Files
relata/internal/auth/oidc.go
T
2026-08-11 10:40:10 +01:00

440 lines
13 KiB
Go

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
}