package oidc import ( "context" "errors" "fmt" "net/http" "sync" "time" gooidc "github.com/coreos/go-oidc/v3/oidc" "golang.org/x/oauth2" "git.ryuvia.com/niklas/terdut-server/internal/config" ) // CallbackPath is where the provider sends the browser back to. Register // /api/oidc/callback as the redirect URI at the provider. const CallbackPath = "/api/oidc/callback" // Identity is what the provider says about somebody who has just signed in. type Identity struct { Issuer string Subject string Username string Email string // EmailVerified is the provider's own claim. Whether to believe it is // config.OIDC.TrustEmail's business, not this package's. EmailVerified bool Groups []string } // Provider runs the authorization-code flow with PKCE against one issuer. type Provider struct { cfg config.OIDC redirectURL string http *http.Client // Discovery is fetched on first use, not at startup. A provider that is // down when terdut starts must not stop terdut starting: password login is // the way in while it is down, and it can only be that if the server is up. mu sync.Mutex provider *gooidc.Provider } // New returns a Provider for cfg. publicURL is the base of the redirect URI. func New(cfg config.OIDC, publicURL string) *Provider { return &Provider{ cfg: cfg, redirectURL: trimSlash(publicURL) + CallbackPath, http: &http.Client{Timeout: 10 * time.Second}, } } func trimSlash(s string) string { for len(s) > 0 && s[len(s)-1] == '/' { s = s[:len(s)-1] } return s } // Name is what the sign-in button calls the provider. func (p *Provider) Name() string { return p.cfg.Name } // Config is the configuration this provider was built from. func (p *Provider) Config() config.OIDC { return p.cfg } // discover returns the provider's metadata, fetching it if need be. A failure is // not cached, so the next login tries again. func (p *Provider) discover(ctx context.Context) (*gooidc.Provider, error) { p.mu.Lock() defer p.mu.Unlock() if p.provider != nil { return p.provider, nil } ctx = gooidc.ClientContext(ctx, p.http) prov, err := gooidc.NewProvider(ctx, p.cfg.Issuer) if err != nil { return nil, fmt.Errorf("oidc discovery: %w", err) } p.provider = prov return prov, nil } func (p *Provider) oauth(prov *gooidc.Provider) *oauth2.Config { return &oauth2.Config{ ClientID: p.cfg.ClientID, ClientSecret: p.cfg.ClientSecret, Endpoint: prov.Endpoint(), RedirectURL: p.redirectURL, Scopes: p.cfg.Scopes, } } // NewVerifier returns a fresh PKCE code verifier. func NewVerifier() string { return oauth2.GenerateVerifier() } // AuthURL is where to send the browser to sign in. func (p *Provider) AuthURL(ctx context.Context, state, nonce, verifier string) (string, error) { prov, err := p.discover(ctx) if err != nil { return "", err } return p.oauth(prov).AuthCodeURL(state, oauth2.S256ChallengeOption(verifier), gooidc.Nonce(nonce), ), nil } // Exchange trades the authorization code for tokens, verifies the ID token // (signature, issuer, audience, expiry and nonce) and returns who it names. func (p *Provider) Exchange(ctx context.Context, code, verifier, nonce string) (*Identity, error) { prov, err := p.discover(ctx) if err != nil { return nil, err } ctx = gooidc.ClientContext(ctx, p.http) tok, err := p.oauth(prov).Exchange(ctx, code, oauth2.VerifierOption(verifier)) if err != nil { return nil, fmt.Errorf("oidc token exchange: %w", err) } raw, _ := tok.Extra("id_token").(string) if raw == "" { return nil, errors.New("oidc: token response has no id_token") } idToken, err := prov.Verifier(&gooidc.Config{ClientID: p.cfg.ClientID}).Verify(ctx, raw) if err != nil { return nil, fmt.Errorf("oidc: verify id_token: %w", err) } if idToken.Nonce != nonce { return nil, errors.New("oidc: id_token nonce mismatch") } var claims map[string]any if err := idToken.Claims(&claims); err != nil { return nil, fmt.Errorf("oidc: read claims: %w", err) } return p.identity(idToken.Issuer, idToken.Subject, claims), nil } // identity maps raw claims onto an Identity using the configured claim names. func (p *Provider) identity(issuer, subject string, claims map[string]any) *Identity { id := &Identity{Issuer: issuer, Subject: subject} id.Username, _ = claims[p.cfg.UsernameClaim].(string) id.Email, _ = claims[p.cfg.EmailClaim].(string) id.EmailVerified, _ = claims["email_verified"].(bool) id.Groups = stringList(claims[p.cfg.GroupsClaim]) return id } // stringList reads a claim that is a list of strings, or a single string, which // some providers send for a one-element list. func stringList(v any) []string { switch t := v.(type) { case string: if t == "" { return nil } return []string{t} case []any: out := make([]string, 0, len(t)) for _, e := range t { if s, ok := e.(string); ok && s != "" { out = append(out, s) } } return out } return nil }