// Package auth implements the browser login flow against an OpenID Connect // issuer: authorization code + PKCE (S256), discovery-driven endpoints, and // RS256 ID token verification via JWKS. The signed-in identity is kept in an // HMAC-protected session cookie (cookie.go) — tokens never reach the browser. package auth import ( "context" "crypto/rand" "encoding/base64" "errors" "fmt" "time" "github.com/coreos/go-oidc/v3/oidc" "golang.org/x/oauth2" ) // txTTL bounds the login transaction: the gap between redirecting to the // issuer and the callback that completes it. const txTTL = 10 * time.Minute // Config identifies this app to the issuer. Scopes defaults to // ["openid","profile","email"] when empty. type Config struct { Issuer string ClientID string ClientSecret string Scopes []string } // Client is the configured OIDC relying party. Discovery happens once in // New; the client is immutable and safe for concurrent use. type Client struct { oauth oauth2.Config verifier *oidc.IDTokenVerifier } // New discovers the issuer's endpoints and returns a ready client. func New(ctx context.Context, cfg Config) (*Client, error) { provider, err := oidc.NewProvider(ctx, cfg.Issuer) if err != nil { return nil, fmt.Errorf("discover issuer %s: %w", cfg.Issuer, err) } if len(cfg.Scopes) == 0 { cfg.Scopes = []string{"openid", "profile", "email"} } return &Client{ oauth: oauth2.Config{ ClientID: cfg.ClientID, ClientSecret: cfg.ClientSecret, Endpoint: provider.Endpoint(), Scopes: cfg.Scopes, }, verifier: provider.Verifier(&oidc.Config{ClientID: cfg.ClientID}), }, nil } // User is the verified identity established by a completed login. type User struct { Sub string Name string Email string Roles []string } // LoginTx is the per-login state kept in a short-lived cookie between the // redirect to the issuer and the callback: state and nonce guard against // CSRF and replay, the verifier carries PKCE, ReturnTo is where the browser // lands afterwards. type LoginTx struct { State string Nonce string Verifier string ReturnTo string ExpiresAt time.Time } // NewLoginTx creates a login transaction with fresh state, nonce, and PKCE // verifier for the given post-login path. func NewLoginTx(returnTo string) (LoginTx, error) { state, err := randomToken(16) if err != nil { return LoginTx{}, err } nonce, err := randomToken(16) if err != nil { return LoginTx{}, err } verifier, err := randomToken(48) if err != nil { return LoginTx{}, err } return LoginTx{ State: state, Nonce: nonce, Verifier: verifier, ReturnTo: returnTo, ExpiresAt: time.Now().Add(txTTL), }, nil } // Valid reports whether the transaction is still within its TTL. func (tx LoginTx) Valid() bool { return time.Now().Before(tx.ExpiresAt) } // AuthCodeURL builds the issuer's authorization URL for tx — PKCE challenge // and nonce included. redirectURI is the callback URL on the origin the // browser came from (it must be registered with the issuer). func (c *Client) AuthCodeURL(tx LoginTx, redirectURI string) string { return c.oauth.AuthCodeURL(tx.State, oauth2.SetAuthURLParam("redirect_uri", redirectURI), oauth2.SetAuthURLParam("nonce", tx.Nonce), oauth2.S256ChallengeOption(tx.Verifier), ) } // CompleteCode exchanges the authorization code (PKCE verifier from tx) and // verifies the returned ID token — signature via JWKS, plus iss/aud/exp and // the nonce — returning the signed-in user. func (c *Client) CompleteCode(ctx context.Context, code, redirectURI string, tx LoginTx) (User, error) { tok, err := c.oauth.Exchange(ctx, code, oauth2.SetAuthURLParam("redirect_uri", redirectURI), oauth2.VerifierOption(tx.Verifier), ) if err != nil { return User{}, fmt.Errorf("exchange authorization code: %w", err) } rawIDToken, ok := tok.Extra("id_token").(string) if !ok || rawIDToken == "" { return User{}, errors.New("token response carries no id_token") } idToken, err := c.verifier.Verify(ctx, rawIDToken) if err != nil { return User{}, fmt.Errorf("verify id_token: %w", err) } if idToken.Nonce != tx.Nonce { return User{}, errors.New("id_token nonce does not match the login transaction") } var claims struct { Name string `json:"name"` Email string `json:"email"` Roles []string `json:"roles"` } if err := idToken.Claims(&claims); err != nil { return User{}, fmt.Errorf("decode id_token claims: %w", err) } return User{Sub: idToken.Subject, Name: claims.Name, Email: claims.Email, Roles: claims.Roles}, nil } // randomToken returns a URL-safe random string with n bytes of entropy. func randomToken(n int) (string, error) { b := make([]byte, n) if _, err := rand.Read(b); err != nil { return "", fmt.Errorf("read randomness: %w", err) } return base64.RawURLEncoding.EncodeToString(b), nil }