package auth import ( "context" "crypto/rand" "crypto/sha256" "crypto/subtle" "crypto/tls" "encoding/base64" "encoding/hex" "fmt" "net/http" "sort" "strings" "time" "github.com/coreos/go-oidc/v3/oidc" "golang.org/x/oauth2" "github.com/urpi/go-bananas/internal/config" "github.com/urpi/go-bananas/internal/domain" ) // OIDCProvider implements Resolver against any OpenID Connect provider, // including the auth.lcl-urpi.be realm used for local development. // // Everything provider-specific comes from the issuer's discovery document, so // the same code works against Keycloak, Authentik, Zitadel or Google without a // configuration change. The only per-provider values are the client // credentials and the group-to-role mapping. type OIDCProvider struct { config config.Config provider *oidc.Provider verifier *oidc.IDTokenVerifier oauth *oauth2.Config state *StateStore } // NewOIDCProvider performs discovery against the configured issuer and returns // a ready provider. Discovery happens eagerly so that a misconfigured issuer // fails at start-up rather than on the first login, when the failure would be // hardest to diagnose. func NewOIDCProvider(ctx context.Context, cfg config.Config, state *StateStore) (*OIDCProvider, error) { if cfg.OIDCIssuer == "" { return nil, fmt.Errorf("%w: OIDC_ISSUER is required", domain.ErrInvalid) } provider, err := newProvider(ctx, cfg) if err != nil { return nil, err } p := &OIDCProvider{ config: cfg, provider: provider, state: state, } scopes := append([]string{oidc.ScopeOpenID}, cfg.OIDCScopes...) p.oauth = &oauth2.Config{ ClientID: cfg.OIDCClientID, ClientSecret: cfg.OIDCClientSecret, RedirectURL: cfg.RedirectURL(), Scopes: scopes, Endpoint: provider.Endpoint(), } p.verifier = provider.Verifier(&oidc.Config{ ClientID: cfg.OIDCClientID, // Keycloak and Authentik both support discovery of the JWKS; skipping // the audience check is not done because a token minted for another // client in the same realm must not be accepted here. }) return p, nil } // newProvider runs discovery with a bounded timeout so that an unreachable // issuer produces a clear error instead of hanging start-up. func newProvider(ctx context.Context, cfg config.Config) (*oidc.Provider, error) { ctx, cancel := context.WithTimeout(ctx, 10*time.Second) defer cancel() // ClientContext carries the custom HTTP client through to discovery, which // is how the TLS-skip development switch and the request timeout reach the // provider's HTTP calls. ctx = oidc.ClientContext(ctx, newProviderHTTPClient(cfg)) provider, err := oidc.NewProvider(ctx, cfg.OIDCIssuer) if err != nil { return nil, fmt.Errorf("OIDC discovery against %s failed: %w", cfg.OIDCIssuer, err) } return provider, nil } // newProviderHTTPClient builds the HTTP client used for provider traffic. // // The timeout matters: without one, an unresponsive identity provider would hold // a request open until the client's own timeout, and a stalled provider would // look like a hung application. func newProviderHTTPClient(cfg config.Config) *http.Client { transport := http.DefaultTransport.(*http.Transport).Clone() transport.MaxIdleConnsPerHost = 4 if cfg.OIDCSkipTLSVerify { transport.TLSClientConfig = insecureTLSConfig() } return &http.Client{ Transport: transport, Timeout: 15 * time.Second, } } // Enabled reports that this resolver can authenticate. func (p *OIDCProvider) Enabled() bool { return p != nil && p.provider != nil } // Begin starts a login by redirecting the browser to the provider. The return // path and PKCE verifier are carried in a signed cookie rather than // server-side state so that any replica can complete the flow. func (p *OIDCProvider) Begin(_ context.Context, req *http.Request, w http.ResponseWriter, returnTo string) error { verifier, err := randomVerifier() if err != nil { return err } nonce, err := randomNonce() if err != nil { return err } state, err := randomState() if err != nil { return err } // The transaction is stored before the redirect, and stores the state it was // built with, so that the callback can prove the response belongs to this // browser's login attempt. if err := p.state.Put(w, verifier, SafeReturnTo(returnTo), nonce, state); err != nil { return err } opts := []oauth2.AuthCodeOption{ // PKCE with S256. The verifier lives in a signed HttpOnly cookie, so the // code is bound to the browser that started the flow. oauth2.SetAuthURLParam("code_challenge", codeChallenge(verifier)), oauth2.SetAuthURLParam("code_challenge_method", "S256"), oauth2.SetAuthURLParam("nonce", nonce), oauth2.SetAuthURLParam("state", state), } http.Redirect(w, req, p.oauth.AuthCodeURL(state, opts...), http.StatusFound) return nil } // Complete finishes a login. // // The transaction cookie is the anchor: the state the provider echoes back is // compared against the state this service minted for this browser, and the PKCE // verifier and nonce both come from the same cookie. A callback that carries a // foreign code or a replayed state is rejected before the code is ever exchanged. func (p *OIDCProvider) Complete(ctx context.Context, req *http.Request) (*Login, error) { if err := req.ParseForm(); err != nil { return nil, fmt.Errorf("parse callback form: %w", err) } if providerErr := req.Form.Get("error"); providerErr != "" { desc := req.Form.Get("error_description") return nil, fmt.Errorf("identity provider returned %q: %s", providerErr, desc) } tx, err := p.state.Get(req) if err != nil { return nil, err } returnedState := req.Form.Get("state") if returnedState == "" { return nil, fmt.Errorf("%w: callback is missing the state parameter", domain.ErrUnauthorized) } // Constant time, so that a mismatching state cannot be discovered a byte at a // time. The state is public, but the comparison is free to be careful. if subtle.ConstantTimeCompare([]byte(returnedState), []byte(tx.State)) != 1 { return nil, fmt.Errorf("%w: callback state does not match the login transaction", domain.ErrUnauthorized) } code := req.Form.Get("code") if code == "" { return nil, fmt.Errorf("%w: callback is missing the authorization code", domain.ErrUnauthorized) } token, err := p.oauth.Exchange(ctx, code, oauth2.VerifierOption(tx.Verifier)) if err != nil { return nil, fmt.Errorf("exchange authorization code: %w", err) } rawIDToken, ok := token.Extra("id_token").(string) if !ok || rawIDToken == "" { return nil, fmt.Errorf("%w: provider returned no id_token", domain.ErrUnauthorized) } idToken, err := p.verifier.Verify(ctx, rawIDToken) if err != nil { return nil, fmt.Errorf("verify id_token: %w", err) } if idToken.Nonce != tx.Nonce { return nil, fmt.Errorf("%w: id_token nonce mismatch", domain.ErrUnauthorized) } claims := struct { Subject string `json:"sub"` Email string `json:"email"` EmailVerified bool `json:"email_verified"` Name string `json:"name"` PreferredUser string `json:"preferred_username"` Groups []string `json:"groups"` RealmRoles []string `json:"realm_access.roles"` }{} if err := idToken.Claims(&claims); err != nil { return nil, fmt.Errorf("decode id_token claims: %w", err) } identity := &Identity{ Subject: claims.Subject, Email: claims.Email, Name: firstNonEmpty(claims.Name, claims.PreferredUser, claims.Email), Method: "oidc", } if identity.Subject == "" { return nil, fmt.Errorf("%w: id_token has no subject", domain.ErrUnauthorized) } identity.Roles = p.rolesFor(claims.Groups, claims.RealmRoles) return &Login{Identity: identity, ReturnTo: tx.ReturnTo}, nil } // rolesFor maps provider groups onto application roles. // // The most privileged matching group wins, and a user in no configured group // gets viewer. The default is deliberately the least privilege: a group mapping // mistake must fail closed. func (p *OIDCProvider) rolesFor(groups, realmRoles []string) []domain.Role { all := make(map[string]struct{}, len(groups)+len(realmRoles)) for _, g := range groups { all[strings.ToLower(strings.TrimSpace(g))] = struct{}{} } for _, r := range realmRoles { all[strings.ToLower(strings.TrimSpace(r))] = struct{}{} } matches := func(configured []string) bool { for _, want := range configured { if _, ok := all[strings.ToLower(strings.TrimSpace(want))]; ok { return true } } return false } switch { case matches(p.config.OIDCAdminGroups): return []domain.Role{domain.RoleAdmin} case matches(p.config.OIDCEditorGroups): return []domain.Role{domain.RoleEditor} case matches(p.config.OIDCViewerGroups): return []domain.Role{domain.RoleViewer} default: return []domain.Role{domain.RoleViewer} } } // End returns the provider's end-session URL. func (p *OIDCProvider) End(_ context.Context, redirect string) string { return p.LogoutURL(redirect) } // LogoutURL builds the provider end-session URL. func (p *OIDCProvider) LogoutURL(redirect string) string { if redirect == "" { return "" } endSession := p.provider.Endpoint() // Keycloak, Authentik and Zitadel all accept the post_logout_redirect_uri // parameter on the end-session endpoint. A provider that does not // implement it ignores the parameter and lands the user on its own logout // confirmation page, which is a safe failure mode. if !strings.Contains(endSession.AuthURL, "/end-session") { return endSession.AuthURL } sep := "?" if strings.Contains(endSession.AuthURL, "?") { sep = "&" } return endSession.AuthURL + sep + "post_logout_redirect_uri=" + urlQueryEscape(redirect) + "&client_id=" + urlQueryEscape(p.config.OIDCClientID) } // Issuer returns the configured issuer, used by the admin UI to show which // provider is in use. func (p *OIDCProvider) Issuer() string { return p.config.OIDCIssuer } // RedirectURI returns the configured callback URL. func (p *OIDCProvider) RedirectURI() string { return p.config.RedirectURL() } // Claims returns the claims supported by this provider, for documentation. func (p *OIDCProvider) Claims() []string { list := []string{"sub", "email", "name", "preferred_username", "groups", "realm_access.roles"} sort.Strings(list) return list } // ClearState expires the transaction cookie. The HTTP layer calls it after // Complete so that a replayed callback cannot reuse a consumed verifier. func (p *OIDCProvider) ClearState(w http.ResponseWriter) { p.state.Clear(w) } // ---------------------------------------------------------------- helpers -- // randomVerifier mints a PKCE verifier. func randomVerifier() (string, error) { return randomToken(32) } // randomState mints an anti-forgery state value. func randomState() (string, error) { return randomToken(24) } // randomNonce mints an ID token nonce. func randomNonce() (string, error) { return randomToken(16) } // randomToken returns a URL-safe random string of n bytes. func randomToken(n int) (string, error) { buf := make([]byte, n) if _, err := rand.Read(buf); err != nil { return "", fmt.Errorf("read random: %w", err) } return base64.RawURLEncoding.EncodeToString(buf), nil } // codeChallenge returns the S256 PKCE challenge for a verifier. func codeChallenge(verifier string) string { sum := sha256.Sum256([]byte(verifier)) return base64.RawURLEncoding.EncodeToString(sum[:]) } // firstNonEmpty returns the first non-empty string. func firstNonEmpty(values ...string) string { for _, v := range values { if strings.TrimSpace(v) != "" { return v } } return "" } // urlQueryEscape percent-encodes a query parameter value. func urlQueryEscape(s string) string { var b strings.Builder for _, b0 := range []byte(s) { switch { case b0 >= 'a' && b0 <= 'z', b0 >= 'A' && b0 <= 'Z', b0 >= '0' && b0 <= '9', b0 == '-', b0 == '_', b0 == '.', b0 == '~': b.WriteByte(b0) default: b.WriteByte('%') b.WriteString(strings.ToUpper(hex.EncodeToString([]byte{b0}))) } } return b.String() } // insecureTLSConfig returns a TLS configuration that skips verification. // // It exists so that a development-only identity provider presenting a // self-signed certificate can be used without wiring a CA into the image. // config.Validate refuses to start when this is enabled outside development. func insecureTLSConfig() *tls.Config { //nolint:gosec // InsecureSkipVerify is intentional and gated by config.Validate. return &tls.Config{InsecureSkipVerify: true} }