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,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