OIDC login via Authentik: PKCE, JIT provisioning, email linking (#5) #24
@@ -223,6 +223,12 @@ func writeServiceError(c *gin.Context, err error) {
|
|||||||
writeAPIError(c, http.StatusForbidden, "REGISTRATION_CLOSED", "registration is closed")
|
writeAPIError(c, http.StatusForbidden, "REGISTRATION_CLOSED", "registration is closed")
|
||||||
case errors.Is(err, domain.ErrLocalAuthDisabled):
|
case errors.Is(err, domain.ErrLocalAuthDisabled):
|
||||||
writeAPIError(c, http.StatusForbidden, "LOCAL_AUTH_DISABLED", "local authentication is disabled")
|
writeAPIError(c, http.StatusForbidden, "LOCAL_AUTH_DISABLED", "local authentication is disabled")
|
||||||
|
case errors.Is(err, domain.ErrOIDCNoEmail):
|
||||||
|
writeAPIError(c, http.StatusBadRequest, "OIDC_NO_EMAIL", "the identity provider returned no email")
|
||||||
|
case errors.Is(err, domain.ErrOIDCEmailUnverified):
|
||||||
|
writeAPIError(c, http.StatusForbidden, "OIDC_EMAIL_UNVERIFIED", "the identity provider's email is not verified")
|
||||||
|
case errors.Is(err, domain.ErrOIDCIdentityConflict):
|
||||||
|
writeAPIError(c, http.StatusConflict, "OIDC_IDENTITY_CONFLICT", "this identity conflicts with an existing account")
|
||||||
case errors.Is(err, domain.ErrInvalidInput):
|
case errors.Is(err, domain.ErrInvalidInput):
|
||||||
writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT", "invalid input")
|
writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT", "invalid input")
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"crypto/subtle"
|
"crypto/subtle"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -16,6 +17,7 @@ import (
|
|||||||
"golang.org/x/oauth2"
|
"golang.org/x/oauth2"
|
||||||
|
|
||||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/config"
|
"gitea.stevedudenhoeffer.com/steve/pansy/internal/config"
|
||||||
|
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
|
||||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/service"
|
"gitea.stevedudenhoeffer.com/steve/pansy/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -30,6 +32,10 @@ const (
|
|||||||
// oidcDiscoveryTimeout bounds a single lazy discovery attempt so a hung IdP
|
// oidcDiscoveryTimeout bounds a single lazy discovery attempt so a hung IdP
|
||||||
// can't wedge a request goroutine.
|
// can't wedge a request goroutine.
|
||||||
oidcDiscoveryTimeout = 10 * time.Second
|
oidcDiscoveryTimeout = 10 * time.Second
|
||||||
|
// oidcExchangeTimeout bounds the callback's token exchange + ID-token
|
||||||
|
// verification (which also fetches JWKS) so a slow IdP can't outlast the
|
||||||
|
// server's write timeout and leave the user on a blank page.
|
||||||
|
oidcExchangeTimeout = 15 * time.Second
|
||||||
)
|
)
|
||||||
|
|
||||||
// oidcClient lazily performs OIDC discovery and holds the derived verifier and
|
// oidcClient lazily performs OIDC discovery and holds the derived verifier and
|
||||||
@@ -59,11 +65,14 @@ func newOIDCClient(cfg *config.Config) *oidcClient {
|
|||||||
|
|
||||||
// ensure performs discovery once (idempotent) and builds the verifier + oauth2
|
// ensure performs discovery once (idempotent) and builds the verifier + oauth2
|
||||||
// config. Safe for concurrent callers; a failure leaves the client uninitialized
|
// config. Safe for concurrent callers; a failure leaves the client uninitialized
|
||||||
// so the next request retries.
|
// so the next request retries. The network discovery runs OUTSIDE the mutex, so
|
||||||
|
// concurrent cold-start (or IdP-outage) requests don't serialize behind one
|
||||||
|
// another's full timeout; the first to finish installs the result.
|
||||||
func (o *oidcClient) ensure(ctx context.Context) error {
|
func (o *oidcClient) ensure(ctx context.Context) error {
|
||||||
o.mu.Lock()
|
o.mu.Lock()
|
||||||
defer o.mu.Unlock()
|
ready := o.provider != nil
|
||||||
if o.provider != nil {
|
o.mu.Unlock()
|
||||||
|
if ready {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,6 +83,12 @@ func (o *oidcClient) ensure(ctx context.Context) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
o.mu.Lock()
|
||||||
|
defer o.mu.Unlock()
|
||||||
|
if o.provider != nil {
|
||||||
|
return nil // another goroutine won the race; keep its result.
|
||||||
|
}
|
||||||
o.provider = provider
|
o.provider = provider
|
||||||
o.verifier = provider.Verifier(&oidc.Config{ClientID: o.clientID})
|
o.verifier = provider.Verifier(&oidc.Config{ClientID: o.clientID})
|
||||||
o.oauth = &oauth2.Config{
|
o.oauth = &oauth2.Config{
|
||||||
@@ -96,12 +111,19 @@ type oidcTx struct {
|
|||||||
Nonce string `json:"n"`
|
Nonce string `json:"n"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// redirectAuthError sends the browser back to the login page with an error code
|
||||||
|
// the UI (#6) can render. Codes: oidc_unavailable, state, no_email,
|
||||||
|
// email_unverified, oidc_conflict, oidc (generic).
|
||||||
|
func redirectAuthError(c *gin.Context, code string) {
|
||||||
|
c.Redirect(http.StatusFound, "/login?error="+code)
|
||||||
|
}
|
||||||
|
|
||||||
// oidcLogin starts the authorization-code + PKCE flow: it stashes fresh state,
|
// oidcLogin starts the authorization-code + PKCE flow: it stashes fresh state,
|
||||||
// PKCE verifier, and nonce in a short-lived cookie, then redirects to the IdP.
|
// PKCE verifier, and nonce in a short-lived cookie, then redirects to the IdP.
|
||||||
func (h *handlers) oidcLogin(c *gin.Context) {
|
func (h *handlers) oidcLogin(c *gin.Context) {
|
||||||
if err := h.oidc.ensure(c.Request.Context()); err != nil {
|
if err := h.oidc.ensure(c.Request.Context()); err != nil {
|
||||||
slog.Error("api: oidc discovery failed", "error", err)
|
slog.Error("api: oidc discovery failed", "error", err)
|
||||||
c.Redirect(http.StatusFound, "/login?error=oidc_unavailable")
|
redirectAuthError(c, "oidc_unavailable")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,12 +131,16 @@ func (h *handlers) oidcLogin(c *gin.Context) {
|
|||||||
nonce, err2 := randToken()
|
nonce, err2 := randToken()
|
||||||
if err1 != nil || err2 != nil {
|
if err1 != nil || err2 != nil {
|
||||||
slog.Error("api: oidc token generation failed", "state_err", err1, "nonce_err", err2)
|
slog.Error("api: oidc token generation failed", "state_err", err1, "nonce_err", err2)
|
||||||
c.Redirect(http.StatusFound, "/login?error=oidc")
|
redirectAuthError(c, "oidc")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
verifier := oauth2.GenerateVerifier()
|
verifier := oauth2.GenerateVerifier()
|
||||||
|
|
||||||
h.setOIDCTxCookie(c, oidcTx{State: state, Verifier: verifier, Nonce: nonce})
|
if err := h.setOIDCTxCookie(c, oidcTx{State: state, Verifier: verifier, Nonce: nonce}); err != nil {
|
||||||
|
slog.Error("api: set oidc tx cookie", "error", err)
|
||||||
|
redirectAuthError(c, "oidc")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
authURL := h.oidc.oauth.AuthCodeURL(state,
|
authURL := h.oidc.oauth.AuthCodeURL(state,
|
||||||
oauth2.S256ChallengeOption(verifier),
|
oauth2.S256ChallengeOption(verifier),
|
||||||
@@ -128,15 +154,13 @@ func (h *handlers) oidcLogin(c *gin.Context) {
|
|||||||
// pansy session. Every failure clears the tx cookie and redirects to the login
|
// pansy session. Every failure clears the tx cookie and redirects to the login
|
||||||
// page with an error code rather than leaking details to the browser.
|
// page with an error code rather than leaking details to the browser.
|
||||||
func (h *handlers) oidcCallback(c *gin.Context) {
|
func (h *handlers) oidcCallback(c *gin.Context) {
|
||||||
ctx := c.Request.Context()
|
|
||||||
|
|
||||||
tx, haveTx := h.readOIDCTxCookie(c)
|
tx, haveTx := h.readOIDCTxCookie(c)
|
||||||
h.clearOIDCTxCookie(c)
|
h.clearOIDCTxCookie(c)
|
||||||
|
|
|||||||
|
|
||||||
// A provider-side error (e.g. user denied consent) comes back as ?error=.
|
// A provider-side error (e.g. user denied consent) comes back as ?error=.
|
||||||
if e := c.Query("error"); e != "" {
|
if e := c.Query("error"); e != "" {
|
||||||
slog.Warn("api: oidc provider returned error", "error", e)
|
slog.Warn("api: oidc provider returned error", "error", e)
|
||||||
c.Redirect(http.StatusFound, "/login?error=oidc")
|
redirectAuthError(c, "oidc")
|
||||||
return
|
return
|
||||||
|
gitea-actions
commented
🔴 oauth2.Exchange and idToken.Verify run on the unbounded request context; a slow IdP can exceed the server's WriteTimeout (30s), leaving the user on a blank page with the tx cookie already cleared. Wrap the exchange+verify block in context.WithTimeout. error-handling · flagged by 2 models
🪰 Gadfly · advisory 🔴 **oauth2.Exchange and idToken.Verify run on the unbounded request context; a slow IdP can exceed the server's WriteTimeout (30s), leaving the user on a blank page with the tx cookie already cleared. Wrap the exchange+verify block in context.WithTimeout.**
_error-handling · flagged by 2 models_
- **`internal/api/oidc.go:164` — token exchange and ID-token verification run on the unbounded request context.** `ctx := c.Request.Context()` (line 131) is passed directly to `h.oidc.oauth.Exchange(ctx, ...)` (line 164) and `h.oidc.verifier.Verify(ctx, rawIDToken)` (line 178). Only `ensure()` gets a 10s timeout (line 70); the network calls to the IdP token endpoint and JWKS do not. The server's `WriteTimeout` is 30s (`cmd/pansy/main.go:65`), and the tx cookie is already cleared at line 134 befo…
<sub>🪰 Gadfly · advisory</sub>
|
|||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,45 +169,51 @@ func (h *handlers) oidcCallback(c *gin.Context) {
|
|||||||
state := c.Query("state")
|
state := c.Query("state")
|
||||||
if !haveTx || state == "" || subtle.ConstantTimeCompare([]byte(state), []byte(tx.State)) != 1 {
|
if !haveTx || state == "" || subtle.ConstantTimeCompare([]byte(state), []byte(tx.State)) != 1 {
|
||||||
slog.Warn("api: oidc state mismatch or missing transaction")
|
slog.Warn("api: oidc state mismatch or missing transaction")
|
||||||
c.Redirect(http.StatusFound, "/login?error=state")
|
redirectAuthError(c, "state")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Bound the network work (token exchange + JWKS fetch during verify) so a slow
|
||||||
|
// IdP can't outlast the server's write timeout.
|
||||||
|
ctx, cancel := context.WithTimeout(c.Request.Context(), oidcExchangeTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
if err := h.oidc.ensure(ctx); err != nil {
|
if err := h.oidc.ensure(ctx); err != nil {
|
||||||
slog.Error("api: oidc discovery failed on callback", "error", err)
|
slog.Error("api: oidc discovery failed on callback", "error", err)
|
||||||
c.Redirect(http.StatusFound, "/login?error=oidc_unavailable")
|
redirectAuthError(c, "oidc_unavailable")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
code := c.Query("code")
|
code := c.Query("code")
|
||||||
if code == "" {
|
if code == "" {
|
||||||
c.Redirect(http.StatusFound, "/login?error=oidc")
|
slog.Warn("api: oidc callback missing code")
|
||||||
|
redirectAuthError(c, "oidc")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
token, err := h.oidc.oauth.Exchange(ctx, code, oauth2.VerifierOption(tx.Verifier))
|
token, err := h.oidc.oauth.Exchange(ctx, code, oauth2.VerifierOption(tx.Verifier))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("api: oidc code exchange failed", "error", err)
|
slog.Error("api: oidc code exchange failed", "error", err)
|
||||||
c.Redirect(http.StatusFound, "/login?error=oidc")
|
redirectAuthError(c, "oidc")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
rawIDToken, ok := token.Extra("id_token").(string)
|
rawIDToken, ok := token.Extra("id_token").(string)
|
||||||
if !ok || rawIDToken == "" {
|
if !ok || rawIDToken == "" {
|
||||||
slog.Error("api: oidc token response missing id_token")
|
slog.Error("api: oidc token response missing id_token")
|
||||||
c.Redirect(http.StatusFound, "/login?error=oidc")
|
redirectAuthError(c, "oidc")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
idToken, err := h.oidc.verifier.Verify(ctx, rawIDToken)
|
idToken, err := h.oidc.verifier.Verify(ctx, rawIDToken)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("api: oidc id_token verification failed", "error", err)
|
slog.Error("api: oidc id_token verification failed", "error", err)
|
||||||
c.Redirect(http.StatusFound, "/login?error=oidc")
|
redirectAuthError(c, "oidc")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if subtle.ConstantTimeCompare([]byte(idToken.Nonce), []byte(tx.Nonce)) != 1 {
|
if subtle.ConstantTimeCompare([]byte(idToken.Nonce), []byte(tx.Nonce)) != 1 {
|
||||||
slog.Warn("api: oidc nonce mismatch")
|
slog.Warn("api: oidc nonce mismatch")
|
||||||
|
gitea-actions
commented
🟡 All LoginOIDC provisioning errors collapse to generic error=oidc, discarding distinct sentinels (no-email, unverified-email) the UI could render maintainability · flagged by 1 model
🪰 Gadfly · advisory 🟡 **All LoginOIDC provisioning errors collapse to generic error=oidc, discarding distinct sentinels (no-email, unverified-email) the UI could render**
_maintainability · flagged by 1 model_
- **`internal/api/oidc.go:215` — `LoginOIDC` provisioning failures all collapse to `error=oidc`.** `LoginOIDC` returns distinct sentinels (`ErrOIDCNoEmail`, `ErrOIDCEmailUnverified`, `ErrInvalidInput` — confirmed in `domain/domain.go:39-45`) that the UI could meaningfully distinguish, but the callback maps every one to the generic `?error=oidc` at line 216. This mirrors the existing `writeServiceError` mapping philosophy but discards information that would help the login page show e.g. "email no…
<sub>🪰 Gadfly · advisory</sub>
|
|||||||
c.Redirect(http.StatusFound, "/login?error=oidc")
|
redirectAuthError(c, "oidc")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,7 +225,7 @@ func (h *handlers) oidcCallback(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
if err := idToken.Claims(&claims); err != nil {
|
if err := idToken.Claims(&claims); err != nil {
|
||||||
slog.Error("api: oidc claims decode failed", "error", err)
|
slog.Error("api: oidc claims decode failed", "error", err)
|
||||||
c.Redirect(http.StatusFound, "/login?error=oidc")
|
redirectAuthError(c, "oidc")
|
||||||
return
|
return
|
||||||
|
gitea-actions
commented
🟡 OIDC tx cookie holds PKCE verifier/nonce/state without integrity protection (HMAC/signature); standard pattern but relies on absence of any cookie-injection vector error-handling, maintainability, security · flagged by 2 models
🪰 Gadfly · advisory 🟡 **OIDC tx cookie holds PKCE verifier/nonce/state without integrity protection (HMAC/signature); standard pattern but relies on absence of any cookie-injection vector**
_error-handling, maintainability, security · flagged by 2 models_
- **`internal/api/oidc.go:229-241` — tx cookie is not integrity-protected (low severity).** The `pansy_oidc_tx` cookie is plain base64 JSON holding the PKCE verifier + nonce + state, with no HMAC/signature. The state CSRF check still holds (an attacker can't *read* the victim's cookie, and the cookie is host-only + HttpOnly + SameSite=Lax), and the verifier alone is useless without a matching authorization code, so this is the common "PKCE-in-cookie" pattern rather than a clear hole. The residua…
<sub>🪰 Gadfly · advisory</sub>
|
|||||||
}
|
}
|
||||||
name := claims.Name
|
name := claims.Name
|
||||||
@@ -213,31 +243,47 @@ func (h *handlers) oidcCallback(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Warn("api: oidc provisioning failed", "error", err)
|
slog.Warn("api: oidc provisioning failed", "error", err)
|
||||||
c.Redirect(http.StatusFound, "/login?error=oidc")
|
redirectAuthError(c, oidcProvisionErrorCode(err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := h.startSession(c, user.ID); err != nil {
|
if err := h.startSession(c, user.ID); err != nil {
|
||||||
slog.Error("api: oidc session start failed", "user_id", user.ID, "error", err)
|
slog.Error("api: oidc session start failed", "user_id", user.ID, "error", err)
|
||||||
c.Redirect(http.StatusFound, "/login?error=oidc")
|
redirectAuthError(c, "oidc")
|
||||||
return
|
return
|
||||||
|
gitea-actions
commented
🔴 readOIDCTxCookie does not require tx.Nonce, so an empty nonce in both tx and token passes ConstantTimeCompare (""==""), silently disabling nonce/replay protection. Require tx.Nonce != "" and reject idToken.Nonce == "". correctness, error-handling, maintainability · flagged by 4 models
🪰 Gadfly · advisory 🔴 **readOIDCTxCookie does not require tx.Nonce, so an empty nonce in both tx and token passes ConstantTimeCompare (""==""), silently disabling nonce/replay protection. Require tx.Nonce != "" and reject idToken.Nonce == "".**
_correctness, error-handling, maintainability · flagged by 4 models_
- **`internal/api/oidc.go:253` — `readOIDCTxCookie` does not require `tx.Nonce`.** The validity check at line 253 is `tx.State == "" || tx.Verifier == ""`; `Nonce` is not required. A hand-crafted or stale cookie yielding a tx with empty `Nonce` passes the check, and the nonce compare at line 184 (`ConstantTimeCompare([]byte(idToken.Nonce), []byte(tx.Nonce))`) succeeds whenever the IdP also returns an empty nonce (many IdPs omit the `nonce` claim, leaving `idToken.Nonce == ""`), so the comparison…
<sub>🪰 Gadfly · advisory</sub>
|
|||||||
}
|
}
|
||||||
c.Redirect(http.StatusFound, "/gardens")
|
c.Redirect(http.StatusFound, "/gardens")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// oidcProvisionErrorCode maps a LoginOIDC failure to a login-page error code so
|
||||||
|
// the UI can explain what went wrong instead of showing a generic message.
|
||||||
|
func oidcProvisionErrorCode(err error) string {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, domain.ErrOIDCNoEmail):
|
||||||
|
return "no_email"
|
||||||
|
case errors.Is(err, domain.ErrOIDCEmailUnverified):
|
||||||
|
return "email_unverified"
|
||||||
|
case errors.Is(err, domain.ErrOIDCIdentityConflict):
|
||||||
|
return "oidc_conflict"
|
||||||
|
default:
|
||||||
|
return "oidc"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// setOIDCTxCookie writes the login transaction as a compact base64 JSON cookie.
|
// setOIDCTxCookie writes the login transaction as a compact base64 JSON cookie.
|
||||||
func (h *handlers) setOIDCTxCookie(c *gin.Context, tx oidcTx) {
|
// It returns an error rather than swallowing a marshal failure, so oidcLogin
|
||||||
|
// never redirects to the IdP with no way to complete the callback.
|
||||||
|
func (h *handlers) setOIDCTxCookie(c *gin.Context, tx oidcTx) error {
|
||||||
b, err := json.Marshal(tx)
|
b, err := json.Marshal(tx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// tx holds only our own generated strings, so this cannot fail in practice.
|
return err // tx holds only our own strings, so this can't happen in practice.
|
||||||
slog.Error("api: marshal oidc tx", "error", err)
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
value := base64.RawURLEncoding.EncodeToString(b)
|
value := base64.RawURLEncoding.EncodeToString(b)
|
||||||
// SameSite=Lax so the cookie survives the IdP's top-level redirect back to the
|
// SameSite=Lax so the cookie survives the IdP's top-level redirect back to the
|
||||||
// callback (Strict would drop it on that cross-site navigation).
|
// callback (Strict would drop it on that cross-site navigation).
|
||||||
c.SetSameSite(http.SameSiteLaxMode)
|
c.SetSameSite(http.SameSiteLaxMode)
|
||||||
c.SetCookie(oidcTxCookie, value, int(oidcTxMaxAge.Seconds()), "/", "", h.cookieSecure(), true)
|
c.SetCookie(oidcTxCookie, value, int(oidcTxMaxAge.Seconds()), "/", "", h.cookieSecure(), true)
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *handlers) readOIDCTxCookie(c *gin.Context) (oidcTx, bool) {
|
func (h *handlers) readOIDCTxCookie(c *gin.Context) (oidcTx, bool) {
|
||||||
@@ -250,7 +296,9 @@ func (h *handlers) readOIDCTxCookie(c *gin.Context) (oidcTx, bool) {
|
|||||||
return oidcTx{}, false
|
return oidcTx{}, false
|
||||||
}
|
}
|
||||||
var tx oidcTx
|
var tx oidcTx
|
||||||
if err := json.Unmarshal(raw, &tx); err != nil || tx.State == "" || tx.Verifier == "" {
|
// All three fields must be present: an empty nonce would make the callback's
|
||||||
|
// nonce check pass vacuously against an ID token that omitted the claim.
|
||||||
|
if err := json.Unmarshal(raw, &tx); err != nil || tx.State == "" || tx.Verifier == "" || tx.Nonce == "" {
|
||||||
return oidcTx{}, false
|
return oidcTx{}, false
|
||||||
}
|
}
|
||||||
return tx, true
|
return tx, true
|
||||||
|
|||||||
@@ -39,10 +39,15 @@ var (
|
|||||||
// ErrOIDCNoEmail means the IdP returned no email claim, so no account can be
|
// ErrOIDCNoEmail means the IdP returned no email claim, so no account can be
|
||||||
// provisioned (email is the account's unique key). The email scope is required.
|
// provisioned (email is the account's unique key). The email scope is required.
|
||||||
ErrOIDCNoEmail = errors.New("oidc identity has no email")
|
ErrOIDCNoEmail = errors.New("oidc identity has no email")
|
||||||
// ErrOIDCEmailUnverified means the IdP's email is unverified and it collides
|
// ErrOIDCEmailUnverified means the IdP asserted an email it hasn't verified;
|
||||||
// with an existing account; auto-linking it would enable account takeover, so
|
// pansy won't provision or link on an unverified email (it would enable
|
||||||
// it is refused.
|
// account takeover / squatting).
|
||||||
ErrOIDCEmailUnverified = errors.New("oidc email not verified")
|
ErrOIDCEmailUnverified = errors.New("oidc email not verified")
|
||||||
|
// ErrOIDCIdentityConflict means the OIDC identity can't be attached: either
|
||||||
|
// the target account already carries a different identity (refusing to
|
||||||
|
// overwrite it prevents lockout/takeover) or the (issuer, subject) pair is
|
||||||
|
// already bound to another account. Mapped to 409.
|
||||||
|
ErrOIDCIdentityConflict = errors.New("oidc identity conflict")
|
||||||
)
|
)
|
||||||
|
|
||||||
// Enumerated string values mirrored from the schema CHECK constraints.
|
// Enumerated string values mirrored from the schema CHECK constraints.
|
||||||
|
|||||||
@@ -57,19 +57,23 @@ func (s *Service) Providers() Providers {
|
|||||||
// issuing no session itself (the caller does). The IdP has already gated access,
|
// issuing no session itself (the caller does). The IdP has already gated access,
|
||||||
// so PANSY_REGISTRATION does not apply. Resolution order:
|
// so PANSY_REGISTRATION does not apply. Resolution order:
|
||||||
// 1. an existing user with the same (issuer, subject) — a returning OIDC user;
|
// 1. an existing user with the same (issuer, subject) — a returning OIDC user;
|
||||||
// 2. else an existing user with the same *verified* email — linked to this
|
// 2. else an existing user with the same email — linked to this identity (so
|
||||||
// identity (so one person isn't split across a local and an OIDC account);
|
// one person isn't split across a local and an OIDC account);
|
||||||
// 3. else a new just-in-time account stamped with the identity.
|
// 3. else a new just-in-time account stamped with the identity.
|
||||||
//
|
//
|
||||||
// An unverified email that collides with an existing account is refused (it
|
// Steps 2 and 3 require a verified email: linking on an unverified email would
|
||||||
// would let anyone who can assert that email at the IdP take over the account).
|
// let anyone who can assert that email at an IdP take over an account, and
|
||||||
// An identity with no email can't be provisioned (email is the account key).
|
// JIT-creating on one would let them squat an email a real user later owns
|
||||||
|
// (then the real user's different identity would be refused by LinkOIDC). An
|
||||||
|
// identity with no email can't be provisioned at all (email is the account key).
|
||||||
|
// Returning users (step 1) skip the email check — their identity is already
|
||||||
|
// proven.
|
||||||
func (s *Service) LoginOIDC(ctx context.Context, id OIDCIdentity) (*domain.User, error) {
|
func (s *Service) LoginOIDC(ctx context.Context, id OIDCIdentity) (*domain.User, error) {
|
||||||
if id.Issuer == "" || id.Subject == "" {
|
if id.Issuer == "" || id.Subject == "" {
|
||||||
return nil, domain.ErrInvalidInput
|
return nil, domain.ErrInvalidInput
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. Returning OIDC user.
|
// 1. Returning OIDC user (identity already proven; email state irrelevant).
|
||||||
u, err := s.store.GetUserByOIDC(ctx, id.Issuer, id.Subject)
|
u, err := s.store.GetUserByOIDC(ctx, id.Issuer, id.Subject)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return u, nil
|
return u, nil
|
||||||
@@ -78,18 +82,20 @@ func (s *Service) LoginOIDC(ctx context.Context, id OIDCIdentity) (*domain.User,
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Provisioning (link or create) requires a verified email.
|
||||||
email := normalizeEmail(id.Email)
|
email := normalizeEmail(id.Email)
|
||||||
if email == "" {
|
if email == "" {
|
||||||
return nil, domain.ErrOIDCNoEmail
|
return nil, domain.ErrOIDCNoEmail
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Link to an existing account by email — but only a verified one.
|
|
||||||
existing, err := s.store.GetUserByEmail(ctx, email)
|
|
||||||
switch {
|
|
||||||
case err == nil:
|
|
||||||
if !id.EmailVerified {
|
if !id.EmailVerified {
|
||||||
return nil, domain.ErrOIDCEmailUnverified
|
return nil, domain.ErrOIDCEmailUnverified
|
||||||
}
|
}
|
||||||
|
|
||||||
|
gitea-actions
commented
🔴 LinkOIDC silently overwrites existing OIDC identity on email-linked user correctness · flagged by 2 models
🪰 Gadfly · advisory 🔴 **LinkOIDC silently overwrites existing OIDC identity on email-linked user**
_correctness · flagged by 2 models_
* **`internal/service/auth.go:93` — `LinkOIDC` silently overwrites an existing OIDC identity** `LoginOIDC` resolves a user by email and unconditionally calls `LinkOIDC`, which executes an `UPDATE` that stomps any existing `oidc_issuer` / `oidc_subject` on that row. The comment on `LinkOIDC` says it is meant for "first OIDC login for a pre-existing local account", but the code does not check that the matched user is local-only. If Alice originally logged in via IdP A (so her row already has issue…
<sub>🪰 Gadfly · advisory</sub>
|
|||||||
|
// 2. Link to an existing account by email. LinkOIDC refuses to overwrite a
|
||||||
|
// different stored identity (returns ErrOIDCIdentityConflict).
|
||||||
|
existing, err := s.store.GetUserByEmail(ctx, email)
|
||||||
|
switch {
|
||||||
|
case err == nil:
|
||||||
return s.store.LinkOIDC(ctx, existing.ID, id.Issuer, id.Subject)
|
return s.store.LinkOIDC(ctx, existing.ID, id.Issuer, id.Subject)
|
||||||
case !errors.Is(err, domain.ErrNotFound):
|
case !errors.Is(err, domain.ErrNotFound):
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -290,7 +290,7 @@ func TestProvidersReflectsConfig(t *testing.T) {
|
|||||||
// OIDC configured with a base URL → reported ready.
|
// OIDC configured with a base URL → reported ready.
|
||||||
ready := openConfig()
|
ready := openConfig()
|
||||||
ready.BaseURL = "https://pansy.example.com"
|
ready.BaseURL = "https://pansy.example.com"
|
||||||
ready.OIDC = config.OIDCConfig{Issuer: "https://idp.example", ClientID: "cid", ButtonLabel: "Sign in with Authentik"}
|
ready.OIDC = config.OIDCConfig{Issuer: testOIDCIssuer, ClientID: "cid", ButtonLabel: "Sign in with Authentik"}
|
||||||
if got := newTestService(t, ready).Providers(); !got.OIDC || got.OIDCLabel != "Sign in with Authentik" {
|
if got := newTestService(t, ready).Providers(); !got.OIDC || got.OIDCLabel != "Sign in with Authentik" {
|
||||||
t.Errorf("providers = %+v, want oidc=true with Authentik label", got)
|
t.Errorf("providers = %+v, want oidc=true with Authentik label", got)
|
||||||
}
|
}
|
||||||
@@ -303,8 +303,11 @@ func TestProvidersReflectsConfig(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// testOIDCIssuer is the issuer URL used across the OIDC service tests.
|
||||||
|
const testOIDCIssuer = "https://idp.example"
|
||||||
|
|
||||||
func oidcIdentity(sub, email, name string, verified bool) OIDCIdentity {
|
func oidcIdentity(sub, email, name string, verified bool) OIDCIdentity {
|
||||||
return OIDCIdentity{Issuer: "https://idp.example", Subject: sub, Email: email, EmailVerified: verified, Name: name}
|
return OIDCIdentity{Issuer: testOIDCIssuer, Subject: sub, Email: email, EmailVerified: verified, Name: name}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLoginOIDCJITProvisionsThenReturnsSameUser(t *testing.T) {
|
func TestLoginOIDCJITProvisionsThenReturnsSameUser(t *testing.T) {
|
||||||
@@ -351,14 +354,47 @@ func TestLoginOIDCLinksExistingLocalAccount(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLoginOIDCRefusesUnverifiedEmailCollision(t *testing.T) {
|
func TestLoginOIDCRefusesUnverifiedEmail(t *testing.T) {
|
||||||
s := newTestService(t, openConfig())
|
s := newTestService(t, openConfig())
|
||||||
mustRegister(t, s, "[email protected]", "Carol", "password123")
|
|
||||||
|
|
||||||
_, err := s.LoginOIDC(context.Background(), oidcIdentity("sub-3", "[email protected]", "Carol", false))
|
// Unverified email colliding with an existing account is refused (takeover).
|
||||||
if !errors.Is(err, domain.ErrOIDCEmailUnverified) {
|
mustRegister(t, s, "[email protected]", "Carol", "password123")
|
||||||
|
if _, err := s.LoginOIDC(context.Background(), oidcIdentity("sub-3", "[email protected]", "Carol", false)); !errors.Is(err, domain.ErrOIDCEmailUnverified) {
|
||||||
t.Errorf("unverified collision err = %v, want ErrOIDCEmailUnverified", err)
|
t.Errorf("unverified collision err = %v, want ErrOIDCEmailUnverified", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Unverified email with NO collision is also refused (can't JIT-provision on
|
||||||
|
// an unverified email — it could squat an address a real user later owns, and
|
||||||
|
// could make an unverified identity the first admin).
|
||||||
|
if _, err := s.LoginOIDC(context.Background(), oidcIdentity("sub-3b", "[email protected]", "Fresh", false)); !errors.Is(err, domain.ErrOIDCEmailUnverified) {
|
||||||
|
t.Errorf("unverified JIT err = %v, want ErrOIDCEmailUnverified", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoginOIDCRefusesOverwritingDifferentIdentity(t *testing.T) {
|
||||||
|
s := newTestService(t, openConfig())
|
||||||
|
|
||||||
|
// A user links identity A.
|
||||||
|
first, err := s.LoginOIDC(context.Background(), oidcIdentity("sub-A", "[email protected]", "Dana", true))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("initial link: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A different identity asserting the same verified email must NOT overwrite
|
||||||
|
// the stored identity (that would hijack/lock out the account).
|
||||||
|
_, err = s.LoginOIDC(context.Background(), oidcIdentity("sub-B", "[email protected]", "Dana", true))
|
||||||
|
if !errors.Is(err, domain.ErrOIDCIdentityConflict) {
|
||||||
|
t.Fatalf("overwrite attempt err = %v, want ErrOIDCIdentityConflict", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The original identity still works and still points at the same account.
|
||||||
|
again, err := s.LoginOIDC(context.Background(), oidcIdentity("sub-A", "[email protected]", "Dana", true))
|
||||||
|
if err != nil || again.ID != first.ID {
|
||||||
|
t.Errorf("original identity broken: user=%v err=%v", again, err)
|
||||||
|
}
|
||||||
|
if again.OIDCSubject == nil || *again.OIDCSubject != "sub-A" {
|
||||||
|
t.Errorf("stored identity was overwritten: %v", again.OIDCSubject)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLoginOIDCRequiresEmail(t *testing.T) {
|
func TestLoginOIDCRequiresEmail(t *testing.T) {
|
||||||
|
|||||||
@@ -120,26 +120,37 @@ func (d *DB) GetUserByOIDC(ctx context.Context, issuer, subject string) (*domain
|
|||||||
}
|
}
|
||||||
|
|
||||||
// LinkOIDC stamps an OIDC identity onto an existing user (first OIDC login for a
|
// LinkOIDC stamps an OIDC identity onto an existing user (first OIDC login for a
|
||||||
// pre-existing local account) and returns the updated row. A collision with
|
// pre-existing local account) and returns the updated row.
|
||||||
// another user's identity pair trips the UNIQUE index and maps to ErrEmailTaken
|
//
|
||||||
// as a generic conflict (it shouldn't happen — the caller looks up by identity
|
// The UPDATE only matches when the account carries no identity yet, or already
|
||||||
// first — but the index is the backstop).
|
// carries this exact one (idempotent) — it will NOT overwrite a different stored
|
||||||
|
// identity, which would let anyone asserting the same email at a second IdP
|
||||||
|
// hijack or lock out the account. A no-match (different identity, or the row is
|
||||||
|
// gone) and a UNIQUE (issuer, subject) collision with another account both
|
||||||
|
// surface as domain.ErrOIDCIdentityConflict. RETURNING folds the read-back into
|
||||||
|
// the same statement.
|
||||||
func (d *DB) LinkOIDC(ctx context.Context, userID int64, issuer, subject string) (*domain.User, error) {
|
func (d *DB) LinkOIDC(ctx context.Context, userID int64, issuer, subject string) (*domain.User, error) {
|
||||||
_, err := d.sql.ExecContext(ctx,
|
u, err := scanUser(d.sql.QueryRowContext(ctx,
|
||||||
`UPDATE users
|
`UPDATE users
|
||||||
SET oidc_issuer = ?, oidc_subject = ?,
|
SET oidc_issuer = ?, oidc_subject = ?,
|
||||||
version = version + 1,
|
version = version + 1,
|
||||||
updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
|
updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
|
||||||
|
gitea-actions
commented
🟡 LinkOIDC returns ErrEmailTaken for OIDC identity collision maintainability · flagged by 4 models
🪰 Gadfly · advisory 🟡 **LinkOIDC returns ErrEmailTaken for OIDC identity collision**
_maintainability · flagged by 4 models_
- **`internal/store/users.go:137` — `LinkOIDC` returns semantically wrong error on OIDC identity collision** A UNIQUE violation on `(oidc_issuer, oidc_subject)` is mapped to `domain.ErrEmailTaken`, whose name and message ("email already registered") describe an entirely different collision. The comment calls this a "generic conflict," but no generic conflict sentinel exists. Adding a dedicated `ErrOIDCIdentityTaken` (or similar) would make the code self-documenting and prevent future confusion i…
<sub>🪰 Gadfly · advisory</sub>
|
|||||||
WHERE id = ?`,
|
WHERE id = ?
|
||||||
issuer, subject, userID,
|
AND (oidc_subject IS NULL OR (oidc_issuer = ? AND oidc_subject = ?))
|
||||||
)
|
RETURNING `+userColumns,
|
||||||
|
issuer, subject, userID, issuer, subject,
|
||||||
|
))
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
// The account already has a different identity (or no longer exists).
|
||||||
|
return nil, domain.ErrOIDCIdentityConflict
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if isUniqueViolation(err) {
|
if isUniqueViolation(err) {
|
||||||
return nil, domain.ErrEmailTaken
|
return nil, domain.ErrOIDCIdentityConflict
|
||||||
}
|
}
|
||||||
return nil, fmt.Errorf("store: link oidc: %w", err)
|
return nil, fmt.Errorf("store: link oidc: %w", err)
|
||||||
}
|
}
|
||||||
return d.GetUserByID(ctx, userID)
|
return u, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// CountUsers returns the number of user rows. Used to decide first-user-is-admin
|
// CountUsers returns the number of user rows. Used to decide first-user-is-admin
|
||||||
|
|||||||
⚪ empty-code failure path skips the slog call every other branch has, an inconsistency from the duplication above
maintainability · flagged by 1 model
internal/api/oidc.go:130-226—oidcCallbackrepeats the same three-line shape (slog.X(...);c.Redirect(http.StatusFound, "/login?error=...");return) across essentially every failure branch. Verified by reading the full function: failure exits occur at lines 137-141, 145-150, 152-156, 158-162, 164-169, 171-176, 178-183, 184-188, 196-200, 214-218, and 220-224 — eleven branches total, ten of which follow the log+redirect+return shape (the code == "" branch at 158-162 is the exception,…🪰 Gadfly · advisory