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:
@@ -0,0 +1,89 @@
|
||||
// Package interceptors holds gRPC interceptors shared by both transports:
|
||||
// grpc-gateway forwards the incoming REST request's Authorization header as
|
||||
// gRPC metadata, so the same unary/stream interceptor authenticates both
|
||||
// REST and gRPC callers (design.md §8) — no separate REST auth middleware.
|
||||
package interceptors
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/lestrrat-go/jwx/v2/jwt"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/metadata"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
type claimsContextKey struct{}
|
||||
|
||||
// ClaimsFromContext returns the authenticated token's claims, set by the
|
||||
// auth interceptor after a successful validation.
|
||||
func ClaimsFromContext(ctx context.Context) (jwt.Token, bool) {
|
||||
token, ok := ctx.Value(claimsContextKey{}).(jwt.Token)
|
||||
return token, ok
|
||||
}
|
||||
|
||||
// TokenValidator is the subset of auth.Validator the interceptor needs,
|
||||
// abstracted so tests can inject a fake without a real JWKS endpoint.
|
||||
type TokenValidator interface {
|
||||
Validate(ctx context.Context, tokenString string) (jwt.Token, error)
|
||||
}
|
||||
|
||||
func bearerToken(ctx context.Context) (string, error) {
|
||||
md, ok := metadata.FromIncomingContext(ctx)
|
||||
if !ok {
|
||||
return "", status.Error(codes.Unauthenticated, "missing metadata")
|
||||
}
|
||||
values := md.Get("authorization")
|
||||
if len(values) == 0 {
|
||||
return "", status.Error(codes.Unauthenticated, "missing authorization header")
|
||||
}
|
||||
const prefix = "Bearer "
|
||||
header := values[0]
|
||||
if !strings.HasPrefix(header, prefix) {
|
||||
return "", status.Error(codes.Unauthenticated, "authorization header must be a bearer token")
|
||||
}
|
||||
return strings.TrimPrefix(header, prefix), nil
|
||||
}
|
||||
|
||||
// UnaryServerInterceptor authenticates every unary RPC before it reaches
|
||||
// handler logic.
|
||||
func UnaryServerInterceptor(validator TokenValidator) grpc.UnaryServerInterceptor {
|
||||
return func(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) {
|
||||
token, err := bearerToken(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
claims, err := validator.Validate(ctx, token)
|
||||
if err != nil {
|
||||
return nil, status.Error(codes.Unauthenticated, "invalid token")
|
||||
}
|
||||
return handler(context.WithValue(ctx, claimsContextKey{}, claims), req)
|
||||
}
|
||||
}
|
||||
|
||||
// StreamServerInterceptor authenticates every streaming RPC before it
|
||||
// reaches handler logic.
|
||||
func StreamServerInterceptor(validator TokenValidator) grpc.StreamServerInterceptor {
|
||||
return func(srv any, ss grpc.ServerStream, info *grpc.StreamServerInfo, handler grpc.StreamHandler) error {
|
||||
token, err := bearerToken(ss.Context())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
claims, err := validator.Validate(ss.Context(), token)
|
||||
if err != nil {
|
||||
return status.Error(codes.Unauthenticated, "invalid token")
|
||||
}
|
||||
return handler(srv, &authenticatedStream{ServerStream: ss, ctx: context.WithValue(ss.Context(), claimsContextKey{}, claims)})
|
||||
}
|
||||
}
|
||||
|
||||
type authenticatedStream struct {
|
||||
grpc.ServerStream
|
||||
ctx context.Context
|
||||
}
|
||||
|
||||
func (s *authenticatedStream) Context() context.Context {
|
||||
return s.ctx
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package interceptors
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/lestrrat-go/jwx/v2/jwt"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/metadata"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
type fakeValidator struct {
|
||||
token jwt.Token
|
||||
err error
|
||||
}
|
||||
|
||||
func (f fakeValidator) Validate(ctx context.Context, tokenString string) (jwt.Token, error) {
|
||||
return f.token, f.err
|
||||
}
|
||||
|
||||
func contextWithAuthHeader(value string) context.Context {
|
||||
if value == "" {
|
||||
return context.Background()
|
||||
}
|
||||
return metadata.NewIncomingContext(context.Background(), metadata.Pairs("authorization", value))
|
||||
}
|
||||
|
||||
func TestUnaryServerInterceptorRejectsMissingOrMalformedHeader(t *testing.T) {
|
||||
validator := fakeValidator{token: jwt.New()}
|
||||
interceptor := UnaryServerInterceptor(validator)
|
||||
handlerCalled := false
|
||||
handler := func(ctx context.Context, req any) (any, error) {
|
||||
handlerCalled = true
|
||||
return "ok", nil
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
header string
|
||||
}{
|
||||
{name: "missing header", header: ""},
|
||||
{name: "malformed header (no Bearer prefix)", header: "sometoken"},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
handlerCalled = false
|
||||
ctx := contextWithAuthHeader(tc.header)
|
||||
_, err := interceptor(ctx, nil, &grpc.UnaryServerInfo{}, handler)
|
||||
if err == nil {
|
||||
t.Fatalf("expected error, got none")
|
||||
}
|
||||
if status.Code(err) != codes.Unauthenticated {
|
||||
t.Fatalf("code = %v, want Unauthenticated", status.Code(err))
|
||||
}
|
||||
if handlerCalled {
|
||||
t.Fatalf("handler must not be called when auth fails")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnaryServerInterceptorRejectsInvalidToken(t *testing.T) {
|
||||
validator := fakeValidator{err: errors.New("bad token")}
|
||||
interceptor := UnaryServerInterceptor(validator)
|
||||
handler := func(ctx context.Context, req any) (any, error) {
|
||||
t.Fatalf("handler must not be called when validation fails")
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
ctx := contextWithAuthHeader("Bearer not-a-real-token")
|
||||
_, err := interceptor(ctx, nil, &grpc.UnaryServerInfo{}, handler)
|
||||
if status.Code(err) != codes.Unauthenticated {
|
||||
t.Fatalf("code = %v, want Unauthenticated", status.Code(err))
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnaryServerInterceptorAllowsValidTokenAndInjectsClaims(t *testing.T) {
|
||||
wantToken := jwt.New()
|
||||
_ = wantToken.Set(jwt.SubjectKey, "test-subject")
|
||||
validator := fakeValidator{token: wantToken}
|
||||
interceptor := UnaryServerInterceptor(validator)
|
||||
|
||||
var gotClaims jwt.Token
|
||||
handler := func(ctx context.Context, req any) (any, error) {
|
||||
claims, ok := ClaimsFromContext(ctx)
|
||||
if !ok {
|
||||
t.Fatalf("expected claims in context")
|
||||
}
|
||||
gotClaims = claims
|
||||
return "ok", nil
|
||||
}
|
||||
|
||||
ctx := contextWithAuthHeader("Bearer a-valid-token")
|
||||
resp, err := interceptor(ctx, nil, &grpc.UnaryServerInfo{}, handler)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if resp != "ok" {
|
||||
t.Fatalf("resp = %v, want ok", resp)
|
||||
}
|
||||
if gotClaims.Subject() != "test-subject" {
|
||||
t.Fatalf("subject = %q, want test-subject", gotClaims.Subject())
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user