more platform
This commit is contained in:
1 parent
8a71e32149
commit
2a66754f9e
27 files changed
+2435
-540
No files matched your search
+337
@@ -0,0 +1,337 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rsa"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// OIDC validates `Authorization: Bearer <jwt>` against any OIDC issuer: it
|
||||
// fetches `<issuer>/.well-known/openid-configuration` once, caches the JWKS,
|
||||
// and verifies signature (RS256/ES256), iss, aud and exp/nbf. Zitadel is the
|
||||
// reference issuer but nothing here is provider-specific.
|
||||
//
|
||||
// Note: the issuer must mint JWT access tokens (for Zitadel, set the
|
||||
// application's access-token type to JWT); opaque tokens require
|
||||
// introspection, which this deliberately does not implement.
|
||||
type OIDC struct {
|
||||
issuer string
|
||||
audience string // optional: when set, must appear in the token's aud
|
||||
client *http.Client
|
||||
|
||||
mu sync.Mutex
|
||||
jwksURI string
|
||||
keys map[string]crypto.PublicKey // kid → verification key
|
||||
lastRefresh time.Time
|
||||
|
||||
now func() time.Time // injectable clock for tests
|
||||
}
|
||||
|
||||
var _ Authenticator = (*OIDC)(nil)
|
||||
|
||||
// NewOIDC builds an OIDC authenticator for the given issuer URL. audience
|
||||
// is the expected `aud` claim (typically the OAuth client id); empty skips
|
||||
// the audience check.
|
||||
func NewOIDC(issuer, audience string) *OIDC {
|
||||
return &OIDC{
|
||||
issuer: strings.TrimSuffix(issuer, "/"),
|
||||
audience: audience,
|
||||
client: &http.Client{Timeout: 10 * time.Second},
|
||||
now: time.Now,
|
||||
}
|
||||
}
|
||||
|
||||
// Authenticate validates the bearer JWT on r and returns the caller as a
|
||||
// minimal Principal. For callers that need the full parsed claim set
|
||||
// (Email/Name/scope list) — e.g. admin handlers rendering who is logged in —
|
||||
// see VerifyClaims.
|
||||
func (o *OIDC) Authenticate(r *http.Request) (*Principal, error) {
|
||||
h := r.Header.Get("Authorization")
|
||||
token, ok := strings.CutPrefix(h, "Bearer ")
|
||||
if !ok || token == "" {
|
||||
return nil, ErrNoCredentials
|
||||
}
|
||||
c, err := o.VerifyClaims(token)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Principal{Subject: c.Subject, Scopes: c.Scopes, Kind: KindOIDC}, nil
|
||||
}
|
||||
|
||||
// Claims is the parsed, verified claim set returned by VerifyClaims. Only
|
||||
// the subset services commonly need is exposed; the raw claim JSON is
|
||||
// available via the original token, which callers can re-decode if they
|
||||
// need something custom.
|
||||
type Claims struct {
|
||||
Issuer string `json:"iss"`
|
||||
Subject string `json:"sub"`
|
||||
Email string `json:"email,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Audience Audience `json:"aud"`
|
||||
Expires int64 `json:"exp"`
|
||||
NotBefore int64 `json:"nbf"`
|
||||
Scopes []string `json:"scope,omitempty"` // parsed from "scope" space-separated claim
|
||||
}
|
||||
|
||||
// Audience unmarshals the `aud` claim, which RFC 7519 allows to be either a
|
||||
// string or an array of strings (some providers emit one, some the other;
|
||||
// Zitadel emits an array).
|
||||
type Audience []string
|
||||
|
||||
func (a *Audience) UnmarshalJSON(b []byte) error {
|
||||
var s string
|
||||
if err := json.Unmarshal(b, &s); err == nil {
|
||||
*a = Audience{s}
|
||||
return nil
|
||||
}
|
||||
var ss []string
|
||||
if err := json.Unmarshal(b, &ss); err != nil {
|
||||
return err
|
||||
}
|
||||
*a = Audience(ss)
|
||||
return nil
|
||||
}
|
||||
|
||||
// contains reports whether want appears in the audience list.
|
||||
func (a Audience) contains(want string) bool {
|
||||
return slices.Contains(a, want)
|
||||
}
|
||||
|
||||
// clockSkew is the tolerance applied when checking expiry/notBefore so a
|
||||
// small clock drift between issuer and verifier doesn't reject a fresh
|
||||
// token.
|
||||
const clockSkew = 30 * time.Second
|
||||
|
||||
// VerifyClaims verifies a raw JWT (no "Bearer " prefix) end-to-end and
|
||||
// returns the parsed claim set. Callers that aren't using the standard
|
||||
// Authorization header (e.g. an admin UI reading a session cookie) can
|
||||
// pass the cookie value directly. The token is verified for issuer,
|
||||
// audience (if configured), expiry/notBefore, and signature via the JWKS
|
||||
// cached against the configured issuer.
|
||||
func (o *OIDC) VerifyClaims(token string) (*Claims, error) {
|
||||
parts := strings.Split(token, ".")
|
||||
if len(parts) != 3 {
|
||||
return nil, errors.New("oidc: malformed jwt")
|
||||
}
|
||||
headerJSON, err := base64.RawURLEncoding.DecodeString(parts[0])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("oidc: bad jwt header: %w", err)
|
||||
}
|
||||
var header struct {
|
||||
Alg string `json:"alg"`
|
||||
Kid string `json:"kid"`
|
||||
}
|
||||
if err := json.Unmarshal(headerJSON, &header); err != nil {
|
||||
return nil, fmt.Errorf("oidc: bad jwt header: %w", err)
|
||||
}
|
||||
payloadJSON, err := base64.RawURLEncoding.DecodeString(parts[1])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("oidc: bad jwt payload: %w", err)
|
||||
}
|
||||
sig, err := base64.RawURLEncoding.DecodeString(parts[2])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("oidc: bad jwt signature: %w", err)
|
||||
}
|
||||
|
||||
key, err := o.key(header.Kid)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
digest := sha256.Sum256([]byte(parts[0] + "." + parts[1]))
|
||||
switch header.Alg {
|
||||
case "RS256":
|
||||
pub, ok := key.(*rsa.PublicKey)
|
||||
if !ok {
|
||||
return nil, errors.New("oidc: key/alg mismatch")
|
||||
}
|
||||
if err := rsa.VerifyPKCS1v15(pub, crypto.SHA256, digest[:], sig); err != nil {
|
||||
return nil, errors.New("oidc: invalid signature")
|
||||
}
|
||||
case "ES256":
|
||||
pub, ok := key.(*ecdsa.PublicKey)
|
||||
if !ok {
|
||||
return nil, errors.New("oidc: key/alg mismatch")
|
||||
}
|
||||
if len(sig) != 64 {
|
||||
return nil, errors.New("oidc: invalid signature")
|
||||
}
|
||||
rr := new(big.Int).SetBytes(sig[:32])
|
||||
ss := new(big.Int).SetBytes(sig[32:])
|
||||
if !ecdsa.Verify(pub, digest[:], rr, ss) {
|
||||
return nil, errors.New("oidc: invalid signature")
|
||||
}
|
||||
default:
|
||||
return nil, fmt.Errorf("oidc: unsupported alg %q", header.Alg)
|
||||
}
|
||||
|
||||
// Decode into a wide struct so Email/Name are picked up regardless of
|
||||
// claim shape (some providers omit them, some populate them).
|
||||
var raw struct {
|
||||
Issuer string `json:"iss"`
|
||||
Subject string `json:"sub"`
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
Audience Audience `json:"aud"`
|
||||
Expires int64 `json:"exp"`
|
||||
NotBefore int64 `json:"nbf"`
|
||||
Scope string `json:"scope"`
|
||||
}
|
||||
if err := json.Unmarshal(payloadJSON, &raw); err != nil {
|
||||
return nil, fmt.Errorf("oidc: bad claims: %w", err)
|
||||
}
|
||||
now := o.now()
|
||||
if strings.TrimSuffix(raw.Issuer, "/") != o.issuer {
|
||||
return nil, errors.New("oidc: wrong issuer")
|
||||
}
|
||||
if raw.Expires != 0 && now.After(time.Unix(raw.Expires, 0).Add(clockSkew)) {
|
||||
return nil, errors.New("oidc: token expired")
|
||||
}
|
||||
if raw.NotBefore != 0 && now.Add(clockSkew).Before(time.Unix(raw.NotBefore, 0)) {
|
||||
return nil, errors.New("oidc: token not yet valid")
|
||||
}
|
||||
if o.audience != "" && !raw.Audience.contains(o.audience) {
|
||||
return nil, errors.New("oidc: wrong audience")
|
||||
}
|
||||
|
||||
var scopes []string
|
||||
if raw.Scope != "" {
|
||||
scopes = strings.Fields(raw.Scope)
|
||||
}
|
||||
return &Claims{
|
||||
Issuer: raw.Issuer,
|
||||
Subject: raw.Subject,
|
||||
Email: raw.Email,
|
||||
Name: raw.Name,
|
||||
Audience: raw.Audience,
|
||||
Expires: raw.Expires,
|
||||
NotBefore: raw.NotBefore,
|
||||
Scopes: scopes,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// key returns the verification key for kid, fetching/refreshing the JWKS
|
||||
// when the kid is unknown (key rotation), rate-limited to once a minute.
|
||||
func (o *OIDC) key(kid string) (crypto.PublicKey, error) {
|
||||
o.mu.Lock()
|
||||
defer o.mu.Unlock()
|
||||
if k, ok := o.keys[kid]; ok {
|
||||
return k, nil
|
||||
}
|
||||
if time.Since(o.lastRefresh) < time.Minute {
|
||||
return nil, fmt.Errorf("oidc: unknown key id %q", kid)
|
||||
}
|
||||
if err := o.refreshLocked(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if k, ok := o.keys[kid]; ok {
|
||||
return k, nil
|
||||
}
|
||||
return nil, fmt.Errorf("oidc: unknown key id %q", kid)
|
||||
}
|
||||
|
||||
// refreshLocked (re)fetches the discovery document (first time) and the
|
||||
// JWKS. Callers must hold o.mu.
|
||||
func (o *OIDC) refreshLocked() error {
|
||||
o.lastRefresh = o.now()
|
||||
if o.jwksURI == "" {
|
||||
var disc struct {
|
||||
JWKSURI string `json:"jwks_uri"`
|
||||
}
|
||||
if err := o.getJSON(o.issuer+"/.well-known/openid-configuration", &disc); err != nil {
|
||||
return fmt.Errorf("oidc: discovery: %w", err)
|
||||
}
|
||||
if disc.JWKSURI == "" {
|
||||
return errors.New("oidc: discovery document has no jwks_uri")
|
||||
}
|
||||
o.jwksURI = disc.JWKSURI
|
||||
}
|
||||
var set struct {
|
||||
Keys []jwk `json:"keys"`
|
||||
}
|
||||
if err := o.getJSON(o.jwksURI, &set); err != nil {
|
||||
return fmt.Errorf("oidc: jwks: %w", err)
|
||||
}
|
||||
keys := make(map[string]crypto.PublicKey, len(set.Keys))
|
||||
for _, k := range set.Keys {
|
||||
pub, err := k.publicKey()
|
||||
if err != nil {
|
||||
continue // skip unsupported key types (e.g. enc-use keys)
|
||||
}
|
||||
keys[k.Kid] = pub
|
||||
}
|
||||
o.keys = keys
|
||||
return nil
|
||||
}
|
||||
|
||||
func (o *OIDC) getJSON(url string, v any) error {
|
||||
res, err := o.client.Get(url)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("GET %s: %s", url, res.Status)
|
||||
}
|
||||
return json.NewDecoder(res.Body).Decode(v)
|
||||
}
|
||||
|
||||
// jwk is the subset of RFC 7517 needed to build RSA and P-256 keys.
|
||||
type jwk struct {
|
||||
Kty string `json:"kty"`
|
||||
Kid string `json:"kid"`
|
||||
Use string `json:"use"`
|
||||
N string `json:"n"`
|
||||
E string `json:"e"`
|
||||
Crv string `json:"crv"`
|
||||
X string `json:"x"`
|
||||
Y string `json:"y"`
|
||||
}
|
||||
|
||||
func (k jwk) publicKey() (crypto.PublicKey, error) {
|
||||
switch k.Kty {
|
||||
case "RSA":
|
||||
n, err := base64.RawURLEncoding.DecodeString(k.N)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
eBytes, err := base64.RawURLEncoding.DecodeString(k.E)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &rsa.PublicKey{
|
||||
N: new(big.Int).SetBytes(n),
|
||||
E: int(new(big.Int).SetBytes(eBytes).Int64()),
|
||||
}, nil
|
||||
case "EC":
|
||||
if k.Crv != "P-256" {
|
||||
return nil, fmt.Errorf("unsupported curve %q", k.Crv)
|
||||
}
|
||||
x, err := base64.RawURLEncoding.DecodeString(k.X)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
y, err := base64.RawURLEncoding.DecodeString(k.Y)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &ecdsa.PublicKey{
|
||||
Curve: elliptic.P256(),
|
||||
X: new(big.Int).SetBytes(x),
|
||||
Y: new(big.Int).SetBytes(y),
|
||||
}, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported key type %q", k.Kty)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user