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

+166
View File
@@ -0,0 +1,166 @@
package auth
import (
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"net/http"
"os"
"path/filepath"
"sync"
"time"
)
// APIKey is the stored record for a minted key. Only the SHA-256 of the
// full key string is kept; the key itself is shown exactly once at mint
// time. SHA-256 (not argon2/bcrypt) is sufficient because keys are 192-bit
// random secrets, not low-entropy passwords.
type APIKey struct {
ID string `json:"id"`
Name string `json:"name"`
Hash string `json:"hash"` // hex sha256 of the full key string
Scopes []string `json:"scopes,omitempty"`
CreatedAt time.Time `json:"createdAt"`
}
// ErrKeyNotFound is returned by Revoke for an unknown key id.
var ErrKeyNotFound = errors.New("auth: api key not found")
// KeyStore is a file-backed set of API keys (<file> is JSON, written
// atomically). Volume is tiny — a handful of integration keys — so a flat
// file beats dragging key records into the event-sourced graph store.
type KeyStore struct {
path string
mu sync.Mutex
keys []APIKey
}
// OpenKeyStore loads (or initializes) the key store at path.
func OpenKeyStore(path string) (*KeyStore, error) {
s := &KeyStore{path: path}
raw, err := os.ReadFile(path)
if errors.Is(err, os.ErrNotExist) {
return s, nil
}
if err != nil {
return nil, err
}
if err := json.Unmarshal(raw, &s.keys); err != nil {
return nil, fmt.Errorf("auth: parse %s: %w", path, err)
}
return s, nil
}
// Mint creates a key with the given display name and scopes, persists its
// hash, and returns the full key string — the only time it is available.
func (s *KeyStore) Mint(name string, scopes []string) (key string, rec APIKey, err error) {
idBytes := make([]byte, 4)
secret := make([]byte, 24)
if _, err = rand.Read(idBytes); err != nil {
return "", APIKey{}, err
}
if _, err = rand.Read(secret); err != nil {
return "", APIKey{}, err
}
id := hex.EncodeToString(idBytes)
key = "gck_" + id + "_" + base64.RawURLEncoding.EncodeToString(secret)
rec = APIKey{
ID: id,
Name: name,
Hash: hashKey(key),
Scopes: scopes,
CreatedAt: time.Now().UTC(),
}
s.mu.Lock()
defer s.mu.Unlock()
s.keys = append(s.keys, rec)
if err := s.saveLocked(); err != nil {
s.keys = s.keys[:len(s.keys)-1]
return "", APIKey{}, err
}
return key, rec, nil
}
// Revoke deletes the key with the given id.
func (s *KeyStore) Revoke(id string) error {
s.mu.Lock()
defer s.mu.Unlock()
for i, k := range s.keys {
if k.ID == id {
s.keys = append(s.keys[:i], s.keys[i+1:]...)
return s.saveLocked()
}
}
return ErrKeyNotFound
}
// List returns the stored key records. Callers serving them over HTTP
// should strip Hash themselves — it isn't secret-bearing, but is also not
// a client concern.
func (s *KeyStore) List() []APIKey {
s.mu.Lock()
defer s.mu.Unlock()
out := make([]APIKey, len(s.keys))
copy(out, s.keys)
return out
}
// lookup finds the record whose hash matches the presented key.
func (s *KeyStore) lookup(presented string) (APIKey, bool) {
h := []byte(hashKey(presented))
s.mu.Lock()
defer s.mu.Unlock()
for _, k := range s.keys {
if subtle.ConstantTimeCompare(h, []byte(k.Hash)) == 1 {
return k, true
}
}
return APIKey{}, false
}
// saveLocked writes the store atomically (tmp + rename). Callers hold s.mu.
func (s *KeyStore) saveLocked() error {
raw, err := json.MarshalIndent(s.keys, "", " ")
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(s.path), 0o755); err != nil {
return err
}
tmp := s.path + ".tmp"
if err := os.WriteFile(tmp, raw, 0o600); err != nil {
return err
}
return os.Rename(tmp, s.path)
}
func hashKey(key string) string {
sum := sha256.Sum256([]byte(key))
return hex.EncodeToString(sum[:])
}
// APIKeyAuth authenticates requests carrying `X-API-Key: <key>` against a
// KeyStore. The Principal's Subject is the key id and its Scopes are the
// scopes the key was minted with.
type APIKeyAuth struct {
Keys *KeyStore
}
var _ Authenticator = APIKeyAuth{}
func (a APIKeyAuth) Authenticate(r *http.Request) (*Principal, error) {
presented := r.Header.Get("X-API-Key")
if presented == "" {
return nil, ErrNoCredentials
}
rec, ok := a.Keys.lookup(presented)
if !ok {
return nil, errors.New("auth: invalid api key")
}
return &Principal{Subject: rec.ID, Scopes: rec.Scopes, Kind: KindAPIKey}, nil
}
+87
View File
@@ -0,0 +1,87 @@
// Package auth is the stable cross-service authentication seam for the
// platform. It is provider-agnostic: one Authenticator interface with
// interchangeable impls — OIDC (validates a bearer JWT against any OIDC
// issuer; Zitadel is the default reference), API key (validates X-API-Key
// against the KeyStore), and a Chain combinator that runs them in order.
//
// Packages under platform/ import only the standard library and other
// platform/ packages — never an application module. Application modules
// depend downward onto platform. This is what keeps the auth seam stable
// and reusable.
package auth
import (
"errors"
"net/http"
"slices"
)
// Kind values for Principal.Kind. Comparing against these strings is the
// authorization layer's primitive: handlers ask "is this OIDC or an API
// key?" rather than re-implementing the chain logic.
const (
KindOIDC = "oidc"
KindAPIKey = "apikey"
KindAnonymous = "anonymous"
)
// ErrNoCredentials is returned when a request carries no credentials for the
// authenticator (no bearer token / no API key). Chain treats it like any
// other failure and falls through to the next authenticator.
var ErrNoCredentials = errors.New("auth: no credentials")
// Principal is the authenticated caller. Authorization (scope checks) is a
// thin layer on top: handlers ask "does this principal have scope X", not
// "how did it authenticate".
type Principal struct {
Subject string `json:"subject"` // OIDC sub or API key id
Scopes []string `json:"scopes,omitempty"` // e.g. ["write","admin"]
Kind string `json:"kind"` // KindOIDC | KindAPIKey | KindAnonymous
}
// HasScope reports whether the principal carries the given scope.
func (p *Principal) HasScope(scope string) bool {
return slices.Contains(p.Scopes, scope)
}
// Authenticator inspects request credentials and returns the caller, or an
// error if authentication fails.
type Authenticator interface {
Authenticate(r *http.Request) (*Principal, error)
}
// Chain tries each authenticator in order, returning the first successful
// Principal. It is how bearer-then-apikey composition is expressed.
type Chain []Authenticator
var _ Authenticator = Chain(nil)
// Authenticate returns the first non-error result. On failure it returns
// the first *real* validation error — an authenticator reporting
// ErrNoCredentials merely didn't apply (e.g. no X-API-Key header alongside
// a bad bearer token) and must not mask why the presented credential was
// rejected.
func (c Chain) Authenticate(r *http.Request) (*Principal, error) {
var firstErr error
for _, a := range c {
p, err := a.Authenticate(r)
if err == nil {
return p, nil
}
if firstErr == nil || (errors.Is(firstErr, ErrNoCredentials) && !errors.Is(err, ErrNoCredentials)) {
firstErr = err
}
}
return nil, firstErr
}
// Anonymous authenticates every request as an unauthenticated principal
// with no scopes. It is the placeholder for tests and local-dev runs; it
// must never be wired in production.
type Anonymous struct{}
var _ Authenticator = Anonymous{}
func (Anonymous) Authenticate(*http.Request) (*Principal, error) {
return &Principal{Subject: "anonymous", Kind: KindAnonymous}, nil
}
+302
View File
@@ -0,0 +1,302 @@
package auth
import (
"crypto"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
"time"
)
// --- OIDC ---
// fakeIssuer hosts a discovery document and a JWKS for a generated RSA key,
// and mints RS256 tokens signed by it.
type fakeIssuer struct {
srv *httptest.Server
key *rsa.PrivateKey
}
func newFakeIssuer(t *testing.T) *fakeIssuer {
t.Helper()
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatal(err)
}
f := &fakeIssuer{key: key}
mux := http.NewServeMux()
mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, _ *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]string{"jwks_uri": f.srv.URL + "/jwks"})
})
mux.HandleFunc("/jwks", func(w http.ResponseWriter, _ *http.Request) {
pub := &key.PublicKey
_ = json.NewEncoder(w).Encode(map[string]any{
"keys": []map[string]string{{
"kty": "RSA",
"kid": "test-key",
"n": base64.RawURLEncoding.EncodeToString(pub.N.Bytes()),
"e": base64.RawURLEncoding.EncodeToString(mustBigIntBytes(pub.E)),
}},
})
})
f.srv = httptest.NewServer(mux)
t.Cleanup(f.srv.Close)
return f
}
// mint signs an RS256 JWT with the given claims (kid "test-key").
func (f *fakeIssuer) mint(t *testing.T, claims map[string]any) string {
t.Helper()
enc := func(v any) string {
b, err := json.Marshal(v)
if err != nil {
t.Fatal(err)
}
return base64.RawURLEncoding.EncodeToString(b)
}
signing := enc(map[string]string{"alg": "RS256", "kid": "test-key"}) + "." + enc(claims)
digest := sha256.Sum256([]byte(signing))
sig, err := rsa.SignPKCS1v15(rand.Reader, f.key, crypto.SHA256, digest[:])
if err != nil {
t.Fatal(err)
}
return signing + "." + base64.RawURLEncoding.EncodeToString(sig)
}
// mustBigIntBytes turns an RSA exponent (typically 65537) into its
// big-endian byte encoding for the JWK "e" field.
func mustBigIntBytes(e int) []byte {
// smallest big-endian encoding of e.
n := e
if n == 0 {
return []byte{0}
}
var buf []byte
for n > 0 {
buf = append([]byte{byte(n & 0xff)}, buf...)
n >>= 8
}
return buf
}
func bearerReq(token string) *http.Request {
r := httptest.NewRequest("GET", "/api/nodes", nil)
r.Header.Set("Authorization", "Bearer "+token)
return r
}
func TestOIDCAuthenticate(t *testing.T) {
iss := newFakeIssuer(t)
o := NewOIDC(iss.srv.URL, "my-client")
valid := map[string]any{
"iss": iss.srv.URL,
"sub": "user-1",
"email": "user-1@example.com",
"name": "User One",
"aud": "my-client",
"exp": time.Now().Add(time.Hour).Unix(),
"scope": "openid profile",
}
p, err := o.Authenticate(bearerReq(iss.mint(t, valid)))
if err != nil {
t.Fatalf("valid token rejected: %v", err)
}
if p.Subject != "user-1" || p.Kind != KindOIDC || !p.HasScope("profile") {
t.Fatalf("unexpected principal: %+v", p)
}
// VerifyClaims returns the rich claim set incl. email/name.
rawTok := iss.mint(t, valid)
c, err := o.VerifyClaims(rawTok)
if err != nil {
t.Fatalf("VerifyClaims rejected valid token: %v", err)
}
if c.Subject != "user-1" || c.Email != "user-1@example.com" || c.Name != "User One" || len(c.Scopes) != 2 {
t.Fatalf("unexpected claims: %+v", c)
}
// aud as array form also accepted
arr := map[string]any{
"iss": iss.srv.URL, "sub": "user-1", "aud": []string{"other", "my-client"},
"exp": time.Now().Add(time.Hour).Unix(),
}
if _, err := o.Authenticate(bearerReq(iss.mint(t, arr))); err != nil {
t.Fatalf("array aud rejected: %v", err)
}
cases := map[string]map[string]any{
"expired": {"iss": iss.srv.URL, "sub": "u", "aud": "my-client", "exp": time.Now().Add(-time.Hour).Unix()},
"wrong issuer": {"iss": "https://evil.example", "sub": "u", "aud": "my-client", "exp": time.Now().Add(time.Hour).Unix()},
"wrong audience": {"iss": iss.srv.URL, "sub": "u", "aud": "not-me", "exp": time.Now().Add(time.Hour).Unix()},
}
for name, c := range cases {
if _, err := o.Authenticate(bearerReq(iss.mint(t, c))); err == nil {
t.Errorf("%s token accepted", name)
}
if _, err := o.VerifyClaims(iss.mint(t, c)); err == nil {
t.Errorf("VerifyClaims accepted %s token", name)
}
}
// tampered payload must fail signature verification
tok := iss.mint(t, valid)
tampered := fmt.Sprintf("%s.%s.%s",
tok[:len(tok)/3],
base64.RawURLEncoding.EncodeToString([]byte(`{"iss":"x","sub":"hacker"}`)),
"AAAA")
if _, err := o.Authenticate(bearerReq(tampered)); err == nil {
t.Error("tampered token accepted")
}
if _, err := o.VerifyClaims(tampered); err == nil {
t.Error("VerifyClaims accepted tampered token")
}
// no credentials → ErrNoCredentials so Chain falls through
if _, err := o.Authenticate(httptest.NewRequest("GET", "/", nil)); err == nil {
t.Error("missing bearer accepted")
}
if _, err := o.VerifyClaims(""); err == nil {
t.Error("VerifyClaims accepted empty token")
}
}
// --- API keys ---
func TestKeyStoreMintAuthenticateRevoke(t *testing.T) {
path := filepath.Join(t.TempDir(), "apikeys.json")
ks, err := OpenKeyStore(path)
if err != nil {
t.Fatal(err)
}
key, rec, err := ks.Mint("ci", []string{"nodes:read"})
if err != nil {
t.Fatal(err)
}
a := APIKeyAuth{Keys: ks}
r := httptest.NewRequest("GET", "/api/nodes", nil)
r.Header.Set("X-API-Key", key)
p, err := a.Authenticate(r)
if err != nil {
t.Fatalf("minted key rejected: %v", err)
}
if p.Subject != rec.ID || p.Kind != KindAPIKey || !p.HasScope("nodes:read") {
t.Fatalf("unexpected principal: %+v", p)
}
// persists across reopen
ks2, err := OpenKeyStore(path)
if err != nil {
t.Fatal(err)
}
if _, err := (APIKeyAuth{Keys: ks2}).Authenticate(r); err != nil {
t.Fatalf("key lost after reopen: %v", err)
}
// invalid key rejected
bad := httptest.NewRequest("GET", "/", nil)
bad.Header.Set("X-API-Key", "gck_not_a_real_key")
if _, err := a.Authenticate(bad); err == nil {
t.Error("invalid key accepted")
}
// revoked key rejected
if err := ks.Revoke(rec.ID); err != nil {
t.Fatal(err)
}
if _, err := a.Authenticate(r); err == nil {
t.Error("revoked key accepted")
}
if err := ks.Revoke(rec.ID); err == nil {
t.Error("revoking twice should fail")
}
}
func TestChainBearerThenAPIKey(t *testing.T) {
iss := newFakeIssuer(t)
ks, err := OpenKeyStore(filepath.Join(t.TempDir(), "k.json"))
if err != nil {
t.Fatal(err)
}
key, _, err := ks.Mint("integration", nil)
if err != nil {
t.Fatal(err)
}
chain := Chain{NewOIDC(iss.srv.URL, ""), APIKeyAuth{Keys: ks}}
r := httptest.NewRequest("GET", "/", nil)
r.Header.Set("X-API-Key", key)
p, err := chain.Authenticate(r)
if err != nil {
t.Fatalf("api key via chain rejected: %v", err)
}
if p.Kind != KindAPIKey {
t.Fatalf("expected apikey principal, got %+v", p)
}
tok := iss.mint(t, map[string]any{"iss": iss.srv.URL, "sub": "u1", "exp": time.Now().Add(time.Hour).Unix()})
p, err = chain.Authenticate(bearerReq(tok))
if err != nil {
t.Fatalf("bearer via chain rejected: %v", err)
}
if p.Kind != KindOIDC {
t.Fatalf("expected oidc principal, got %+v", p)
}
if _, err := chain.Authenticate(httptest.NewRequest("GET", "/", nil)); err == nil {
t.Error("credential-less request accepted by chain")
}
}
// --- middleware ---
func TestBearerMiddleware(t *testing.T) {
iss := newFakeIssuer(t)
tok := iss.mint(t, map[string]any{
"iss": iss.srv.URL, "sub": "u", "aud": "client",
"exp": time.Now().Add(time.Hour).Unix(),
})
a := NewOIDC(iss.srv.URL, "client")
hits := 0
handler := BearerMiddleware(a, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
hits++
w.WriteHeader(http.StatusOK)
}))
// valid token → passes through
w := httptest.NewRecorder()
handler.ServeHTTP(w, bearerReq(tok))
if w.Code != http.StatusOK || hits != 1 {
t.Fatalf("valid token: code=%d hits=%d", w.Code, hits)
}
// missing token → 401
w = httptest.NewRecorder()
handler.ServeHTTP(w, httptest.NewRequest("GET", "/", nil))
if w.Code != http.StatusUnauthorized || hits != 1 {
t.Fatalf("missing token: code=%d hits=%d", w.Code, hits)
}
}
func TestWriteMiddlewarePassesReads(t *testing.T) {
a := NewOIDC("https://issuer.example", "")
hits := 0
handler := WriteMiddleware(a, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { hits++ }))
for _, m := range []string{http.MethodGet, http.MethodHead, http.MethodOptions} {
w := httptest.NewRecorder()
handler.ServeHTTP(w, httptest.NewRequest(m, "/", nil))
if w.Code != http.StatusOK {
t.Errorf("%s: code=%d, want 200", m, w.Code)
}
}
}
+35
View File
@@ -0,0 +1,35 @@
package auth
import "net/http"
// BearerMiddleware wraps next, requiring a valid bearer credential from a.
// On success the request is forwarded unchanged; on failure a 401 is
// returned.
func BearerMiddleware(a Authenticator, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if _, err := a.Authenticate(r); err != nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
next.ServeHTTP(w, r)
})
}
// WriteMiddleware wraps next, requiring a valid bearer credential from a
// only for mutating HTTP methods (POST, PUT, PATCH, DELETE). Safe methods
// (GET, HEAD, OPTIONS) pass through unauthenticated — suitable for
// services that serve public reads but gate writes behind auth.
func WriteMiddleware(a Authenticator, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet, http.MethodHead, http.MethodOptions:
next.ServeHTTP(w, r)
default:
if _, err := a.Authenticate(r); err != nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
next.ServeHTTP(w, r)
}
})
}
+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)
}
}