more platform

This commit is contained in:
mats committed 2026-06-29 12:35:32 +02:00
1 parent 8a71e32149
commit 2a66754f9e
27 files changed
+2435 -540

No files matched your search

+337
View File
@@ -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)
}
}