|
| 1 | +package auth |
| 2 | + |
| 3 | +import ( |
| 4 | + "crypto" |
| 5 | + "crypto/ecdsa" |
| 6 | + "crypto/elliptic" |
| 7 | + "crypto/rsa" |
| 8 | + "encoding/base64" |
| 9 | + "encoding/json" |
| 10 | + "fmt" |
| 11 | + "log" |
| 12 | + "math/big" |
| 13 | + "net/http" |
| 14 | + "sync" |
| 15 | + "time" |
| 16 | +) |
| 17 | + |
| 18 | +// jwksResponse is the raw JSON structure from the /.well-known/jwks.json endpoint. |
| 19 | +type jwksResponse struct { |
| 20 | + Keys []jwk `json:"keys"` |
| 21 | +} |
| 22 | + |
| 23 | +// jwk represents a single JSON Web Key (RSA or EC public key fields). |
| 24 | +type jwk struct { |
| 25 | + Kid string `json:"kid"` // Key ID |
| 26 | + Kty string `json:"kty"` // Key type ("RSA" or "EC") |
| 27 | + Alg string `json:"alg"` // Algorithm (e.g. "RS256", "ES256") |
| 28 | + |
| 29 | + // RSA fields: |
| 30 | + N string `json:"n"` // RSA modulus (base64url encoded) |
| 31 | + E string `json:"e"` // RSA exponent (base64url encoded) |
| 32 | + |
| 33 | + // EC fields: |
| 34 | + Crv string `json:"crv"` // Curve (e.g. "P-256") |
| 35 | + X string `json:"x"` // EC X coordinate |
| 36 | + Y string `json:"y"` // EC Y coordinate |
| 37 | +} |
| 38 | + |
| 39 | +// JWKSCache fetches and caches public keys from a JWKS endpoint. |
| 40 | +// It refreshes the cache periodically to handle key rotation. |
| 41 | +type JWKSCache struct { |
| 42 | + jwksURL string |
| 43 | + mu sync.RWMutex |
| 44 | + keys map[string]crypto.PublicKey // kid → public key |
| 45 | +} |
| 46 | + |
| 47 | +// NewJWKSCache creates a JWKSCache and performs an initial key fetch. |
| 48 | +// It also spawns a background goroutine that refreshes the cache every |
| 49 | +// refreshInterval to transparently handle key rotation at the IdP. |
| 50 | +func NewJWKSCache(jwksURL string, refreshInterval time.Duration) (*JWKSCache, error) { |
| 51 | + c := &JWKSCache{ |
| 52 | + jwksURL: jwksURL, |
| 53 | + keys: make(map[string]crypto.PublicKey), |
| 54 | + } |
| 55 | + |
| 56 | + // Perform the initial fetch synchronously so we fail fast on misconfiguration. |
| 57 | + if err := c.refresh(); err != nil { |
| 58 | + return nil, fmt.Errorf("initial JWKS fetch from %s failed: %w", jwksURL, err) |
| 59 | + } |
| 60 | + |
| 61 | + // Background refresh goroutine — handles key rotation. |
| 62 | + go func() { |
| 63 | + ticker := time.NewTicker(refreshInterval) |
| 64 | + defer ticker.Stop() |
| 65 | + for range ticker.C { |
| 66 | + if err := c.refresh(); err != nil { |
| 67 | + log.Printf("[JWKS] Background refresh failed (will retry): %v", err) |
| 68 | + } else { |
| 69 | + log.Printf("[JWKS] Key cache refreshed from %s", jwksURL) |
| 70 | + } |
| 71 | + } |
| 72 | + }() |
| 73 | + |
| 74 | + return c, nil |
| 75 | +} |
| 76 | + |
| 77 | +// GetKey returns the public key for the given key ID (kid). |
| 78 | +// Returns an error if the kid is not found in the cache. |
| 79 | +func (c *JWKSCache) GetKey(kid string) (crypto.PublicKey, error) { |
| 80 | + c.mu.RLock() |
| 81 | + key, ok := c.keys[kid] |
| 82 | + c.mu.RUnlock() |
| 83 | + |
| 84 | + if !ok { |
| 85 | + // Kid not found — try one eager refresh in case the IdP just rotated keys. |
| 86 | + log.Printf("[JWKS] kid %q not in cache — attempting eager refresh", kid) |
| 87 | + if err := c.refresh(); err != nil { |
| 88 | + return nil, fmt.Errorf("kid %q not found and refresh failed: %w", kid, err) |
| 89 | + } |
| 90 | + c.mu.RLock() |
| 91 | + key, ok = c.keys[kid] |
| 92 | + c.mu.RUnlock() |
| 93 | + if !ok { |
| 94 | + return nil, fmt.Errorf("kid %q not found in JWKS after refresh", kid) |
| 95 | + } |
| 96 | + } |
| 97 | + return key, nil |
| 98 | +} |
| 99 | + |
| 100 | +// refresh fetches the JWKS endpoint and atomically replaces the key cache. |
| 101 | +func (c *JWKSCache) refresh() error { |
| 102 | + resp, err := http.Get(c.jwksURL) //nolint:noctx |
| 103 | + if err != nil { |
| 104 | + return fmt.Errorf("GET %s: %w", c.jwksURL, err) |
| 105 | + } |
| 106 | + defer resp.Body.Close() |
| 107 | + |
| 108 | + if resp.StatusCode != http.StatusOK { |
| 109 | + return fmt.Errorf("GET %s: unexpected HTTP %d", c.jwksURL, resp.StatusCode) |
| 110 | + } |
| 111 | + |
| 112 | + var jwks jwksResponse |
| 113 | + if err := json.NewDecoder(resp.Body).Decode(&jwks); err != nil { |
| 114 | + return fmt.Errorf("decode JWKS: %w", err) |
| 115 | + } |
| 116 | + |
| 117 | + newKeys := make(map[string]crypto.PublicKey, len(jwks.Keys)) |
| 118 | + for _, k := range jwks.Keys { |
| 119 | + if k.Kty == "RSA" { |
| 120 | + pub, err := parseRSAPublicKey(k) |
| 121 | + if err != nil { |
| 122 | + log.Printf("[JWKS] Skipping RSA key kid=%q: %v", k.Kid, err) |
| 123 | + continue |
| 124 | + } |
| 125 | + newKeys[k.Kid] = pub |
| 126 | + } else if k.Kty == "EC" && k.Crv == "P-256" { |
| 127 | + pub, err := parseECPublicKey(k) |
| 128 | + if err != nil { |
| 129 | + log.Printf("[JWKS] Skipping EC key kid=%q: %v", k.Kid, err) |
| 130 | + continue |
| 131 | + } |
| 132 | + newKeys[k.Kid] = pub |
| 133 | + } else { |
| 134 | + continue // Skip unsupported key types/curves |
| 135 | + } |
| 136 | + } |
| 137 | + |
| 138 | + if len(newKeys) == 0 { |
| 139 | + return fmt.Errorf("JWKS response contained no supported RSA or EC keys") |
| 140 | + } |
| 141 | + |
| 142 | + c.mu.Lock() |
| 143 | + c.keys = newKeys |
| 144 | + c.mu.Unlock() |
| 145 | + |
| 146 | + log.Printf("[JWKS] Loaded %d key(s) from %s", len(newKeys), c.jwksURL) |
| 147 | + return nil |
| 148 | +} |
| 149 | + |
| 150 | +// parseECPublicKey converts a JSON Web Key into a Go *ecdsa.PublicKey |
| 151 | +// using only the standard library. |
| 152 | +func parseECPublicKey(k jwk) (*ecdsa.PublicKey, error) { |
| 153 | + xBytes, err := base64.RawURLEncoding.DecodeString(k.X) |
| 154 | + if err != nil { |
| 155 | + return nil, fmt.Errorf("decode X: %w", err) |
| 156 | + } |
| 157 | + |
| 158 | + yBytes, err := base64.RawURLEncoding.DecodeString(k.Y) |
| 159 | + if err != nil { |
| 160 | + return nil, fmt.Errorf("decode Y: %w", err) |
| 161 | + } |
| 162 | + |
| 163 | + return &ecdsa.PublicKey{ |
| 164 | + Curve: elliptic.P256(), |
| 165 | + X: new(big.Int).SetBytes(xBytes), |
| 166 | + Y: new(big.Int).SetBytes(yBytes), |
| 167 | + }, nil |
| 168 | +} |
| 169 | + |
| 170 | +// parseRSAPublicKey converts a JSON Web Key into a Go *rsa.PublicKey |
| 171 | +// using only the standard library (no external JWT packages). |
| 172 | +func parseRSAPublicKey(k jwk) (*rsa.PublicKey, error) { |
| 173 | + // Decode the base64url-encoded modulus (N) |
| 174 | + nBytes, err := base64.RawURLEncoding.DecodeString(k.N) |
| 175 | + if err != nil { |
| 176 | + return nil, fmt.Errorf("decode N: %w", err) |
| 177 | + } |
| 178 | + |
| 179 | + // Decode the base64url-encoded exponent (E) |
| 180 | + eBytes, err := base64.RawURLEncoding.DecodeString(k.E) |
| 181 | + if err != nil { |
| 182 | + return nil, fmt.Errorf("decode E: %w", err) |
| 183 | + } |
| 184 | + |
| 185 | + // Convert E bytes to int |
| 186 | + var eInt int |
| 187 | + for _, b := range eBytes { |
| 188 | + eInt = eInt<<8 | int(b) |
| 189 | + } |
| 190 | + |
| 191 | + return &rsa.PublicKey{ |
| 192 | + N: new(big.Int).SetBytes(nBytes), |
| 193 | + E: eInt, |
| 194 | + }, nil |
| 195 | +} |
0 commit comments