// Package auth also implements federated sign-in. // // The flow is OAuth 2.0 Authorization Code with PKCE, upgraded to OpenID // Connect when the provider offers an issuer. Everything the provider returns // is treated as untrusted input and validated before it becomes an identity. package auth import ( "context" "crypto/rand" "crypto/sha256" "crypto/subtle" "encoding/base64" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "strings" "sync" "time" ) // OAuth errors. As with tokens, the caller learns that sign-in failed and the // specifics go to the log. var ( ErrOAuthDisabled = errors.New("federated sign-in is not configured") ErrOAuthState = errors.New("sign-in state did not match") ErrOAuthProvider = errors.New("the identity provider rejected the request") ErrOAuthIdentity = errors.New("the identity provider returned an unusable identity") ErrOAuthNotWanted = errors.New("this account is not permitted to sign in") ) // maxProviderBody bounds what will be read from the provider. A provider is // trusted for identity, not for restraint. const maxProviderBody = 1 << 20 // OAuthConfig is what the service needs to talk to the provider. type OAuthConfig struct { Enabled bool ProviderName string Issuer string AuthorizeURL string TokenURL string UserInfoURL string JWKSURL string ClientID string ClientSecret string RedirectURI string Scopes []string AllowedEmailDomains []string Timeout time.Duration } // OAuthService performs the authorization-code exchange. type OAuthService struct { cfg OAuthConfig client *http.Client // discovery is resolved once, lazily, and cached. A provider's endpoints do // not change between requests. discoOnce sync.Once discoErr error jwks *jwksCache } // NewOAuthService builds the service. It performs no network I/O: a provider // that is unreachable at startup must not stop Relata from starting, because // the break-glass local login lives in the same process. func NewOAuthService(cfg OAuthConfig) *OAuthService { if cfg.Timeout <= 0 { cfg.Timeout = 15 * time.Second } if cfg.ProviderName == "" { cfg.ProviderName = "your identity provider" } c := &http.Client{ Timeout: cfg.Timeout, // The provider is configured, not supplied by a user, but a redirect // chain is still somewhere a token exchange should not wander. Three is // enough for a provider behind a load balancer. CheckRedirect: func(req *http.Request, via []*http.Request) error { if len(via) >= 3 { return errors.New("too many redirects from the identity provider") } return nil }, } return &OAuthService{cfg: cfg, client: c, jwks: newJWKSCache(c)} } // Enabled reports whether federated sign-in is available. func (s *OAuthService) Enabled() bool { return s != nil && s.cfg.Enabled } // ProviderName is the label shown on the sign-in button. func (s *OAuthService) ProviderName() string { if s == nil { return "" } return s.cfg.ProviderName } // --------------------------------------------------------------------------- // Starting the flow // --------------------------------------------------------------------------- // FlowState is the per-attempt secret material. It is held in a short-lived // cookie, never server-side: it is single-use, expires in minutes, and storing // it would mean a write on every visit to the sign-in page. type FlowState struct { State string `json:"s"` Verifier string `json:"v"` Nonce string `json:"n"` RedirectPath string `json:"r,omitempty"` IssuedAt int64 `json:"t"` } // AuthorizeURL builds the URL to send the browser to, and the state to keep. // // returnTo is where to land after a successful sign-in. It is validated as a // local path here rather than at the callback, so an open redirect cannot be // smuggled in through the state cookie. func (s *OAuthService) AuthorizeURL(ctx context.Context, returnTo string) (string, *FlowState, error) { if !s.Enabled() { return "", nil, ErrOAuthDisabled } if err := s.discover(ctx); err != nil { return "", nil, err } state, err := randomURLSafe(32) if err != nil { return "", nil, err } verifier, err := randomURLSafe(32) if err != nil { return "", nil, err } nonce, err := randomURLSafe(16) if err != nil { return "", nil, err } fs := &FlowState{ State: state, Verifier: verifier, Nonce: nonce, RedirectPath: SafeRedirectPath(returnTo), IssuedAt: time.Now().Unix(), } challenge := pkceChallenge(verifier) q := url.Values{} q.Set("response_type", "code") q.Set("client_id", s.cfg.ClientID) q.Set("redirect_uri", s.cfg.RedirectURI) q.Set("scope", strings.Join(s.cfg.Scopes, " ")) q.Set("state", state) q.Set("code_challenge", challenge) q.Set("code_challenge_method", "S256") if s.isOIDC() { q.Set("nonce", nonce) } sep := "?" if strings.Contains(s.cfg.AuthorizeURL, "?") { sep = "&" } return s.cfg.AuthorizeURL + sep + q.Encode(), fs, nil } // --------------------------------------------------------------------------- // Completing the flow // --------------------------------------------------------------------------- // Identity is what the provider vouched for, after validation. type Identity struct { // Subject is the provider's immutable identifier. It is the key. Subject string // Email and DisplayName are for display and the domain allowlist only. Email string DisplayName string Provider string } // Exchange completes the flow: validates state, swaps the code, and resolves an // identity. // // The state comparison is constant-time and happens before anything is sent to // the provider, so a mismatched callback costs nothing and reveals nothing. func (s *OAuthService) Exchange(ctx context.Context, fs *FlowState, gotState, code string) (*Identity, error) { if !s.Enabled() { return nil, ErrOAuthDisabled } if fs == nil || fs.State == "" || gotState == "" { return nil, ErrOAuthState } if subtle.ConstantTimeCompare([]byte(fs.State), []byte(gotState)) != 1 { return nil, ErrOAuthState } // A flow that has been open for a long time is more likely to be a replayed // callback than a slow typist. if age := time.Since(time.Unix(fs.IssuedAt, 0)); age > 15*time.Minute || age < -time.Minute { return nil, ErrOAuthState } if strings.TrimSpace(code) == "" { return nil, ErrOAuthProvider } if err := s.discover(ctx); err != nil { return nil, err } tok, err := s.exchangeCode(ctx, code, fs.Verifier) if err != nil { return nil, err } id, err := s.identify(ctx, tok, fs.Nonce) if err != nil { return nil, err } if err := s.permitted(id); err != nil { return nil, err } id.Provider = s.cfg.ProviderName return id, nil } // tokenResponse is the provider's reply to the code exchange. type tokenResponse struct { AccessToken string `json:"access_token"` TokenType string `json:"token_type"` IDToken string `json:"id_token"` ExpiresIn int64 `json:"expires_in"` Scope string `json:"scope"` } func (s *OAuthService) exchangeCode(ctx context.Context, code, verifier string) (*tokenResponse, error) { form := url.Values{} form.Set("grant_type", "authorization_code") form.Set("code", code) form.Set("redirect_uri", s.cfg.RedirectURI) form.Set("client_id", s.cfg.ClientID) form.Set("code_verifier", verifier) req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.cfg.TokenURL, strings.NewReader(form.Encode())) if err != nil { return nil, fmt.Errorf("%w: building the token request: %v", ErrOAuthProvider, err) } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Accept", "application/json") // Client credentials go in the Authorization header rather than the body: // it is the form every provider accepts, and it keeps the secret out of // anything that logs request bodies. req.SetBasicAuth(url.QueryEscape(s.cfg.ClientID), url.QueryEscape(s.cfg.ClientSecret)) resp, err := s.client.Do(req) if err != nil { return nil, fmt.Errorf("%w: %v", ErrOAuthProvider, err) } defer resp.Body.Close() body, err := io.ReadAll(io.LimitReader(resp.Body, maxProviderBody)) if err != nil { return nil, fmt.Errorf("%w: reading the token response: %v", ErrOAuthProvider, err) } if resp.StatusCode < 200 || resp.StatusCode >= 300 { // The body may name the misconfiguration, which is exactly what an // operator needs — and exactly what a user must not be shown. return nil, fmt.Errorf("%w: token endpoint returned %d: %s", ErrOAuthProvider, resp.StatusCode, truncate(string(body), 300)) } var tr tokenResponse dec := json.NewDecoder(strings.NewReader(string(body))) if err := dec.Decode(&tr); err != nil { return nil, fmt.Errorf("%w: token response was not JSON: %v", ErrOAuthProvider, err) } if tr.AccessToken == "" && tr.IDToken == "" { return nil, fmt.Errorf("%w: token response carried neither an access token nor an ID token", ErrOAuthProvider) } return &tr, nil } // identify resolves an identity, preferring a validated ID token. func (s *OAuthService) identify(ctx context.Context, tok *tokenResponse, nonce string) (*Identity, error) { if s.isOIDC() && tok.IDToken != "" { id, err := s.identityFromIDToken(ctx, tok.IDToken, nonce) if err == nil { return id, nil } // An ID token that fails validation is a hard failure, not a reason to // fall back: falling back would let a forged token downgrade the check. return nil, err } if s.cfg.UserInfoURL == "" { return nil, fmt.Errorf("%w: no ID token and no userinfo endpoint", ErrOAuthIdentity) } return s.identityFromUserInfo(ctx, tok.AccessToken) } func (s *OAuthService) identityFromUserInfo(ctx context.Context, accessToken string) (*Identity, error) { if accessToken == "" { return nil, fmt.Errorf("%w: no access token to call userinfo with", ErrOAuthIdentity) } req, err := http.NewRequestWithContext(ctx, http.MethodGet, s.cfg.UserInfoURL, nil) if err != nil { return nil, fmt.Errorf("%w: %v", ErrOAuthProvider, err) } req.Header.Set("Authorization", "Bearer "+accessToken) req.Header.Set("Accept", "application/json") resp, err := s.client.Do(req) if err != nil { return nil, fmt.Errorf("%w: %v", ErrOAuthProvider, err) } defer resp.Body.Close() body, err := io.ReadAll(io.LimitReader(resp.Body, maxProviderBody)) if err != nil { return nil, fmt.Errorf("%w: %v", ErrOAuthProvider, err) } if resp.StatusCode < 200 || resp.StatusCode >= 300 { return nil, fmt.Errorf("%w: userinfo returned %d: %s", ErrOAuthProvider, resp.StatusCode, truncate(string(body), 300)) } var claims map[string]any if err := json.Unmarshal(body, &claims); err != nil { return nil, fmt.Errorf("%w: userinfo was not JSON: %v", ErrOAuthIdentity, err) } return identityFromClaims(claims) } // permitted applies the optional email-domain allowlist. func (s *OAuthService) permitted(id *Identity) error { if len(s.cfg.AllowedEmailDomains) == 0 { return nil } at := strings.LastIndex(id.Email, "@") if at < 0 { return fmt.Errorf("%w: an email address is required when a domain allowlist is set", ErrOAuthNotWanted) } got := strings.ToLower(id.Email[at+1:]) for _, d := range s.cfg.AllowedEmailDomains { if strings.EqualFold(strings.TrimPrefix(strings.TrimSpace(d), "@"), got) { return nil } } return ErrOAuthNotWanted } // identityFromClaims pulls the standard claims out of a claim set. func identityFromClaims(claims map[string]any) (*Identity, error) { sub := claimString(claims, "sub") if sub == "" { // Without a subject there is nothing stable to key an account on, and // guessing from the email is what this design exists to avoid. return nil, fmt.Errorf("%w: no 'sub' claim", ErrOAuthIdentity) } if len(sub) > 255 { return nil, fmt.Errorf("%w: 'sub' claim is implausibly long", ErrOAuthIdentity) } email := strings.ToLower(claimString(claims, "email")) name := claimString(claims, "name") if name == "" { name = strings.TrimSpace(claimString(claims, "given_name") + " " + claimString(claims, "family_name")) } if name == "" { name = claimString(claims, "preferred_username") } return &Identity{ Subject: sub, Email: truncate(email, 320), DisplayName: truncate(strings.TrimSpace(name), 120), }, nil } func claimString(m map[string]any, key string) string { switch v := m[key].(type) { case string: return strings.TrimSpace(v) case float64: // Some providers number their subjects. return strings.TrimSpace(fmt.Sprintf("%.0f", v)) } return "" } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- func (s *OAuthService) isOIDC() bool { return s.cfg.Issuer != "" || s.cfg.JWKSURL != "" } // SafeRedirectPath reduces a requested post-login destination to a local path. // // Anything that could leave the site becomes "/". Rejecting a leading "//" or a // backslash matters as much as rejecting a scheme: browsers read "//evil.test" // as protocol-relative and "/\evil.test" as the same thing. func SafeRedirectPath(p string) string { p = strings.TrimSpace(p) if p == "" || !strings.HasPrefix(p, "/") { return "/" } if strings.HasPrefix(p, "//") || strings.HasPrefix(p, "/\\") { return "/" } if strings.ContainsAny(p, "\r\n") { return "/" } // A path with a scheme in it is not a path. if strings.Contains(strings.ToLower(p), "://") { return "/" } return p } func pkceChallenge(verifier string) string { sum := sha256.Sum256([]byte(verifier)) return base64.RawURLEncoding.EncodeToString(sum[:]) } func randomURLSafe(n int) (string, error) { buf := make([]byte, n) if _, err := rand.Read(buf); err != nil { return "", fmt.Errorf("generate random value: %w", err) } return base64.RawURLEncoding.EncodeToString(buf), nil } func truncate(s string, n int) string { if len(s) <= n { return s } return s[:n] + "…" }