feat(auth): add Authentik JWT validation shared by gRPC and REST

JWKS caching via lestrrat-go/jwx, signature/iss/aud/exp validation, and
a single gRPC interceptor that grpc-gateway's forwarded headers make
work identically for REST callers.
This commit is contained in:
riotpiaole
2026-06-21 18:55:23 -07:00
parent 5d3dc23e81
commit ef7b54710d
7 changed files with 571 additions and 13 deletions
+94
View File
@@ -0,0 +1,94 @@
// Package auth implements §8 of design.md: Authentik OIDC discovery + JWKS
// caching, and JWT validation shared by the gRPC and REST (grpc-gateway)
// transports via a single interceptor.
package auth
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"github.com/lestrrat-go/jwx/v2/jwk"
)
// DiscoveryDocument is the subset of an OIDC discovery document this package
// needs.
type DiscoveryDocument struct {
Issuer string `json:"issuer"`
JWKSURI string `json:"jwks_uri"`
}
// FetchDiscoveryDocument GETs issuerURL's well-known OIDC discovery document.
func FetchDiscoveryDocument(ctx context.Context, issuerURL string) (*DiscoveryDocument, error) {
url := strings.TrimRight(issuerURL, "/") + "/.well-known/openid-configuration"
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("build discovery request: %w", err)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, fmt.Errorf("fetch discovery document %s: %w", url, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("fetch discovery document %s: status %d", url, resp.StatusCode)
}
var doc DiscoveryDocument
if err := json.NewDecoder(resp.Body).Decode(&doc); err != nil {
return nil, fmt.Errorf("decode discovery document %s: %w", url, err)
}
return &doc, nil
}
// KeySetProvider returns the current JWKS for validating a token's
// signature. Abstracted so tests can inject a static key set instead of
// running a real JWKS HTTP endpoint.
type KeySetProvider interface {
Get(ctx context.Context) (jwk.Set, error)
}
// JWKSCache fetches a JWKS URI once at startup and keeps it fresh via a
// background auto-refresh, so request-path validation never makes a network
// call (design.md §8).
type JWKSCache struct {
jwksURI string
cache *jwk.Cache
}
// NewJWKSCache registers jwksURI with a background auto-refreshing cache and
// performs the initial fetch synchronously so startup fails fast on a
// misconfigured/unreachable JWKS endpoint.
func NewJWKSCache(ctx context.Context, jwksURI string) (*JWKSCache, error) {
cache := jwk.NewCache(ctx)
if err := cache.Register(jwksURI, jwk.WithMinRefreshInterval(15*time.Minute)); err != nil {
return nil, fmt.Errorf("register jwks cache %s: %w", jwksURI, err)
}
if _, err := cache.Refresh(ctx, jwksURI); err != nil {
return nil, fmt.Errorf("initial jwks fetch %s: %w", jwksURI, err)
}
return &JWKSCache{jwksURI: jwksURI, cache: cache}, nil
}
// Get returns the cached key set, refreshing in the background per the
// interval configured in NewJWKSCache rather than on every call.
func (c *JWKSCache) Get(ctx context.Context) (jwk.Set, error) {
return c.cache.Get(ctx, c.jwksURI)
}
// staticKeySet is a KeySetProvider backed by a fixed jwk.Set, used in tests
// to avoid standing up a real JWKS HTTP endpoint.
type staticKeySet struct {
set jwk.Set
}
func StaticKeySet(set jwk.Set) KeySetProvider {
return staticKeySet{set: set}
}
func (s staticKeySet) Get(ctx context.Context) (jwk.Set, error) {
return s.set, nil
}
+52
View File
@@ -0,0 +1,52 @@
package auth
import (
"context"
"fmt"
"github.com/lestrrat-go/jwx/v2/jwt"
)
// Validator validates a bearer JWT's signature, issuer, audience, and
// exp/nbf claims against a cached JWKS (design.md §8).
type Validator struct {
Issuer string
Audience string
Keys KeySetProvider
}
// NewValidator does the OIDC discovery + JWKS cache setup and returns a
// ready-to-use Validator. Fails fast if the discovery document or initial
// JWKS fetch is unreachable.
func NewValidator(ctx context.Context, issuerURL, audience string) (*Validator, error) {
doc, err := FetchDiscoveryDocument(ctx, issuerURL)
if err != nil {
return nil, fmt.Errorf("new validator: %w", err)
}
cache, err := NewJWKSCache(ctx, doc.JWKSURI)
if err != nil {
return nil, fmt.Errorf("new validator: %w", err)
}
return &Validator{Issuer: doc.Issuer, Audience: audience, Keys: cache}, nil
}
// Validate parses and verifies tokenString: signature against the current
// JWKS, plus iss/aud/exp/nbf claims. Returns the parsed token on success.
func (v *Validator) Validate(ctx context.Context, tokenString string) (jwt.Token, error) {
set, err := v.Keys.Get(ctx)
if err != nil {
return nil, fmt.Errorf("validate: fetch key set: %w", err)
}
token, err := jwt.Parse(
[]byte(tokenString),
jwt.WithKeySet(set),
jwt.WithValidate(true),
jwt.WithIssuer(v.Issuer),
jwt.WithAudience(v.Audience),
)
if err != nil {
return nil, fmt.Errorf("validate: %w", err)
}
return token, nil
}
+135
View File
@@ -0,0 +1,135 @@
package auth
import (
"context"
"crypto/rand"
"crypto/rsa"
"testing"
"time"
"github.com/lestrrat-go/jwx/v2/jwa"
"github.com/lestrrat-go/jwx/v2/jwk"
"github.com/lestrrat-go/jwx/v2/jwt"
)
const (
testIssuer = "https://authentik.homelab.internal/application/o/kmsvc/"
testAudience = "kmsvc"
testKeyID = "test-key-1"
)
func generateRSAKeyPair(t *testing.T) *rsa.PrivateKey {
t.Helper()
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("generate rsa key: %v", err)
}
return key
}
func publicKeySet(t *testing.T, priv *rsa.PrivateKey, kid string) jwk.Set {
t.Helper()
pubKey, err := jwk.FromRaw(priv.PublicKey)
if err != nil {
t.Fatalf("jwk from raw: %v", err)
}
if err := pubKey.Set(jwk.KeyIDKey, kid); err != nil {
t.Fatalf("set kid: %v", err)
}
if err := pubKey.Set(jwk.AlgorithmKey, jwa.RS256); err != nil {
t.Fatalf("set alg: %v", err)
}
set := jwk.NewSet()
if err := set.AddKey(pubKey); err != nil {
t.Fatalf("add key to set: %v", err)
}
return set
}
// signToken builds and signs a JWT with the given claims overrides, using
// sane defaults (valid iss/aud/exp) so each test case only needs to override
// what it's testing.
func signToken(t *testing.T, priv *rsa.PrivateKey, kid string, opts ...func(*jwt.Builder)) string {
t.Helper()
builder := jwt.NewBuilder().
Issuer(testIssuer).
Audience([]string{testAudience}).
IssuedAt(time.Now()).
Expiration(time.Now().Add(time.Hour))
for _, opt := range opts {
opt(builder)
}
token, err := builder.Build()
if err != nil {
t.Fatalf("build token: %v", err)
}
signingKey, err := jwk.FromRaw(priv)
if err != nil {
t.Fatalf("jwk from raw priv: %v", err)
}
if err := signingKey.Set(jwk.KeyIDKey, kid); err != nil {
t.Fatalf("set signing kid: %v", err)
}
signed, err := jwt.Sign(token, jwt.WithKey(jwa.RS256, signingKey))
if err != nil {
t.Fatalf("sign token: %v", err)
}
return string(signed)
}
func TestValidatorTableDriven(t *testing.T) {
priv := generateRSAKeyPair(t)
otherPriv := generateRSAKeyPair(t)
keySet := publicKeySet(t, priv, testKeyID)
validator := &Validator{Issuer: testIssuer, Audience: testAudience, Keys: StaticKeySet(keySet)}
tests := []struct {
name string
token string
wantError bool
}{
{
name: "valid token",
token: signToken(t, priv, testKeyID),
},
{
name: "expired token",
token: signToken(t, priv, testKeyID, func(b *jwt.Builder) {
b.Expiration(time.Now().Add(-time.Hour))
}),
wantError: true,
},
{
name: "wrong issuer",
token: signToken(t, priv, testKeyID, func(b *jwt.Builder) {
b.Issuer("https://not-authentik.example.com/")
}),
wantError: true,
},
{
name: "wrong audience",
token: signToken(t, priv, testKeyID, func(b *jwt.Builder) {
b.Audience([]string{"some-other-service"})
}),
wantError: true,
},
{
name: "bad signature (signed by a different key)",
token: signToken(t, otherPriv, testKeyID),
wantError: true,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
_, err := validator.Validate(context.Background(), tc.token)
if tc.wantError && err == nil {
t.Fatalf("expected error, got none")
}
if !tc.wantError && err != nil {
t.Fatalf("unexpected error: %v", err)
}
})
}
}