package auth import ( "context" "crypto" "crypto/rsa" "crypto/subtle" "encoding/base64" "encoding/binary" "encoding/json" "fmt" "io" "math/big" "net/http" "strings" "sync" "time" // crypto.SHA256/384/512 resolve through the registry, and Hash.New panics // on an algorithm whose package was never linked in. These imports are what // make the RS384 and RS512 branches below work rather than crash. _ "crypto/sha256" _ "crypto/sha512" ) // discover fills in any endpoint the operator did not configure, from the // provider's OpenID discovery document. // // It runs once. An explicitly configured endpoint always wins over a discovered // one, so an operator can override a provider that advertises something wrong. func (s *OAuthService) discover(ctx context.Context) error { s.discoOnce.Do(func() { if s.cfg.Issuer == "" { return } if s.cfg.AuthorizeURL != "" && s.cfg.TokenURL != "" && s.cfg.JWKSURL != "" { return // nothing left to learn } u := strings.TrimRight(s.cfg.Issuer, "/") + "/.well-known/openid-configuration" req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) if err != nil { s.discoErr = fmt.Errorf("%w: %v", ErrOAuthProvider, err) return } req.Header.Set("Accept", "application/json") resp, err := s.client.Do(req) if err != nil { s.discoErr = fmt.Errorf("%w: fetching %s: %v", ErrOAuthProvider, u, err) return } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { s.discoErr = fmt.Errorf("%w: %s returned %d", ErrOAuthProvider, u, resp.StatusCode) return } var doc struct { Issuer string `json:"issuer"` AuthzURL string `json:"authorization_endpoint"` TokenURL string `json:"token_endpoint"` UserInfoURL string `json:"userinfo_endpoint"` JWKSURL string `json:"jwks_uri"` } body, err := io.ReadAll(io.LimitReader(resp.Body, maxProviderBody)) if err != nil { s.discoErr = fmt.Errorf("%w: %v", ErrOAuthProvider, err) return } if err := json.Unmarshal(body, &doc); err != nil { s.discoErr = fmt.Errorf("%w: discovery document was not JSON: %v", ErrOAuthProvider, err) return } // The document's own issuer must match the one configured, or a // compromised discovery endpoint could redirect the whole flow. if doc.Issuer != "" && strings.TrimRight(doc.Issuer, "/") != strings.TrimRight(s.cfg.Issuer, "/") { s.discoErr = fmt.Errorf("%w: discovery document declares issuer %q, expected %q", ErrOAuthProvider, doc.Issuer, s.cfg.Issuer) return } if s.cfg.AuthorizeURL == "" { s.cfg.AuthorizeURL = doc.AuthzURL } if s.cfg.TokenURL == "" { s.cfg.TokenURL = doc.TokenURL } if s.cfg.UserInfoURL == "" { s.cfg.UserInfoURL = doc.UserInfoURL } if s.cfg.JWKSURL == "" { s.cfg.JWKSURL = doc.JWKSURL } }) if s.discoErr != nil { return s.discoErr } if s.cfg.AuthorizeURL == "" || s.cfg.TokenURL == "" { return fmt.Errorf("%w: no authorization or token endpoint is configured or discoverable", ErrOAuthProvider) } return nil } // identityFromIDToken validates an OIDC ID token and reads the identity from it. // // Validation is signature first, then claims. A token whose signature does not // verify is never parsed for meaning. func (s *OAuthService) identityFromIDToken(ctx context.Context, raw, nonce string) (*Identity, error) { parts := strings.Split(raw, ".") if len(parts) != 3 { return nil, fmt.Errorf("%w: ID token is not a three-part JWT", ErrOAuthIdentity) } headerRaw, err := base64.RawURLEncoding.DecodeString(parts[0]) if err != nil { return nil, fmt.Errorf("%w: ID token header is not base64url", ErrOAuthIdentity) } var header struct { Alg string `json:"alg"` Kid string `json:"kid"` } if err := json.Unmarshal(headerRaw, &header); err != nil { return nil, fmt.Errorf("%w: ID token header is not JSON", ErrOAuthIdentity) } // Only RSA signing is accepted. "none" is the classic JWT forgery, and an // HMAC algorithm here would let the public key be used as the shared // secret — both are refused by allowing an explicit list rather than // trusting the token to name its own algorithm. var hash crypto.Hash switch header.Alg { case "RS256": hash = crypto.SHA256 case "RS384": hash = crypto.SHA384 case "RS512": hash = crypto.SHA512 default: return nil, fmt.Errorf("%w: ID token algorithm %q is not accepted", ErrOAuthIdentity, header.Alg) } // Belt and braces: if the algorithm's package were ever dropped, this // returns an error instead of panicking inside a request handler. if !hash.Available() { return nil, fmt.Errorf("%w: hash for %s is not available in this build", ErrOAuthIdentity, header.Alg) } if s.cfg.JWKSURL == "" { return nil, fmt.Errorf("%w: no JWKS endpoint to verify the ID token with", ErrOAuthIdentity) } key, fromCache, err := s.jwks.key(ctx, s.cfg.JWKSURL, header.Kid) if err != nil { return nil, err } sig, err := base64.RawURLEncoding.DecodeString(parts[2]) if err != nil { return nil, fmt.Errorf("%w: ID token signature is not base64url", ErrOAuthIdentity) } signed := parts[0] + "." + parts[1] h := hash.New() h.Write([]byte(signed)) digest := h.Sum(nil) if err := rsa.VerifyPKCS1v15(key, hash, digest, sig); err != nil { // A cached key that no longer verifies is what a rotation looks like // when the provider keeps the key ID. Refetch once and try again; // anything else is a genuinely bad signature. if !fromCache { return nil, fmt.Errorf("%w: ID token signature does not verify", ErrOAuthIdentity) } rotated, rerr := s.jwks.rotatedKey(ctx, s.cfg.JWKSURL, header.Kid) if rerr != nil { return nil, fmt.Errorf("%w: ID token signature does not verify", ErrOAuthIdentity) } if err := rsa.VerifyPKCS1v15(rotated, hash, digest, sig); err != nil { return nil, fmt.Errorf("%w: ID token signature does not verify", ErrOAuthIdentity) } } payloadRaw, err := base64.RawURLEncoding.DecodeString(parts[1]) if err != nil { return nil, fmt.Errorf("%w: ID token payload is not base64url", ErrOAuthIdentity) } var claims map[string]any if err := json.Unmarshal(payloadRaw, &claims); err != nil { return nil, fmt.Errorf("%w: ID token payload is not JSON", ErrOAuthIdentity) } if err := s.checkIDTokenClaims(claims, nonce); err != nil { return nil, err } return identityFromClaims(claims) } // checkIDTokenClaims enforces issuer, audience, expiry and nonce. func (s *OAuthService) checkIDTokenClaims(claims map[string]any, nonce string) error { if iss := claimString(claims, "iss"); s.cfg.Issuer != "" && strings.TrimRight(iss, "/") != strings.TrimRight(s.cfg.Issuer, "/") { return fmt.Errorf("%w: ID token issuer %q does not match %q", ErrOAuthIdentity, iss, s.cfg.Issuer) } if !audienceContains(claims["aud"], s.cfg.ClientID) { return fmt.Errorf("%w: ID token audience does not include this client", ErrOAuthIdentity) } now := time.Now() // A little leeway for clock skew, in both directions. const skew = 2 * time.Minute if exp, ok := claimTime(claims, "exp"); ok && now.After(exp.Add(skew)) { return fmt.Errorf("%w: ID token has expired", ErrOAuthIdentity) } else if !ok { return fmt.Errorf("%w: ID token has no expiry", ErrOAuthIdentity) } if iat, ok := claimTime(claims, "iat"); ok && now.Add(skew).Before(iat) { return fmt.Errorf("%w: ID token is issued in the future", ErrOAuthIdentity) } // The nonce binds the token to this browser's sign-in attempt, which is // what stops a token obtained elsewhere being replayed here. if nonce != "" { got := claimString(claims, "nonce") if subtle.ConstantTimeCompare([]byte(got), []byte(nonce)) != 1 { return fmt.Errorf("%w: ID token nonce does not match this sign-in attempt", ErrOAuthIdentity) } } return nil } func audienceContains(aud any, clientID string) bool { switch v := aud.(type) { case string: return subtle.ConstantTimeCompare([]byte(v), []byte(clientID)) == 1 case []any: for _, item := range v { if s, ok := item.(string); ok && subtle.ConstantTimeCompare([]byte(s), []byte(clientID)) == 1 { return true } } } return false } func claimTime(m map[string]any, key string) (time.Time, bool) { switch v := m[key].(type) { case float64: return time.Unix(int64(v), 0), true case int64: return time.Unix(v, 0), true } return time.Time{}, false } // --------------------------------------------------------------------------- // JWKS // --------------------------------------------------------------------------- // jwksCache holds the provider's signing keys. // // Keys are cached because fetching them per sign-in would make the identity // provider a hard dependency of every login, and refreshed on an unknown key ID // because that is exactly what a key rotation looks like. type jwksCache struct { client *http.Client mu sync.RWMutex keys map[string]*rsa.PublicKey fetchedAt time.Time // lastMiss throttles refetching, so an unknown key ID cannot be used to // make Relata hammer the provider. lastMiss time.Time // lastRotationFetch throttles the refetch that follows a failed signature. lastRotationFetch time.Time } const ( jwksTTL = time.Hour jwksMissDelay = time.Minute ) func newJWKSCache(c *http.Client) *jwksCache { return &jwksCache{client: c, keys: map[string]*rsa.PublicKey{}} } func (c *jwksCache) key(ctx context.Context, url, kid string) (*rsa.PublicKey, bool, error) { c.mu.RLock() k, ok := c.keys[kid] fresh := time.Since(c.fetchedAt) < jwksTTL missRecently := time.Since(c.lastMiss) < jwksMissDelay c.mu.RUnlock() if ok && fresh { return k, true, nil } if !ok && fresh && missRecently { return nil, false, fmt.Errorf("%w: no signing key %q in the provider's key set", ErrOAuthIdentity, kid) } if err := c.refresh(ctx, url); err != nil { // A stale key still verifies a token signed before the fetch failed. if ok { return k, true, nil } return nil, false, err } c.mu.RLock() k, ok = c.keys[kid] c.mu.RUnlock() if !ok { c.mu.Lock() c.lastMiss = time.Now() c.mu.Unlock() return nil, false, fmt.Errorf("%w: no signing key %q in the provider's key set", ErrOAuthIdentity, kid) } return k, false, nil } // rotatedKey refetches the key set and returns the key for kid. // // It exists for one case: a provider that replaces a key while keeping its key // ID. Nothing about the cached entry looks stale, so without this every sign-in // would fail until the cache expired — up to an hour of nobody being able to // get in. It is called only after a signature has already failed against a // cached key, and it is throttled, so it cannot be used to make Relata hammer // the provider. func (c *jwksCache) rotatedKey(ctx context.Context, url, kid string) (*rsa.PublicKey, error) { c.mu.Lock() if time.Since(c.lastRotationFetch) < jwksMissDelay { c.mu.Unlock() return nil, fmt.Errorf("%w: signing key %q did not verify and the key set was "+ "refetched too recently to try again", ErrOAuthIdentity, kid) } c.lastRotationFetch = time.Now() c.mu.Unlock() if err := c.refresh(ctx, url); err != nil { return nil, err } c.mu.RLock() k, ok := c.keys[kid] c.mu.RUnlock() if !ok { return nil, fmt.Errorf("%w: no signing key %q in the provider's key set", ErrOAuthIdentity, kid) } return k, nil } func (c *jwksCache) refresh(ctx context.Context, url string) error { req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return fmt.Errorf("%w: %v", ErrOAuthProvider, err) } req.Header.Set("Accept", "application/json") resp, err := c.client.Do(req) if err != nil { return fmt.Errorf("%w: fetching signing keys: %v", ErrOAuthProvider, err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return fmt.Errorf("%w: key set endpoint returned %d", ErrOAuthProvider, resp.StatusCode) } body, err := io.ReadAll(io.LimitReader(resp.Body, maxProviderBody)) if err != nil { return fmt.Errorf("%w: %v", ErrOAuthProvider, err) } var set struct { Keys []struct { Kty string `json:"kty"` Kid string `json:"kid"` Use string `json:"use"` N string `json:"n"` E string `json:"e"` } `json:"keys"` } if err := json.Unmarshal(body, &set); err != nil { return fmt.Errorf("%w: key set was not JSON: %v", ErrOAuthProvider, err) } keys := map[string]*rsa.PublicKey{} for _, k := range set.Keys { if k.Kty != "RSA" || (k.Use != "" && k.Use != "sig") { continue } pub, err := rsaKeyFromJWK(k.N, k.E) if err != nil { continue // one unusable key must not discard the rest } keys[k.Kid] = pub } if len(keys) == 0 { return fmt.Errorf("%w: key set contained no usable RSA signing keys", ErrOAuthProvider) } c.mu.Lock() c.keys = keys c.fetchedAt = time.Now() c.mu.Unlock() return nil } func rsaKeyFromJWK(nB64, eB64 string) (*rsa.PublicKey, error) { nBytes, err := base64.RawURLEncoding.DecodeString(nB64) if err != nil { return nil, err } eBytes, err := base64.RawURLEncoding.DecodeString(eB64) if err != nil { return nil, err } if len(eBytes) == 0 || len(eBytes) > 8 { return nil, fmt.Errorf("implausible RSA exponent") } // Left-pad the exponent to eight bytes so it can be read as a uint64. padded := make([]byte, 8) copy(padded[8-len(eBytes):], eBytes) e := binary.BigEndian.Uint64(padded) if e == 0 || e > 1<<31 { return nil, fmt.Errorf("implausible RSA exponent") } key := &rsa.PublicKey{N: new(big.Int).SetBytes(nBytes), E: int(e)} if key.N.BitLen() < 2048 { return nil, fmt.Errorf("RSA key smaller than 2048 bits") } return key, nil }