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())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user