more platform
This commit is contained in:
1 parent
8a71e32149
commit
2a66754f9e
27 files changed
+2435
-540
No files matched your search
+166
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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