diff --git a/README.md b/README.md index 09f8b6f..ef46b76 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ authkit is a Go library for authentication and authorization in Web API services. It provides reusable request authentication, principal resolution, and authorization plumbing without becoming an identity provider, hosted login system, or policy framework. -The shared auth path works end to end: an API token or OIDC-issued JWT bearer token authenticates to an external identity, the identity resolves to an internal principal, and an authorizer checks that principal against an action, application resource, and optional caller-supplied facts. +The shared auth path works end to end: a short-lived authkit access JWT authenticates to an internal principal, and an authorizer checks that principal against an action, application resource, and optional caller-supplied facts. ## Installation @@ -21,16 +21,24 @@ go run ./examples/notes The example prints a seed API token and starts `http://localhost:8080`. -Use the printed token to call the allowed route: +Exchange the seed API token for an authkit access JWT: ```sh -curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/notes/allowed +ACCESS_TOKEN=$(curl -s -X POST \ + -H "Authorization: Bearer $TOKEN" \ + http://localhost:8080/auth/token | jq -r .access_token) ``` -The same token is authenticated but denied by policy for another note: +Use the access JWT to call the allowed route: ```sh -curl -i -H "Authorization: Bearer $TOKEN" http://localhost:8080/notes/denied +curl -H "Authorization: Bearer $ACCESS_TOKEN" http://localhost:8080/notes/allowed +``` + +The same access JWT is authenticated but denied by policy for another note: + +```sh +curl -i -H "Authorization: Bearer $ACCESS_TOKEN" http://localhost:8080/notes/denied ``` The example is also covered by tests: diff --git a/accessjwt/accessjwt_test.go b/accessjwt/accessjwt_test.go new file mode 100644 index 0000000..6bd7ee6 --- /dev/null +++ b/accessjwt/accessjwt_test.go @@ -0,0 +1,602 @@ +package accessjwt + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "encoding/base64" + "encoding/json" + "testing" + "time" + + "github.com/lestrrat-go/jwx/v3/jwa" + "github.com/lestrrat-go/jwx/v3/jwk" + "github.com/lestrrat-go/jwx/v3/jws" + "github.com/lestrrat-go/jwx/v3/jwt" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/meigma/authkit" + "github.com/meigma/authkit/roleauth" + "github.com/meigma/authkit/store/memory" +) + +const ( + testIssuer = "https://auth.example.test" + testAudience = "notes-api" + testPrincipalID = "principal_1" + testTokenID = "token-123" + testAction = "note:read" + testRoleID = "reader" + testKeyID = "key-1" + rsaKeyBits = 2048 +) + +func TestIssuerRejectsInvalidOptions(t *testing.T) { + privateKey, _ := newRSAKeyPair(t) + + tests := []struct { + name string + opts IssuerOptions + }{ + { + name: "missing issuer", + opts: issuerOptions(privateKey, func(opts *IssuerOptions) { + opts.Issuer = "" + }), + }, + { + name: "missing audience", + opts: issuerOptions(privateKey, func(opts *IssuerOptions) { + opts.Audience = "" + }), + }, + { + name: "missing signing key", + opts: issuerOptions(privateKey, func(opts *IssuerOptions) { + opts.SigningKey = nil + }), + }, + { + name: "non-positive TTL", + opts: issuerOptions(privateKey, func(opts *IssuerOptions) { + opts.TTL = 0 + }), + }, + { + name: "signing key without kid", + opts: issuerOptions(newRSAKey(t, "", DefaultAlgorithm), nil), + }, + { + name: "none algorithm", + opts: issuerOptions(privateKey, func(opts *IssuerOptions) { + opts.Algorithm = jwa.NoSignature().String() + }), + }, + { + name: "symmetric algorithm", + opts: issuerOptions(privateKey, func(opts *IssuerOptions) { + opts.Algorithm = jwa.HS256().String() + }), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + issuer, err := NewIssuer(tt.opts) + + require.Error(t, err) + assert.Nil(t, issuer) + }) + } +} + +func TestVerifierRejectsInvalidOptions(t *testing.T) { + _, publicKey := newRSAKeyPair(t) + keySet := newKeySet(t, publicKey) + + tests := []struct { + name string + opts VerifierOptions + }{ + { + name: "missing issuer", + opts: verifierOptions(keySet, func(opts *VerifierOptions) { + opts.Issuer = "" + }), + }, + { + name: "missing audience", + opts: verifierOptions(keySet, func(opts *VerifierOptions) { + opts.Audience = "" + }), + }, + { + name: "missing key set", + opts: verifierOptions(keySet, func(opts *VerifierOptions) { + opts.KeySet = nil + }), + }, + { + name: "negative skew", + opts: verifierOptions(keySet, func(opts *VerifierOptions) { + opts.AcceptableSkew = -time.Second + }), + }, + { + name: "empty algorithm entry", + opts: verifierOptions(keySet, func(opts *VerifierOptions) { + opts.AllowedAlgorithms = []string{""} + }), + }, + { + name: "unsupported algorithm", + opts: verifierOptions(keySet, func(opts *VerifierOptions) { + opts.AllowedAlgorithms = []string{"unsupported"} + }), + }, + { + name: "symmetric algorithm", + opts: verifierOptions(keySet, func(opts *VerifierOptions) { + opts.AllowedAlgorithms = []string{jwa.HS256().String()} + }), + }, + { + name: "key without kid", + opts: verifierOptions(newKeySet(t, newRSAKey(t, "", DefaultAlgorithm)), nil), + }, + { + name: "key without algorithm", + opts: verifierOptions(newKeySet(t, newRSAKey(t, testKeyID, "")), nil), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + verifier, err := NewVerifier(tt.opts) + + require.Error(t, err) + assert.Nil(t, verifier) + }) + } +} + +func TestIssueAndVerifyToken(t *testing.T) { + issuer, verifier, keySet := newIssuerAndVerifier(t) + + issued, err := issuer.IssueToken(context.Background(), IssueRequest{ + PrincipalID: testPrincipalID, + }) + require.NoError(t, err) + + assert.Equal(t, testTokenID, issued.ID) + assert.Equal(t, testPrincipalID, issued.PrincipalID) + assert.Equal(t, fixedTime(), issued.IssuedAt) + assert.Equal(t, fixedTime().Add(time.Hour), issued.ExpiresAt) + assert.NotEmpty(t, issued.Plaintext) + assertProtectedHeader(t, issued.Plaintext, TokenType, DefaultAlgorithm, testKeyID) + assertNoAuthorizationClaims(t, issued.Plaintext, keySet) + + verified, err := verifier.VerifyToken(context.Background(), issued.Plaintext) + require.NoError(t, err) + + assert.Equal(t, testTokenID, verified.ID) + assert.Equal(t, testPrincipalID, verified.PrincipalID) + assert.Equal(t, testIssuer, verified.Issuer) + assert.Equal(t, testAudience, verified.Audience) + assert.Equal(t, fixedTime(), verified.IssuedAt) + assert.Equal(t, fixedTime().Add(time.Hour), verified.ExpiresAt) +} + +func TestIssueTokenRejectsInvalidRequest(t *testing.T) { + issuer, _, _ := newIssuerAndVerifier(t) + + issued, err := issuer.IssueToken(context.Background(), IssueRequest{}) + + require.Error(t, err) + assert.Empty(t, issued) +} + +func TestIssueTokenRejectsEmptyGeneratedTokenID(t *testing.T) { + privateKey, _ := newRSAKeyPair(t) + issuer, err := NewIssuer(issuerOptions(privateKey, func(opts *IssuerOptions) { + opts.TokenID = func() (string, error) { + return "", nil + } + })) + require.NoError(t, err) + + issued, err := issuer.IssueToken(context.Background(), IssueRequest{ + PrincipalID: testPrincipalID, + }) + + require.Error(t, err) + assert.Empty(t, issued) +} + +func TestVerifyTokenRejectsInvalidTokens(t *testing.T) { + _, verifier, _ := newIssuerAndVerifier(t) + wrongIssuer := issueTokenWithOptions(t, func(opts *IssuerOptions) { + opts.Issuer = "https://other.example.test" + }) + wrongAudience := issueTokenWithOptions(t, func(opts *IssuerOptions) { + opts.Audience = "other-api" + }) + expired := issueTokenWithClock(t, fixedTime().Add(-2*time.Hour)) + + tests := []struct { + name string + token string + }{ + {name: "malformed token", token: "not-a-jwt"}, + {name: "wrong signature", token: issueWithWrongSignature(t)}, + {name: "wrong kid", token: issueWithWrongKeyID(t)}, + {name: "wrong issuer", token: wrongIssuer}, + {name: "wrong audience", token: wrongAudience}, + {name: "expired token", token: expired}, + {name: "missing subject", token: signToken(t, nil, TokenType, DefaultAlgorithm, testKeyID)}, + {name: "wrong typ", token: signToken(t, baseClaims(), "JWT", DefaultAlgorithm, testKeyID)}, + {name: "missing typ", token: signTokenWithoutType(t, baseClaims())}, + {name: "none algorithm", token: unsignedToken(t, baseClaims())}, + {name: "algorithm confusion", token: hmacSignedToken(t, baseClaims())}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + verified, err := verifier.VerifyToken(context.Background(), tt.token) + + require.Error(t, err) + require.ErrorIs(t, err, authkit.ErrUnauthenticated) + assert.Empty(t, verified) + }) + } +} + +func TestVerifyTokenRejectsCriticalHeaders(t *testing.T) { + privateKey, publicKey := newRSAKeyPair(t) + verifier, err := NewVerifier(verifierOptions(newKeySet(t, publicKey), nil)) + require.NoError(t, err) + token := signTokenWithHeaders(t, privateKey, baseClaims(), func(headers jws.Headers) { + require.NoError(t, headers.Set("x-authkit-required", true)) + require.NoError(t, headers.Set(jws.CriticalKey, []string{"x-authkit-required"})) + }) + + verified, err := verifier.VerifyToken(context.Background(), token) + + require.ErrorIs(t, err, authkit.ErrUnauthenticated) + assert.Empty(t, verified) +} + +func TestVerifiedTokenUsesStorageBackedAuthorization(t *testing.T) { + issuer, verifier, _ := newIssuerAndVerifier(t) + store := memory.NewStore() + principal, err := store.CreatePrincipal(context.Background(), authkit.CreatePrincipalRequest{ + Kind: authkit.PrincipalKindService, + DisplayName: "publisher", + }) + require.NoError(t, err) + _, err = store.CreateRole(context.Background(), authkit.CreateRoleRequest{ + ID: testRoleID, + DisplayName: "Reader", + }) + require.NoError(t, err) + require.NoError(t, store.GrantRoleAction(context.Background(), authkit.GrantRoleActionRequest{ + RoleID: testRoleID, + Action: testAction, + })) + require.NoError(t, store.AssignPrincipalRole(context.Background(), authkit.AssignPrincipalRoleRequest{ + PrincipalID: principal.ID, + RoleID: testRoleID, + })) + + issued, err := issuer.IssueToken(context.Background(), IssueRequest{ + PrincipalID: principal.ID, + }) + require.NoError(t, err) + verified, err := verifier.VerifyToken(context.Background(), issued.Plaintext) + require.NoError(t, err) + loaded, err := store.FindPrincipal(context.Background(), verified.PrincipalID) + require.NoError(t, err) + + authorizer, err := roleauth.NewAuthorizer(store) + require.NoError(t, err) + decision, err := authorizer.Can(context.Background(), authkit.AuthorizationCheck{ + Principal: loaded, + Action: testAction, + Resource: authkit.Resource{Type: "note", ID: "note-1"}, + }) + + require.NoError(t, err) + assert.True(t, decision.Allowed) +} + +func newIssuerAndVerifier(t *testing.T) (*Issuer, *Verifier, jwk.Set) { + t.Helper() + + privateKey, publicKey := newRSAKeyPair(t) + keySet := newKeySet(t, publicKey) + issuer, err := NewIssuer(issuerOptions(privateKey, nil)) + require.NoError(t, err) + verifier, err := NewVerifier(verifierOptions(keySet, nil)) + require.NoError(t, err) + + return issuer, verifier, keySet +} + +func issuerOptions(signingKey jwk.Key, mutate func(*IssuerOptions)) IssuerOptions { + opts := IssuerOptions{ + Issuer: testIssuer, + Audience: testAudience, + TTL: time.Hour, + SigningKey: signingKey, + Clock: fixedTime, + TokenID: func() (string, error) { + return testTokenID, nil + }, + } + if mutate != nil { + mutate(&opts) + } + + return opts +} + +func verifierOptions(keySet jwk.Set, mutate func(*VerifierOptions)) VerifierOptions { + opts := VerifierOptions{ + Issuer: testIssuer, + Audience: testAudience, + KeySet: keySet, + Clock: fixedTime, + } + if mutate != nil { + mutate(&opts) + } + + return opts +} + +func issueToken(t *testing.T, issuer *Issuer) IssuedToken { + t.Helper() + + issued, err := issuer.IssueToken(context.Background(), IssueRequest{ + PrincipalID: testPrincipalID, + }) + require.NoError(t, err) + + return issued +} + +func issueTokenWithOptions(t *testing.T, mutate func(*IssuerOptions)) string { + t.Helper() + + privateKey, _ := newRSAKeyPair(t) + issuer, err := NewIssuer(issuerOptions(privateKey, mutate)) + require.NoError(t, err) + + return issueToken(t, issuer).Plaintext +} + +func issueTokenWithClock(t *testing.T, now time.Time) string { + t.Helper() + + return issueTokenWithOptions(t, func(opts *IssuerOptions) { + opts.Clock = func() time.Time { + return now + } + }) +} + +func issueWithWrongSignature(t *testing.T) string { + t.Helper() + + privateKey, _ := newRSAKeyPair(t) + issuer, err := NewIssuer(issuerOptions(privateKey, nil)) + require.NoError(t, err) + + return issueToken(t, issuer).Plaintext +} + +func issueWithWrongKeyID(t *testing.T) string { + t.Helper() + + return issueTokenWithOptions(t, func(opts *IssuerOptions) { + opts.SigningKey = newRSAKey(t, "other-key", DefaultAlgorithm) + }) +} + +func signToken( + t *testing.T, + claims map[string]any, + tokenType string, + algorithmName string, + keyID string, +) string { + t.Helper() + + key := newRSAKey(t, keyID, algorithmName) + algorithm, err := signatureAlgorithm(algorithmName) + require.NoError(t, err) + token := jwt.New() + for name, value := range claims { + require.NoError(t, token.Set(name, value)) + } + headers := jws.NewHeaders() + require.NoError(t, headers.Set(jws.TypeKey, tokenType)) + signed, err := jwt.Sign(token, jwt.WithKey(algorithm, key, jws.WithProtectedHeaders(headers))) + require.NoError(t, err) + + return string(signed) +} + +func signTokenWithHeaders( + t *testing.T, + key jwk.Key, + claims map[string]any, + mutate func(jws.Headers), +) string { + t.Helper() + + algorithm, err := signatureAlgorithm(DefaultAlgorithm) + require.NoError(t, err) + token := jwt.New() + for name, value := range claims { + require.NoError(t, token.Set(name, value)) + } + headers := jws.NewHeaders() + require.NoError(t, headers.Set(jws.TypeKey, TokenType)) + if mutate != nil { + mutate(headers) + } + signed, err := jwt.Sign(token, jwt.WithKey(algorithm, key, jws.WithProtectedHeaders(headers))) + require.NoError(t, err) + + return string(signed) +} + +func signTokenWithoutType(t *testing.T, claims map[string]any) string { + t.Helper() + + token := jwt.New() + for name, value := range claims { + require.NoError(t, token.Set(name, value)) + } + payload, err := json.Marshal(token) + require.NoError(t, err) + + key := newRSAKey(t, testKeyID, DefaultAlgorithm) + signed, err := jws.Sign(payload, jws.WithKey(jwa.RS256(), key)) + require.NoError(t, err) + + return string(signed) +} + +func unsignedToken(t *testing.T, claims map[string]any) string { + t.Helper() + + header := map[string]any{ + jws.AlgorithmKey: jwa.NoSignature().String(), + jws.TypeKey: TokenType, + } + headerBytes, err := json.Marshal(header) + require.NoError(t, err) + payloadBytes, err := json.Marshal(claims) + require.NoError(t, err) + + return base64.RawURLEncoding.EncodeToString(headerBytes) + "." + + base64.RawURLEncoding.EncodeToString(payloadBytes) + "." +} + +func hmacSignedToken(t *testing.T, claims map[string]any) string { + t.Helper() + + key, err := jwk.Import([]byte("secret")) + require.NoError(t, err) + require.NoError(t, key.Set(jwk.KeyIDKey, testKeyID)) + token := jwt.New() + for name, value := range claims { + require.NoError(t, token.Set(name, value)) + } + headers := jws.NewHeaders() + require.NoError(t, headers.Set(jws.TypeKey, TokenType)) + signed, err := jwt.Sign(token, jwt.WithKey(jwa.HS256(), key, jws.WithProtectedHeaders(headers))) + require.NoError(t, err) + + return string(signed) +} + +func baseClaims() map[string]any { + now := fixedTime() + + return map[string]any{ + jwt.IssuerKey: testIssuer, + jwt.SubjectKey: testPrincipalID, + jwt.AudienceKey: []string{testAudience}, + jwt.IssuedAtKey: now, + jwt.ExpirationKey: now.Add(time.Hour), + jwt.JwtIDKey: testTokenID, + } +} + +func assertProtectedHeader(t *testing.T, plaintext string, tokenType string, algorithm string, keyID string) { + t.Helper() + + message, err := jws.Parse([]byte(plaintext), jws.WithCompact()) + require.NoError(t, err) + require.Len(t, message.Signatures(), 1) + headers := message.Signatures()[0].ProtectedHeaders() + require.NotNil(t, headers) + + gotType, ok := headers.Type() + require.True(t, ok) + assert.Equal(t, tokenType, gotType) + gotAlgorithm, ok := headers.Algorithm() + require.True(t, ok) + assert.Equal(t, algorithm, gotAlgorithm.String()) + gotKeyID, ok := headers.KeyID() + require.True(t, ok) + assert.Equal(t, keyID, gotKeyID) +} + +func assertNoAuthorizationClaims(t *testing.T, plaintext string, keySet jwk.Set) { + t.Helper() + + token, err := jwt.Parse( + []byte(plaintext), + jwt.WithKeySet(keySet), + jwt.WithIssuer(testIssuer), + jwt.WithAudience(testAudience), + jwt.WithClock(jwt.ClockFunc(fixedTime)), + ) + require.NoError(t, err) + assert.ElementsMatch(t, []string{ + jwt.AudienceKey, + jwt.ExpirationKey, + jwt.IssuedAtKey, + jwt.IssuerKey, + jwt.JwtIDKey, + jwt.SubjectKey, + }, token.Keys()) + assert.False(t, token.Has("roles")) + assert.False(t, token.Has("permissions")) + assert.False(t, token.Has("actions")) +} + +func newRSAKeyPair(t *testing.T) (jwk.Key, jwk.Key) { + t.Helper() + + privateKey := newRSAKey(t, testKeyID, DefaultAlgorithm) + publicKey, err := jwk.PublicKeyOf(privateKey) + require.NoError(t, err) + + return privateKey, publicKey +} + +func newRSAKey(t *testing.T, keyID string, algorithm string) jwk.Key { + t.Helper() + + rawKey, err := rsa.GenerateKey(rand.Reader, rsaKeyBits) + require.NoError(t, err) + key, err := jwk.Import(rawKey) + require.NoError(t, err) + if keyID != "" { + require.NoError(t, key.Set(jwk.KeyIDKey, keyID)) + } + if algorithm != "" { + require.NoError(t, key.Set(jwk.AlgorithmKey, algorithm)) + } + + return key +} + +func newKeySet(t *testing.T, key jwk.Key) jwk.Set { + t.Helper() + + keySet := jwk.NewSet() + require.NoError(t, keySet.AddKey(key)) + + return keySet +} + +func fixedTime() time.Time { + return time.Date(2026, time.May, 13, 21, 0, 0, 0, time.UTC) +} diff --git a/accessjwt/doc.go b/accessjwt/doc.go new file mode 100644 index 0000000..2e323e8 --- /dev/null +++ b/accessjwt/doc.go @@ -0,0 +1,6 @@ +// Package accessjwt issues and verifies authkit-owned access JWTs. +// +// Access JWTs authenticate an authkit principal. They intentionally carry only +// principal identity and token metadata; authorization data stays in authkit +// storage and is evaluated at request time. +package accessjwt diff --git a/accessjwt/issuer.go b/accessjwt/issuer.go new file mode 100644 index 0000000..016df71 --- /dev/null +++ b/accessjwt/issuer.go @@ -0,0 +1,148 @@ +package accessjwt + +import ( + "context" + "crypto/rand" + "errors" + "fmt" + "strings" + "time" + + "github.com/lestrrat-go/jwx/v3/jwa" + "github.com/lestrrat-go/jwx/v3/jwk" + "github.com/lestrrat-go/jwx/v3/jws" + "github.com/lestrrat-go/jwx/v3/jwt" +) + +// Issuer issues signed authkit access JWTs. +type Issuer struct { + issuer string + audience string + ttl time.Duration + signingKey jwk.Key + algorithm jwa.SignatureAlgorithm + clock func() time.Time + tokenID TokenIDFunc +} + +// NewIssuer constructs an Issuer from opts. +func NewIssuer(opts IssuerOptions) (*Issuer, error) { + if err := validateRequiredString("issuer", opts.Issuer); err != nil { + return nil, err + } + if err := validateRequiredString("audience", opts.Audience); err != nil { + return nil, err + } + if opts.TTL <= 0 { + return nil, errors.New("accessjwt: TTL must be positive") + } + if opts.SigningKey == nil { + return nil, errors.New("accessjwt: signing key is required") + } + if err := validateKeyID("signing key", opts.SigningKey); err != nil { + return nil, err + } + + algorithm, err := signatureAlgorithm(defaultString(opts.Algorithm, DefaultAlgorithm)) + if err != nil { + return nil, err + } + if err := validateOptionalKeyAlgorithm("signing key", opts.SigningKey, algorithm); err != nil { + return nil, err + } + + clock := opts.Clock + if clock == nil { + clock = time.Now + } + tokenID := opts.TokenID + if tokenID == nil { + tokenID = randomTokenID + } + + return &Issuer{ + issuer: opts.Issuer, + audience: opts.Audience, + ttl: opts.TTL, + signingKey: opts.SigningKey, + algorithm: algorithm, + clock: clock, + tokenID: tokenID, + }, nil +} + +// IssueToken issues a signed compact access JWT for req.PrincipalID. +func (i *Issuer) IssueToken(ctx context.Context, req IssueRequest) (IssuedToken, error) { + if err := ctx.Err(); err != nil { + return IssuedToken{}, err + } + if err := validateRequiredString("principal ID", req.PrincipalID); err != nil { + return IssuedToken{}, err + } + + tokenID, tokenIDErr := i.tokenID() + if tokenIDErr != nil { + return IssuedToken{}, fmt.Errorf("accessjwt: generate token ID: %w", tokenIDErr) + } + if validationErr := validateRequiredString("token ID", tokenID); validationErr != nil { + return IssuedToken{}, validationErr + } + + issuedAt := i.clock() + expiresAt := issuedAt.Add(i.ttl) + token, err := jwt.NewBuilder(). + Issuer(i.issuer). + Subject(req.PrincipalID). + Audience([]string{i.audience}). + IssuedAt(issuedAt). + Expiration(expiresAt). + JwtID(tokenID). + Build() + if err != nil { + return IssuedToken{}, fmt.Errorf("accessjwt: build token: %w", err) + } + + headers := jws.NewHeaders() + if headerErr := headers.Set(jws.TypeKey, TokenType); headerErr != nil { + return IssuedToken{}, fmt.Errorf("accessjwt: set token type: %w", headerErr) + } + + signed, err := jwt.Sign( + token, + jwt.WithKey(i.algorithm, i.signingKey, jws.WithProtectedHeaders(headers)), + ) + if err != nil { + return IssuedToken{}, fmt.Errorf("accessjwt: sign token: %w", err) + } + + return IssuedToken{ + ID: tokenID, + Plaintext: string(signed), + PrincipalID: req.PrincipalID, + IssuedAt: issuedAt, + ExpiresAt: expiresAt, + }, nil +} + +func validateRequiredString(name string, value string) error { + if strings.TrimSpace(value) == "" { + return fmt.Errorf("accessjwt: %s is required", name) + } + if strings.TrimSpace(value) != value { + return fmt.Errorf("accessjwt: %s must not contain surrounding whitespace", name) + } + + return nil +} + +func defaultString(value string, fallback string) string { + if value == "" { + return fallback + } + + return value +} + +func randomTokenID() (string, error) { + return rand.Text(), nil +} diff --git a/accessjwt/keys.go b/accessjwt/keys.go new file mode 100644 index 0000000..05f65cc --- /dev/null +++ b/accessjwt/keys.go @@ -0,0 +1,114 @@ +package accessjwt + +import ( + "errors" + "fmt" + "strings" + + "github.com/lestrrat-go/jwx/v3/jwa" + "github.com/lestrrat-go/jwx/v3/jwk" +) + +func signatureAlgorithms(names []string) (map[string]jwa.SignatureAlgorithm, error) { + if len(names) == 0 { + names = []string{DefaultAlgorithm} + } + + algorithmMap := make(map[string]jwa.SignatureAlgorithm, len(names)) + for i, name := range names { + algorithm, err := signatureAlgorithm(name) + if err != nil { + return nil, fmt.Errorf("accessjwt: allowed algorithm %d: %w", i, err) + } + if _, ok := algorithmMap[algorithm.String()]; ok { + continue + } + + algorithmMap[algorithm.String()] = algorithm + } + + return algorithmMap, nil +} + +func signatureAlgorithm(name string) (jwa.SignatureAlgorithm, error) { + trimmed := strings.TrimSpace(name) + if trimmed == "" { + return jwa.EmptySignatureAlgorithm(), errors.New("signing algorithm is required") + } + if trimmed != name { + return jwa.EmptySignatureAlgorithm(), errors.New("signing algorithm must not contain surrounding whitespace") + } + + algorithm, ok := jwa.LookupSignatureAlgorithm(trimmed) + if !ok || algorithm == jwa.EmptySignatureAlgorithm() || algorithm == jwa.NoSignature() { + return jwa.EmptySignatureAlgorithm(), fmt.Errorf("unsupported signing algorithm %q", trimmed) + } + if algorithm.IsSymmetric() { + return jwa.EmptySignatureAlgorithm(), fmt.Errorf("symmetric signing algorithm %q is not supported", trimmed) + } + + return algorithm, nil +} + +func validateKeySet(set jwk.Set, allowed map[string]jwa.SignatureAlgorithm) error { + for index := range set.Len() { + key, ok := set.Key(index) + if !ok || key == nil { + return fmt.Errorf("accessjwt: key set entry %d is required", index) + } + if err := validateKeyID(fmt.Sprintf("key set entry %d", index), key); err != nil { + return err + } + algorithm, ok := key.Algorithm() + if !ok { + return fmt.Errorf("accessjwt: key set entry %d algorithm is required", index) + } + signatureAlgorithm, ok := algorithm.(jwa.SignatureAlgorithm) + if !ok { + return fmt.Errorf("accessjwt: key set entry %d algorithm must be a signature algorithm", index) + } + if _, ok := allowed[signatureAlgorithm.String()]; !ok { + return fmt.Errorf( + "accessjwt: key set entry %d algorithm %q is not allowed", + index, + signatureAlgorithm.String(), + ) + } + } + + return nil +} + +func validateKeyID(name string, key jwk.Key) error { + keyID, ok := key.KeyID() + if !ok || strings.TrimSpace(keyID) == "" { + return fmt.Errorf("accessjwt: %s kid is required", name) + } + if strings.TrimSpace(keyID) != keyID { + return fmt.Errorf("accessjwt: %s kid must not contain surrounding whitespace", name) + } + + return nil +} + +func validateOptionalKeyAlgorithm(name string, key jwk.Key, expected jwa.SignatureAlgorithm) error { + keyAlgorithm, ok := key.Algorithm() + if !ok { + return nil + } + + signatureAlgorithm, ok := keyAlgorithm.(jwa.SignatureAlgorithm) + if !ok { + return fmt.Errorf("accessjwt: %s algorithm must be a signature algorithm", name) + } + if signatureAlgorithm.String() != expected.String() { + return fmt.Errorf( + "accessjwt: %s algorithm %q does not match %q", + name, + signatureAlgorithm.String(), + expected.String(), + ) + } + + return nil +} diff --git a/accessjwt/types.go b/accessjwt/types.go new file mode 100644 index 0000000..2c7465b --- /dev/null +++ b/accessjwt/types.go @@ -0,0 +1,108 @@ +package accessjwt + +import ( + "time" + + "github.com/lestrrat-go/jwx/v3/jwk" +) + +const ( + // DefaultAlgorithm is the signing algorithm used when an options struct omits one. + DefaultAlgorithm = "RS256" + + // TokenType is the protected JWS typ header used for access JWTs. + TokenType = "at+jwt" +) + +// TokenIDFunc generates a unique token ID for an issued access JWT. +type TokenIDFunc func() (string, error) + +// IssueRequest describes a request to issue an access JWT. +type IssueRequest struct { + // PrincipalID identifies the principal authenticated by the token. + PrincipalID string +} + +// IssuedToken describes an access JWT immediately after issuance. +type IssuedToken struct { + // ID is the token's jti claim. + ID string + + // Plaintext is the signed compact JWT returned to the caller. + Plaintext string + + // PrincipalID is the principal authenticated by the token. + PrincipalID string + + // IssuedAt is the token's iat claim. + IssuedAt time.Time + + // ExpiresAt is the token's exp claim. + ExpiresAt time.Time +} + +// VerifiedToken describes a successfully verified access JWT. +type VerifiedToken struct { + // ID is the token's jti claim. + ID string + + // PrincipalID is the principal authenticated by the token. + PrincipalID string + + // Issuer is the verified iss claim. + Issuer string + + // Audience is the configured audience that matched the aud claim. + Audience string + + // IssuedAt is the token's iat claim. + IssuedAt time.Time + + // ExpiresAt is the token's exp claim. + ExpiresAt time.Time +} + +// IssuerOptions configures an Issuer. +type IssuerOptions struct { + // Issuer is the exact iss claim written to issued tokens. + Issuer string + + // Audience is the aud claim written to issued tokens. + Audience string + + // TTL is the lifetime of issued tokens. + TTL time.Duration + + // SigningKey is the private JWK used to sign tokens. It must carry a non-empty kid. + SigningKey jwk.Key + + // Algorithm is the JWS signing algorithm. Empty selects DefaultAlgorithm. + Algorithm string + + // Clock returns the current time. Nil selects time.Now. + Clock func() time.Time + + // TokenID generates jti values. Nil selects a cryptographically random generator. + TokenID TokenIDFunc +} + +// VerifierOptions configures a Verifier. +type VerifierOptions struct { + // Issuer is the exact iss claim accepted by the verifier. + Issuer string + + // Audience is the aud claim value accepted by the verifier. + Audience string + + // KeySet contains public verification keys. Each key must carry a kid and allowed alg. + KeySet jwk.Set + + // AllowedAlgorithms limits accepted JWS signing algorithms. Empty selects DefaultAlgorithm. + AllowedAlgorithms []string + + // AcceptableSkew is the allowed difference for exp, iat, and nbf validation. + AcceptableSkew time.Duration + + // Clock returns the current time. Nil selects time.Now. + Clock func() time.Time +} diff --git a/accessjwt/verifier.go b/accessjwt/verifier.go new file mode 100644 index 0000000..220741a --- /dev/null +++ b/accessjwt/verifier.go @@ -0,0 +1,178 @@ +package accessjwt + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/lestrrat-go/jwx/v3/jwa" + "github.com/lestrrat-go/jwx/v3/jwk" + "github.com/lestrrat-go/jwx/v3/jws" + "github.com/lestrrat-go/jwx/v3/jwt" + + "github.com/meigma/authkit" +) + +// Verifier verifies authkit access JWTs. +type Verifier struct { + issuer string + audience string + keySet jwk.Set + allowedAlgorithms map[string]jwa.SignatureAlgorithm + acceptableSkew time.Duration + clock func() time.Time +} + +// NewVerifier constructs a Verifier from opts. +func NewVerifier(opts VerifierOptions) (*Verifier, error) { + if err := validateRequiredString("issuer", opts.Issuer); err != nil { + return nil, err + } + if err := validateRequiredString("audience", opts.Audience); err != nil { + return nil, err + } + if opts.KeySet == nil || opts.KeySet.Len() == 0 { + return nil, errors.New("accessjwt: key set is required") + } + if opts.AcceptableSkew < 0 { + return nil, errors.New("accessjwt: acceptable skew must not be negative") + } + + algorithmMap, err := signatureAlgorithms(opts.AllowedAlgorithms) + if err != nil { + return nil, err + } + if validationErr := validateKeySet(opts.KeySet, algorithmMap); validationErr != nil { + return nil, validationErr + } + + keySet, err := opts.KeySet.Clone() + if err != nil { + return nil, fmt.Errorf("accessjwt: clone key set: %w", err) + } + + clock := opts.Clock + if clock == nil { + clock = time.Now + } + + return &Verifier{ + issuer: opts.Issuer, + audience: opts.Audience, + keySet: keySet, + allowedAlgorithms: algorithmMap, + acceptableSkew: opts.AcceptableSkew, + clock: clock, + }, nil +} + +// VerifyToken verifies plaintext and returns its principal token metadata. +func (v *Verifier) VerifyToken(ctx context.Context, plaintext string) (VerifiedToken, error) { + if err := ctx.Err(); err != nil { + return VerifiedToken{}, err + } + if plaintext == "" { + return VerifiedToken{}, unauthenticated("token is required") + } + + if err := v.validateProtectedHeaders([]byte(plaintext)); err != nil { + return VerifiedToken{}, unauthenticated(err.Error()) + } + + token, err := jwt.Parse( + []byte(plaintext), + jwt.WithKeySet(v.keySet), + jwt.WithIssuer(v.issuer), + jwt.WithAudience(v.audience), + jwt.WithRequiredClaim(jwt.SubjectKey), + jwt.WithRequiredClaim(jwt.JwtIDKey), + jwt.WithRequiredClaim(jwt.IssuedAtKey), + jwt.WithRequiredClaim(jwt.ExpirationKey), + jwt.WithClock(jwt.ClockFunc(v.clock)), + jwt.WithAcceptableSkew(v.acceptableSkew), + ) + if err != nil { + return VerifiedToken{}, unauthenticated("JWT verification failed") + } + + verified, err := v.verifiedToken(token) + if err != nil { + return VerifiedToken{}, unauthenticated(err.Error()) + } + + return verified, nil +} + +func (v *Verifier) validateProtectedHeaders(raw []byte) error { + message, err := jws.Parse(raw, jws.WithCompact()) + if err != nil { + return errors.New("malformed JWT") + } + + signatures := message.Signatures() + if len(signatures) != 1 { + return errors.New("JWT must have exactly one signature") + } + + headers := signatures[0].ProtectedHeaders() + if headers == nil { + return errors.New("JWT protected header is required") + } + tokenType, ok := headers.Type() + if !ok || tokenType != TokenType { + return errors.New("JWT type must be at+jwt") + } + keyID, ok := headers.KeyID() + if !ok || keyID == "" { + return errors.New("JWT key ID is required") + } + algorithm, ok := headers.Algorithm() + if !ok { + return errors.New("JWT algorithm is required") + } + if _, ok := v.allowedAlgorithms[algorithm.String()]; !ok { + return errors.New("JWT algorithm is not allowed") + } + if critical, ok := headers.Critical(); ok && len(critical) > 0 { + return errors.New("JWT critical headers are not supported") + } + + return nil +} + +func (v *Verifier) verifiedToken(token jwt.Token) (VerifiedToken, error) { + principalID, ok := token.Subject() + if !ok || principalID == "" { + return VerifiedToken{}, errors.New("subject claim is required") + } + tokenID, ok := token.JwtID() + if !ok || tokenID == "" { + return VerifiedToken{}, errors.New("JWT ID claim is required") + } + issuer, ok := token.Issuer() + if !ok || issuer == "" { + return VerifiedToken{}, errors.New("issuer claim is required") + } + issuedAt, ok := token.IssuedAt() + if !ok || issuedAt.IsZero() { + return VerifiedToken{}, errors.New("issued-at claim is required") + } + expiresAt, ok := token.Expiration() + if !ok || expiresAt.IsZero() { + return VerifiedToken{}, errors.New("expiration claim is required") + } + + return VerifiedToken{ + ID: tokenID, + PrincipalID: principalID, + Issuer: issuer, + Audience: v.audience, + IssuedAt: issuedAt, + ExpiresAt: expiresAt, + }, nil +} + +func unauthenticated(reason string) error { + return fmt.Errorf("%w: %s", authkit.ErrUnauthenticated, reason) +} diff --git a/accessjwtauth/authenticator.go b/accessjwtauth/authenticator.go new file mode 100644 index 0000000..b02474b --- /dev/null +++ b/accessjwtauth/authenticator.go @@ -0,0 +1,100 @@ +package accessjwtauth + +import ( + "context" + "errors" + "fmt" + "net/http" + "strings" + + "github.com/meigma/authkit" + "github.com/meigma/authkit/accessjwt" +) + +const ( + // Name identifies the authkit access-token authenticator. + Name = "authkit-access-token" + + bearerScheme = "Bearer" +) + +// Authenticator verifies authkit access JWT bearer tokens from HTTP requests. +type Authenticator struct { + verifier *accessjwt.Verifier + principalFinder authkit.PrincipalFinder +} + +// NewAuthenticator constructs an access JWT request authenticator. +func NewAuthenticator( + verifier *accessjwt.Verifier, + principalFinder authkit.PrincipalFinder, +) (*Authenticator, error) { + if verifier == nil { + return nil, errors.New("accessjwtauth: verifier is required") + } + if principalFinder == nil { + return nil, errors.New("accessjwtauth: principal finder is required") + } + + return &Authenticator{ + verifier: verifier, + principalFinder: principalFinder, + }, nil +} + +// Name returns the stable authenticator name. +func (a *Authenticator) Name() string { + return Name +} + +// AuthenticatePrincipal verifies the request's access JWT and loads its principal. +func (a *Authenticator) AuthenticatePrincipal( + ctx context.Context, + req *http.Request, +) (*authkit.PrincipalAuthentication, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + if req == nil { + return nil, unauthenticated("request is required") + } + + rawToken, err := bearerToken(req) + if err != nil { + return nil, err + } + + verified, err := a.verifier.VerifyToken(ctx, rawToken) + if err != nil { + return nil, err + } + + principal, err := a.principalFinder.FindPrincipal(ctx, verified.PrincipalID) + if errors.Is(err, authkit.ErrPrincipalNotFound) { + return nil, unauthenticated("principal not found") + } + if err != nil { + return nil, fmt.Errorf("%w: find principal: %w", authkit.ErrInternal, err) + } + if principal.ID == "" { + return nil, fmt.Errorf("%w: principal finder returned principal without ID", authkit.ErrInternal) + } + + return &authkit.PrincipalAuthentication{ + Principal: principal, + }, nil +} + +func bearerToken(req *http.Request) (string, error) { + header := req.Header.Get("Authorization") + parts := strings.Fields(header) + if len(parts) != 2 || !strings.EqualFold(parts[0], bearerScheme) { + return "", unauthenticated("bearer token is required") + } + + return parts[1], nil +} + +func unauthenticated(reason string) error { + return fmt.Errorf("%w: %s", authkit.ErrUnauthenticated, reason) +} diff --git a/accessjwtauth/authenticator_test.go b/accessjwtauth/authenticator_test.go new file mode 100644 index 0000000..bd30e98 --- /dev/null +++ b/accessjwtauth/authenticator_test.go @@ -0,0 +1,227 @@ +package accessjwtauth_test + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/lestrrat-go/jwx/v3/jwa" + "github.com/lestrrat-go/jwx/v3/jwk" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/meigma/authkit" + "github.com/meigma/authkit/accessjwt" + "github.com/meigma/authkit/accessjwtauth" + "github.com/meigma/authkit/store/memory" +) + +const ( + testIssuer = "https://auth.example.test" + testAudience = "notes-api" + testKeyID = "key-1" + testPrincipalID = "principal_1" + testTokenID = "token-123" +) + +func TestNewAuthenticatorValidatesDependencies(t *testing.T) { + _, verifier := newIssuerAndVerifier(t) + store := memory.NewStore() + + tests := []struct { + name string + verifier *accessjwt.Verifier + principalFinder authkit.PrincipalFinder + }{ + { + name: "missing verifier", + principalFinder: store, + }, + { + name: "missing principal finder", + verifier: verifier, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + authenticator, err := accessjwtauth.NewAuthenticator(tt.verifier, tt.principalFinder) + + require.Error(t, err) + assert.Nil(t, authenticator) + }) + } +} + +func TestAuthenticatorAuthenticatesAccessJWT(t *testing.T) { + issuer, verifier := newIssuerAndVerifier(t) + store := memory.NewStore() + principal, err := store.CreatePrincipal(context.Background(), authkit.CreatePrincipalRequest{ + Kind: authkit.PrincipalKindService, + DisplayName: "notes service", + }) + require.NoError(t, err) + authenticator, err := accessjwtauth.NewAuthenticator(verifier, store) + require.NoError(t, err) + issued, err := issuer.IssueToken(context.Background(), accessjwt.IssueRequest{ + PrincipalID: principal.ID, + }) + require.NoError(t, err) + req := requestWithBearer(issued.Plaintext) + + authentication, err := authenticator.AuthenticatePrincipal(context.Background(), req) + + require.NoError(t, err) + assert.Equal(t, accessjwtauth.Name, authenticator.Name()) + require.NotNil(t, authentication) + assert.Equal(t, principal, authentication.Principal) +} + +func TestAuthenticatorRejectsInvalidRequests(t *testing.T) { + privateKey, publicKey := newRSAKeyPair(t) + issuer := newIssuer(t, privateKey, fixedTime(), nil) + verifier := newVerifier(t, publicKey) + store := memory.NewStore() + principal, err := store.CreatePrincipal(context.Background(), authkit.CreatePrincipalRequest{ + Kind: authkit.PrincipalKindService, + DisplayName: "notes service", + }) + require.NoError(t, err) + valid, err := issuer.IssueToken(context.Background(), accessjwt.IssueRequest{ + PrincipalID: principal.ID, + }) + require.NoError(t, err) + expiredIssuer := newIssuer(t, privateKey, fixedTime().Add(-2*time.Hour), nil) + expired, err := expiredIssuer.IssueToken(context.Background(), accessjwt.IssueRequest{ + PrincipalID: principal.ID, + }) + require.NoError(t, err) + wrongIssuer := newIssuer(t, privateKey, fixedTime(), func(opts *accessjwt.IssuerOptions) { + opts.Issuer = "https://other.example.test" + }) + wrongIssuerToken, err := wrongIssuer.IssueToken(context.Background(), accessjwt.IssueRequest{ + PrincipalID: principal.ID, + }) + require.NoError(t, err) + wrongAudience := newIssuer(t, privateKey, fixedTime(), func(opts *accessjwt.IssuerOptions) { + opts.Audience = "other-api" + }) + wrongAudienceToken, err := wrongAudience.IssueToken(context.Background(), accessjwt.IssueRequest{ + PrincipalID: principal.ID, + }) + require.NoError(t, err) + missingPrincipal, err := issuer.IssueToken(context.Background(), accessjwt.IssueRequest{ + PrincipalID: "missing", + }) + require.NoError(t, err) + authenticator, err := accessjwtauth.NewAuthenticator(verifier, store) + require.NoError(t, err) + + tests := []struct { + name string + req *http.Request + }{ + {name: "missing bearer", req: httptest.NewRequest(http.MethodGet, "/", nil)}, + {name: "malformed bearer", req: requestWithAuthorization("Basic " + valid.Plaintext)}, + {name: "invalid JWT", req: requestWithBearer("not-a-jwt")}, + {name: "expired JWT", req: requestWithBearer(expired.Plaintext)}, + {name: "wrong issuer", req: requestWithBearer(wrongIssuerToken.Plaintext)}, + {name: "wrong audience", req: requestWithBearer(wrongAudienceToken.Plaintext)}, + {name: "missing principal", req: requestWithBearer(missingPrincipal.Plaintext)}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + authentication, err := authenticator.AuthenticatePrincipal(context.Background(), tt.req) + + require.ErrorIs(t, err, authkit.ErrUnauthenticated) + assert.Nil(t, authentication) + }) + } +} + +func newIssuerAndVerifier(t *testing.T) (*accessjwt.Issuer, *accessjwt.Verifier) { + t.Helper() + + privateKey, publicKey := newRSAKeyPair(t) + return newIssuer(t, privateKey, fixedTime(), nil), newVerifier(t, publicKey) +} + +func newIssuer( + t *testing.T, + privateKey jwk.Key, + now time.Time, + mutate func(*accessjwt.IssuerOptions), +) *accessjwt.Issuer { + t.Helper() + + issuerOpts := accessjwt.IssuerOptions{ + Issuer: testIssuer, + Audience: testAudience, + TTL: time.Hour, + SigningKey: privateKey, + Clock: func() time.Time { + return now + }, + TokenID: func() (string, error) { + return testTokenID, nil + }, + } + if mutate != nil { + mutate(&issuerOpts) + } + issuer, err := accessjwt.NewIssuer(issuerOpts) + require.NoError(t, err) + + return issuer +} + +func newVerifier(t *testing.T, publicKey jwk.Key) *accessjwt.Verifier { + t.Helper() + + keySet := jwk.NewSet() + require.NoError(t, keySet.AddKey(publicKey)) + verifier, err := accessjwt.NewVerifier(accessjwt.VerifierOptions{ + Issuer: testIssuer, + Audience: testAudience, + KeySet: keySet, + Clock: fixedTime, + }) + require.NoError(t, err) + + return verifier +} + +func newRSAKeyPair(t *testing.T) (jwk.Key, jwk.Key) { + t.Helper() + + rawKey, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + privateKey, err := jwk.Import(rawKey) + require.NoError(t, err) + require.NoError(t, privateKey.Set(jwk.KeyIDKey, testKeyID)) + require.NoError(t, privateKey.Set(jwk.AlgorithmKey, jwa.RS256())) + publicKey, err := jwk.PublicKeyOf(privateKey) + require.NoError(t, err) + + return privateKey, publicKey +} + +func requestWithBearer(token string) *http.Request { + return requestWithAuthorization("Bearer " + token) +} + +func requestWithAuthorization(header string) *http.Request { + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Set("Authorization", header) + + return req +} + +func fixedTime() time.Time { + return time.Date(2026, time.May, 13, 22, 0, 0, 0, time.UTC) +} diff --git a/accessjwtauth/doc.go b/accessjwtauth/doc.go new file mode 100644 index 0000000..eff0093 --- /dev/null +++ b/accessjwtauth/doc.go @@ -0,0 +1,2 @@ +// Package accessjwtauth authenticates HTTP bearer requests with authkit access JWTs. +package accessjwtauth diff --git a/apikey/authenticator.go b/apikey/authenticator.go deleted file mode 100644 index d4eadba..0000000 --- a/apikey/authenticator.go +++ /dev/null @@ -1,52 +0,0 @@ -package apikey - -import ( - "context" - "errors" - "net/http" - "strings" - - "github.com/meigma/authkit" -) - -const bearerScheme = "Bearer" - -// Authenticator verifies API tokens from HTTP Authorization headers. -type Authenticator struct { - service *Service -} - -// NewAuthenticator constructs an API-token authenticator. -func NewAuthenticator(service *Service) (*Authenticator, error) { - if service == nil { - return nil, errors.New("apikey: service is required") - } - - return &Authenticator{ - service: service, - }, nil -} - -// Name returns the stable authenticator name. -func (a *Authenticator) Name() string { - return Provider -} - -// Authenticate verifies the request's bearer API token. -func (a *Authenticator) Authenticate(ctx context.Context, req *http.Request) (*authkit.Identity, error) { - if err := ctx.Err(); err != nil { - return nil, err - } - - if req == nil { - return nil, unauthenticated("request is required") - } - - header := req.Header.Get("Authorization") - parts := strings.Fields(header) - if len(parts) != 2 || !strings.EqualFold(parts[0], bearerScheme) { - return nil, unauthenticated("bearer token is required") - } - - return a.service.VerifyToken(ctx, parts[1]) -} diff --git a/apikey/doc.go b/apikey/doc.go index 37637ad..629c9b7 100644 --- a/apikey/doc.go +++ b/apikey/doc.go @@ -1,7 +1,7 @@ -// Package apikey provides opaque API-token issuing and authentication. +// Package apikey provides opaque API-token issuing and verification. // // Tokens are storage-backed, revocable credentials. The service stores only a // SHA-256 hash of the token secret, returns the plaintext token only at issue -// time, and authenticates successful tokens to authkit.Identity values with the -// Provider set to "api-token". +// time, and verifies successful tokens to principal-bearing token metadata for +// access JWT exchange. package apikey diff --git a/apikey/service.go b/apikey/service.go index ed79699..3e07f35 100644 --- a/apikey/service.go +++ b/apikey/service.go @@ -76,52 +76,50 @@ func (s *Service) IssueToken(ctx context.Context, req IssueRequest) (IssuedToken ID: tokenID, Plaintext: plaintext, ExpiresAt: req.ExpiresAt, - IdentityLink: authkit.LinkIdentityRequest{ - Provider: Provider, - Subject: tokenID, - PrincipalID: req.PrincipalID, - }, }, nil } -// VerifyToken authenticates plaintext and returns its external identity. -func (s *Service) VerifyToken(ctx context.Context, plaintext string) (*authkit.Identity, error) { +// VerifyAPIToken authenticates plaintext and returns its API-token metadata. +func (s *Service) VerifyAPIToken(ctx context.Context, plaintext string) (VerifiedToken, error) { if err := ctx.Err(); err != nil { - return nil, err + return VerifiedToken{}, err } tokenID, secret, ok := parseToken(plaintext) if !ok { - return nil, unauthenticated("malformed token") + return VerifiedToken{}, unauthenticated("malformed token") } stored, err := s.store.FindToken(ctx, tokenID) if errors.Is(err, ErrTokenNotFound) { - return nil, unauthenticated("token not found") + return VerifiedToken{}, unauthenticated("token not found") } if err != nil { - return nil, fmt.Errorf("%w: find token: %w", authkit.ErrInternal, err) + return VerifiedToken{}, fmt.Errorf("%w: find token: %w", authkit.ErrInternal, err) } secretHash := hashSecret(secret) if subtle.ConstantTimeCompare(secretHash[:], stored.SecretHash[:]) != 1 { - return nil, unauthenticated("token secret mismatch") + return VerifiedToken{}, unauthenticated("token secret mismatch") } now := s.clock() if stored.RevokedAt != nil { - return nil, unauthenticated("token revoked") + return VerifiedToken{}, unauthenticated("token revoked") } if !now.Before(stored.ExpiresAt) { - return nil, unauthenticated("token expired") + return VerifiedToken{}, unauthenticated("token expired") + } + if stored.PrincipalID == "" { + return VerifiedToken{}, fmt.Errorf("%w: stored token principal ID is required", authkit.ErrInternal) } _ = s.store.UpdateTokenLastUsed(ctx, tokenID, now) - return &authkit.Identity{ - Provider: Provider, - Subject: tokenID, - CredentialID: tokenID, + return VerifiedToken{ + ID: tokenID, + PrincipalID: stored.PrincipalID, + ExpiresAt: stored.ExpiresAt, }, nil } diff --git a/apikey/service_test.go b/apikey/service_test.go index a238a76..22a9978 100644 --- a/apikey/service_test.go +++ b/apikey/service_test.go @@ -4,8 +4,6 @@ import ( "context" "crypto/sha256" "errors" - "net/http" - "net/http/httptest" "strings" "testing" "time" @@ -38,11 +36,6 @@ func TestServiceIssueToken(t *testing.T) { require.NoError(t, err) assert.NotEmpty(t, issued.ID) assert.Equal(t, expiresAt, issued.ExpiresAt) - assert.Equal(t, authkit.LinkIdentityRequest{ - Provider: apikey.Provider, - Subject: issued.ID, - PrincipalID: testPrincipalID, - }, issued.IdentityLink) require.True(t, strings.HasPrefix(issued.Plaintext, "ak_"+issued.ID+"_")) require.Len(t, strings.Split(issued.Plaintext, "_"), tokenParts) @@ -89,20 +82,19 @@ func TestServiceIssueTokenValidatesRequest(t *testing.T) { } } -func TestServiceVerifyToken(t *testing.T) { +func TestServiceVerifyAPIToken(t *testing.T) { now := fixedTime() service, store := newService(t, now) issued := issueToken(t, service, now.Add(time.Hour)) - identity, err := service.VerifyToken(context.Background(), issued.Plaintext) + verified, err := service.VerifyAPIToken(context.Background(), issued.Plaintext) require.NoError(t, err) - require.NotNil(t, identity) - assert.Equal(t, &authkit.Identity{ - Provider: apikey.Provider, - Subject: issued.ID, - CredentialID: issued.ID, - }, identity) + assert.Equal(t, apikey.VerifiedToken{ + ID: issued.ID, + PrincipalID: testPrincipalID, + ExpiresAt: issued.ExpiresAt, + }, verified) stored, err := store.FindToken(context.Background(), issued.ID) require.NoError(t, err) @@ -110,7 +102,7 @@ func TestServiceVerifyToken(t *testing.T) { assert.Equal(t, now, *stored.LastUsedAt) } -func TestServiceVerifyTokenRejectsInvalidTokens(t *testing.T) { +func TestServiceVerifyAPITokenRejectsInvalidTokens(t *testing.T) { now := fixedTime() service, _ := newService(t, now) issued := issueToken(t, service, now.Add(time.Hour)) @@ -129,15 +121,15 @@ func TestServiceVerifyTokenRejectsInvalidTokens(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - identity, err := service.VerifyToken(context.Background(), tt.plaintext) + verified, err := service.VerifyAPIToken(context.Background(), tt.plaintext) require.ErrorIs(t, err, authkit.ErrUnauthenticated) - assert.Nil(t, identity) + assert.Empty(t, verified) }) } } -func TestServiceVerifyTokenRejectsExpiredToken(t *testing.T) { +func TestServiceVerifyAPITokenRejectsExpiredToken(t *testing.T) { now := fixedTime() current := now service, _ := newServiceWithClock(t, func() time.Time { @@ -146,13 +138,13 @@ func TestServiceVerifyTokenRejectsExpiredToken(t *testing.T) { issued := issueToken(t, service, now.Add(time.Hour)) current = now.Add(time.Hour) - identity, err := service.VerifyToken(context.Background(), issued.Plaintext) + verified, err := service.VerifyAPIToken(context.Background(), issued.Plaintext) require.ErrorIs(t, err, authkit.ErrUnauthenticated) - assert.Nil(t, identity) + assert.Empty(t, verified) } -func TestServiceVerifyTokenIgnoresLastUsedUpdateErrors(t *testing.T) { +func TestServiceVerifyAPITokenIgnoresLastUsedUpdateErrors(t *testing.T) { now := fixedTime() service, store := newService(t, now) issued := issueToken(t, service, now.Add(time.Hour)) @@ -164,11 +156,11 @@ func TestServiceVerifyTokenIgnoresLastUsedUpdateErrors(t *testing.T) { })) require.NoError(t, err) - identity, err := service.VerifyToken(context.Background(), issued.Plaintext) + verified, err := service.VerifyAPIToken(context.Background(), issued.Plaintext) require.NoError(t, err) - require.NotNil(t, identity) - assert.Equal(t, issued.ID, identity.Subject) + assert.Equal(t, issued.ID, verified.ID) + assert.Equal(t, testPrincipalID, verified.PrincipalID) } func TestServiceRevokeToken(t *testing.T) { @@ -184,107 +176,15 @@ func TestServiceRevokeToken(t *testing.T) { require.NotNil(t, stored.RevokedAt) assert.Equal(t, now, *stored.RevokedAt) - identity, err := service.VerifyToken(context.Background(), issued.Plaintext) + verified, err := service.VerifyAPIToken(context.Background(), issued.Plaintext) require.ErrorIs(t, err, authkit.ErrUnauthenticated) - assert.Nil(t, identity) -} - -func TestAuthenticatorAcceptsBearerSchemeCaseInsensitively(t *testing.T) { - now := fixedTime() - service, _ := newService(t, now) - issued := issueToken(t, service, now.Add(time.Hour)) - authenticator, err := apikey.NewAuthenticator(service) - require.NoError(t, err) - assert.Equal(t, apikey.Provider, authenticator.Name()) - - tests := []struct { - name string - scheme string - }{ - {name: "canonical", scheme: "Bearer"}, - {name: "lowercase", scheme: "bearer"}, - {name: "mixed case", scheme: "bEaReR"}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - req := httptest.NewRequest(http.MethodGet, "/", nil) - req.Header.Set("Authorization", tt.scheme+" "+issued.Plaintext) - - identity, err := authenticator.Authenticate(context.Background(), req) - - require.NoError(t, err) - require.NotNil(t, identity) - assert.Equal(t, issued.ID, identity.Subject) - }) - } -} - -func TestAuthenticatorRejectsInvalidHeaders(t *testing.T) { - now := fixedTime() - service, _ := newService(t, now) - authenticator, err := apikey.NewAuthenticator(service) - require.NoError(t, err) - - tests := []struct { - name string - header string - }{ - {name: "missing"}, - {name: "wrong scheme", header: "Basic token"}, - {name: "empty bearer", header: "Bearer "}, - {name: "extra fields", header: "Bearer token extra"}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - req := httptest.NewRequest(http.MethodGet, "/", nil) - if tt.header != "" { - req.Header.Set("Authorization", tt.header) - } - - identity, err := authenticator.Authenticate(context.Background(), req) - - require.ErrorIs(t, err, authkit.ErrUnauthenticated) - assert.Nil(t, identity) - }) - } -} - -func TestTokenIdentityResolvesThroughMemoryStore(t *testing.T) { - now := fixedTime() - store := memory.NewStore() - service, err := apikey.NewService(store, apikey.WithClock(func() time.Time { - return now - })) - require.NoError(t, err) - principal, err := store.CreatePrincipal(context.Background(), authkit.CreatePrincipalRequest{ - Kind: authkit.PrincipalKindService, - DisplayName: "deploy service", - }) - require.NoError(t, err) - issued := issueTokenForPrincipal(t, service, principal.ID, now.Add(time.Hour)) - _, err = store.LinkIdentity(context.Background(), issued.IdentityLink) - require.NoError(t, err) - - identity, err := service.VerifyToken(context.Background(), issued.Plaintext) - require.NoError(t, err) - require.NotNil(t, identity) - resolved, err := store.ResolveIdentity(context.Background(), *identity) - - require.NoError(t, err) - require.NotNil(t, resolved) - assert.Equal(t, principal, *resolved) + assert.Empty(t, verified) } func TestConstructorsValidateRequiredDependencies(t *testing.T) { service, err := apikey.NewService(nil) require.Error(t, err) assert.Nil(t, service) - - authenticator, err := apikey.NewAuthenticator(nil) - require.Error(t, err) - assert.Nil(t, authenticator) } func newService(t *testing.T, now time.Time) (*apikey.Service, *memory.Store) { @@ -299,6 +199,10 @@ func newServiceWithClock(t *testing.T, clock func() time.Time) (*apikey.Service, t.Helper() store := memory.NewStore() + _, err := store.CreatePrincipal(context.Background(), authkit.CreatePrincipalRequest{ + Kind: authkit.PrincipalKindService, + }) + require.NoError(t, err) service, err := apikey.NewService(store, apikey.WithClock(clock)) require.NoError(t, err) diff --git a/apikey/types.go b/apikey/types.go index 7e9d124..206e1ac 100644 --- a/apikey/types.go +++ b/apikey/types.go @@ -3,16 +3,11 @@ package apikey import ( "crypto/sha256" "time" - - "github.com/meigma/authkit" ) -// Provider identifies identities produced by API-token authentication. -const Provider = "api-token" - // IssueRequest describes a request to issue an opaque API token. type IssueRequest struct { - // PrincipalID identifies the principal the token should be linked to. + // PrincipalID identifies the principal the token should authenticate as. PrincipalID string // Name is an optional human-readable token label. @@ -32,9 +27,18 @@ type IssuedToken struct { // ExpiresAt is the time after which the token must no longer authenticate. ExpiresAt time.Time +} + +// VerifiedToken describes a successfully verified API token. +type VerifiedToken struct { + // ID is the stable lookup identifier embedded in the token. + ID string - // IdentityLink is the explicit identity-link request applications can store for the token. - IdentityLink authkit.LinkIdentityRequest + // PrincipalID identifies the principal the token authenticates as. + PrincipalID string + + // ExpiresAt is the time after which the token must no longer authenticate. + ExpiresAt time.Time } // TokenMetadata describes an API token without its secret material. diff --git a/compose/authenticators.go b/compose/authenticators.go index b564586..a28648d 100644 --- a/compose/authenticators.go +++ b/compose/authenticators.go @@ -2,16 +2,43 @@ package compose import ( "github.com/meigma/authkit" - "github.com/meigma/authkit/apikey" + "github.com/meigma/authkit/accessjwt" + "github.com/meigma/authkit/accessjwtauth" "github.com/meigma/authkit/oidc" ) +// PrincipalAuthenticatorSpec builds one authkit principal authenticator for a composed service. +type PrincipalAuthenticatorSpec interface { + // BuildPrincipalAuthenticator constructs the authenticator represented by the spec. + BuildPrincipalAuthenticator() (authkit.PrincipalAuthenticator, error) +} + // AuthenticatorSpec builds one authkit authenticator for a composed service. type AuthenticatorSpec interface { // BuildAuthenticator constructs the authenticator represented by the spec. BuildAuthenticator() (authkit.Authenticator, error) } +type accessJWTAuthenticatorSpec struct { + verifier *accessjwt.Verifier + principalFinder authkit.PrincipalFinder +} + +// AccessJWT configures an access JWT authenticator from verifier and principalFinder. +func AccessJWT( + verifier *accessjwt.Verifier, + principalFinder authkit.PrincipalFinder, +) PrincipalAuthenticatorSpec { + return accessJWTAuthenticatorSpec{ + verifier: verifier, + principalFinder: principalFinder, + } +} + +func (s accessJWTAuthenticatorSpec) BuildPrincipalAuthenticator() (authkit.PrincipalAuthenticator, error) { + return accessjwtauth.NewAuthenticator(s.verifier, s.principalFinder) +} + type existingAuthenticatorSpec struct { authenticator authkit.Authenticator } @@ -25,19 +52,6 @@ func (s existingAuthenticatorSpec) BuildAuthenticator() (authkit.Authenticator, return s.authenticator, nil } -type apiTokenAuthenticatorSpec struct { - service *apikey.Service -} - -// APIToken configures an API-token authenticator from service. -func APIToken(service *apikey.Service) AuthenticatorSpec { - return apiTokenAuthenticatorSpec{service: service} -} - -func (s apiTokenAuthenticatorSpec) BuildAuthenticator() (authkit.Authenticator, error) { - return apikey.NewAuthenticator(s.service) -} - type oidcAuthenticatorSpec struct { source oidc.ProviderSource opts []oidc.Option diff --git a/compose/doc.go b/compose/doc.go index 5ecd85d..99df854 100644 --- a/compose/doc.go +++ b/compose/doc.go @@ -1,6 +1,7 @@ // Package compose provides thin convenience wiring for common authkit setups. // -// The package is a composition layer over the explicit authkit, apikey, oidc, -// and httpauth packages. It does not own storage, provider trust, policy -// setup, migrations, seed credentials, or management workflows. +// The package is a composition layer over the explicit authkit, accessjwt, +// accessjwtauth, apikey, oidc, and httpauth packages. It does not own storage, +// provider trust, policy setup, migrations, seed credentials, or management +// workflows. package compose diff --git a/compose/http.go b/compose/http.go index 8ea55c8..c6be413 100644 --- a/compose/http.go +++ b/compose/http.go @@ -10,6 +10,9 @@ import ( // HTTPOptions configures HTTP auth composition. type HTTPOptions struct { + // PrincipalAuthenticators are built and tried before identity authenticators. + PrincipalAuthenticators []PrincipalAuthenticatorSpec + // Authenticators are built and tried in order. Authenticators []AuthenticatorSpec @@ -34,15 +37,23 @@ type HTTP struct { // NewHTTP composes authenticators, a pipeline, and net/http middleware. func NewHTTP(opts HTTPOptions) (*HTTP, error) { + principalAuthenticators, err := buildPrincipalAuthenticators(opts.PrincipalAuthenticators) + if err != nil { + return nil, err + } authenticators, err := buildAuthenticators(opts.Authenticators) if err != nil { return nil, err } + if len(principalAuthenticators) == 0 && len(authenticators) == 0 { + return nil, errors.New("compose: at least one authenticator is required") + } pipeline, err := authkit.NewPipeline(authkit.PipelineOptions{ - Authenticators: authenticators, - Resolver: opts.Resolver, - Authorizer: opts.Authorizer, + PrincipalAuthenticators: principalAuthenticators, + Authenticators: authenticators, + Resolver: opts.Resolver, + Authorizer: opts.Authorizer, }) if err != nil { return nil, fmt.Errorf("compose: create pipeline: %w", err) @@ -59,11 +70,30 @@ func NewHTTP(opts HTTPOptions) (*HTTP, error) { }, nil } -func buildAuthenticators(specs []AuthenticatorSpec) ([]authkit.Authenticator, error) { - if len(specs) == 0 { - return nil, errors.New("compose: at least one authenticator is required") +func buildPrincipalAuthenticators( + specs []PrincipalAuthenticatorSpec, +) ([]authkit.PrincipalAuthenticator, error) { + authenticators := make([]authkit.PrincipalAuthenticator, 0, len(specs)) + for i, spec := range specs { + if spec == nil { + return nil, fmt.Errorf("compose: principal authenticator spec %d is required", i) + } + + authenticator, err := spec.BuildPrincipalAuthenticator() + if err != nil { + return nil, fmt.Errorf("compose: build principal authenticator %d: %w", i, err) + } + if authenticator == nil { + return nil, fmt.Errorf("compose: principal authenticator %d built nil authenticator", i) + } + + authenticators = append(authenticators, authenticator) } + return authenticators, nil +} + +func buildAuthenticators(specs []AuthenticatorSpec) ([]authkit.Authenticator, error) { authenticators := make([]authkit.Authenticator, 0, len(specs)) for i, spec := range specs { if spec == nil { diff --git a/compose/http_test.go b/compose/http_test.go index 1704f3f..2c327a1 100644 --- a/compose/http_test.go +++ b/compose/http_test.go @@ -6,15 +6,15 @@ import ( "net/http" "net/http/httptest" "testing" - "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/meigma/authkit" - "github.com/meigma/authkit/apikey" + "github.com/meigma/authkit/accessjwt" "github.com/meigma/authkit/compose" "github.com/meigma/authkit/httpauth" + "github.com/meigma/authkit/internal/authtest" "github.com/meigma/authkit/oidc" "github.com/meigma/authkit/store/memory" ) @@ -44,6 +44,14 @@ func TestNewHTTPValidatesInputs(t *testing.T) { }, want: "compose: authenticator spec 0 is required", }, + { + name: "rejects nil principal authenticator spec", + options: compose.HTTPOptions{ + PrincipalAuthenticators: []compose.PrincipalAuthenticatorSpec{nil}, + Authorizer: testAuthorizer{}, + }, + want: "compose: principal authenticator spec 0 is required", + }, { name: "wraps authenticator build errors", options: compose.HTTPOptions{ @@ -58,6 +66,19 @@ func TestNewHTTPValidatesInputs(t *testing.T) { want: "compose: build authenticator 0: boom", wantIs: boom, }, + { + name: "wraps principal authenticator build errors", + options: compose.HTTPOptions{ + PrincipalAuthenticators: []compose.PrincipalAuthenticatorSpec{ + principalAuthenticatorSpecFunc(func() (authkit.PrincipalAuthenticator, error) { + return nil, boom + }), + }, + Authorizer: testAuthorizer{}, + }, + want: "compose: build principal authenticator 0: boom", + wantIs: boom, + }, { name: "rejects nil built authenticator", options: compose.HTTPOptions{ @@ -73,6 +94,20 @@ func TestNewHTTPValidatesInputs(t *testing.T) { }, want: "compose: authenticator 0 built nil authenticator", }, + { + name: "rejects nil built principal authenticator", + options: compose.HTTPOptions{ + PrincipalAuthenticators: []compose.PrincipalAuthenticatorSpec{ + principalAuthenticatorSpecFunc(func() (authkit.PrincipalAuthenticator, error) { + var authenticator authkit.PrincipalAuthenticator + + return authenticator, nil + }), + }, + Authorizer: testAuthorizer{}, + }, + want: "compose: principal authenticator 0 built nil authenticator", + }, { name: "wraps missing resolver error", options: compose.HTTPOptions{ @@ -104,6 +139,34 @@ func TestNewHTTPValidatesInputs(t *testing.T) { } } +func TestNewHTTPWithAccessJWTDoesNotRequireResolver(t *testing.T) { + ctx := context.Background() + store := memory.NewStore() + principal, err := store.CreatePrincipal(ctx, authkit.CreatePrincipalRequest{ + Kind: authkit.PrincipalKindService, + DisplayName: "notes service", + }) + require.NoError(t, err) + issuer, verifier := newAccessJWTIssuerAndVerifier(t) + issued, err := issuer.IssueToken(ctx, accessjwt.IssueRequest{ + PrincipalID: principal.ID, + }) + require.NoError(t, err) + kit, err := compose.NewHTTP(compose.HTTPOptions{ + PrincipalAuthenticators: []compose.PrincipalAuthenticatorSpec{ + compose.AccessJWT(verifier, store), + }, + Authorizer: testAuthorizer{}, + }) + require.NoError(t, err) + + authentication, err := kit.Pipeline.Authenticate(ctx, requestWithBearer(issued.Plaintext)) + + require.NoError(t, err) + assert.Empty(t, authentication.Identity) + assert.Equal(t, principal.ID, authentication.Principal.ID) +} + func TestNewHTTPPreservesAuthenticatorOrder(t *testing.T) { first := newTestAuthenticator("first") second := newTestAuthenticator("second") @@ -178,40 +241,6 @@ func TestNewHTTPAppliesMiddlewareOptions(t *testing.T) { assert.Contains(t, recorder.Body.String(), "custom auth error") } -func TestAPITokenSpecBuildsUsableAuthenticator(t *testing.T) { - ctx := context.Background() - store := memory.NewStore() - principal, err := store.CreatePrincipal(ctx, authkit.CreatePrincipalRequest{ - Kind: authkit.PrincipalKindService, - DisplayName: "test service", - }) - require.NoError(t, err) - tokenService, err := apikey.NewService(store) - require.NoError(t, err) - issued, err := tokenService.IssueToken(ctx, apikey.IssueRequest{ - PrincipalID: principal.ID, - Name: "test token", - ExpiresAt: time.Now().Add(time.Hour), - }) - require.NoError(t, err) - _, err = store.LinkIdentity(ctx, issued.IdentityLink) - require.NoError(t, err) - kit, err := compose.NewHTTP(compose.HTTPOptions{ - Authenticators: []compose.AuthenticatorSpec{ - compose.APIToken(tokenService), - }, - Resolver: store, - Authorizer: testAuthorizer{}, - }) - require.NoError(t, err) - - authentication, err := kit.Pipeline.Authenticate(ctx, requestWithBearer(issued.Plaintext)) - - require.NoError(t, err) - assert.Equal(t, apikey.Provider, authentication.Identity.Provider) - assert.Equal(t, principal.ID, authentication.Principal.ID) -} - func TestOIDCSpecBuildsAuthenticator(t *testing.T) { source, err := oidc.NewStaticProviderSource() require.NoError(t, err) @@ -235,11 +264,6 @@ func TestHelperSpecsWrapConstructorErrors(t *testing.T) { spec compose.AuthenticatorSpec want string }{ - { - name: "API token service is required", - spec: compose.APIToken(nil), - want: "compose: build authenticator 0: apikey: service is required", - }, { name: "OIDC provider source is required", spec: compose.OIDC(nil), @@ -261,12 +285,52 @@ func TestHelperSpecsWrapConstructorErrors(t *testing.T) { } } +func TestAccessJWTSpecWrapsConstructorErrors(t *testing.T) { + _, verifier := newAccessJWTIssuerAndVerifier(t) + store := memory.NewStore() + + tests := []struct { + name string + spec compose.PrincipalAuthenticatorSpec + want string + }{ + { + name: "verifier is required", + spec: compose.AccessJWT(nil, store), + want: "compose: build principal authenticator 0: accessjwtauth: verifier is required", + }, + { + name: "principal finder is required", + spec: compose.AccessJWT(verifier, nil), + want: "compose: build principal authenticator 0: accessjwtauth: principal finder is required", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := compose.NewHTTP(compose.HTTPOptions{ + PrincipalAuthenticators: []compose.PrincipalAuthenticatorSpec{tt.spec}, + Authorizer: testAuthorizer{}, + }) + + require.Error(t, err) + assert.Contains(t, err.Error(), tt.want) + }) + } +} + type authenticatorSpecFunc func() (authkit.Authenticator, error) func (f authenticatorSpecFunc) BuildAuthenticator() (authkit.Authenticator, error) { return f() } +type principalAuthenticatorSpecFunc func() (authkit.PrincipalAuthenticator, error) + +func (f principalAuthenticatorSpecFunc) BuildPrincipalAuthenticator() (authkit.PrincipalAuthenticator, error) { + return f() +} + type testAuthenticator struct { name string identity authkit.Identity @@ -322,3 +386,9 @@ func requestWithBearer(token string) *http.Request { return req } + +func newAccessJWTIssuerAndVerifier(t *testing.T) (*accessjwt.Issuer, *accessjwt.Verifier) { + t.Helper() + + return authtest.NewAccessJWTIssuerAndVerifier(t) +} diff --git a/doc.go b/doc.go index e334696..84aa3a1 100644 --- a/doc.go +++ b/doc.go @@ -1,14 +1,14 @@ // Package authkit provides core authentication and authorization contracts for // Go Web API services. // -// The core pipeline keeps credentials separate from application principals: -// authenticators return external Identity values, a PrincipalResolver maps -// those identities to internal Principal values, and an Authorizer evaluates -// authorization checks containing the principal, action, application Resource, -// and caller-supplied Facts. The apikey and oidc packages are concrete -// authenticators for opaque API tokens and OIDC-issued JWT bearer tokens. The -// provisioning package can wrap a resolver to create principals for -// caller-approved identities and assign initial local roles from admin-managed -// CEL provisioning rules. The roleauth package authorizes from local -// admin-managed roles and effective action grants. +// The core pipeline keeps credentials separate from authorization decisions: +// principal authenticators return internal Principal values for authkit-owned +// request credentials, while legacy identity authenticators can still return +// external Identity values that a PrincipalResolver maps to Principal values. +// An Authorizer evaluates authorization checks containing the principal, action, +// application Resource, and caller-supplied Facts. The accessjwt package issues +// and verifies authkit-owned access JWTs, exchange converts verified credentials +// into access JWTs, accessjwtauth adapts access JWTs to HTTP bearer +// authentication, and roleauth authorizes from local admin-managed roles and +// effective action grants. package authkit diff --git a/docs/docs/explanations/architecture.md b/docs/docs/explanations/architecture.md index cf68262..da77188 100644 --- a/docs/docs/explanations/architecture.md +++ b/docs/docs/explanations/architecture.md @@ -6,21 +6,28 @@ description: Understand the authkit authentication and authorization architectur # Architecture authkit separates credentials, application principals, and authorization policy. -That separation lets API tokens and OIDC-issued JWTs authenticate through -different mechanisms while resolving to the same internal principal. +That separation lets API tokens act as exchange credentials while protected +resource routes authenticate short-lived authkit access JWTs. ## Core Pipeline -The core request path is: +The JWT-first protected-resource path is: ```text -Authenticator -> Identity -> PrincipalResolver -> Principal -> Authorizer +PrincipalAuthenticator -> Principal -> Authorizer ``` -An `Authenticator` verifies a credential on an HTTP request and returns an -external `Identity`. A `PrincipalResolver` maps that external identity to an -internal `Principal`. An `Authorizer` decides whether an authorization check -containing the principal, action, resource, and caller-supplied facts is allowed. +An `authkit.PrincipalAuthenticator` verifies an authkit-issued request +credential and returns an internal `Principal`. An `Authorizer` decides whether +an authorization check containing the principal, action, resource, and +caller-supplied facts is allowed. + +The older identity path still exists for external credentials that have not yet +been moved behind exchange flows: + +```text +Authenticator -> Identity -> PrincipalResolver -> Principal -> Authorizer +``` The invariant is credential independence: permissions attach to the internal principal, not to a token, JWT, email address, or provider-specific user record. @@ -78,6 +85,9 @@ code. Adapters sit at the edges: - `apikey` issues and verifies opaque API tokens. +- `exchange` converts verified credentials into access JWTs. +- `accessjwt` issues and verifies authkit-owned access JWTs. +- `accessjwtauth` adapts access JWTs to HTTP bearer authentication. - `oidc` verifies signed JWT bearer tokens from trusted issuers. - `onboarding` coordinates explicit identity attachment and principal provisioning. - `provisioning` can create principals for caller-approved unresolved identities. @@ -94,14 +104,9 @@ Identity linking is explicit. Applications create principals, trust providers, and link external identities to principals through setup code, migrations, CLIs, or admin handlers they own. -For API tokens, the external identity is keyed as: - -```text -provider = "api-token" -subject = token ID -``` - -For OIDC, the external identity is keyed as: +API tokens do not create identity links. They store `principal_id` directly and +are exchanged for authkit access JWTs. OIDC still uses identity links until it +is moved behind an exchange flow. For OIDC, the external identity is keyed as: ```text provider = issuer URL diff --git a/docs/docs/explanations/security-model.md b/docs/docs/explanations/security-model.md index f2c4383..10d6519 100644 --- a/docs/docs/explanations/security-model.md +++ b/docs/docs/explanations/security-model.md @@ -11,15 +11,15 @@ principals, and delegates policy decisions to an authorizer. ## API Tokens -API tokens are opaque, revocable, storage-backed credentials. +API tokens are opaque, revocable, storage-backed exchange credentials. authkit generates token IDs and secrets with high entropy. The plaintext token is returned only when issued. Storage adapters persist only the token secret -hash, expiration, revocation state, and metadata. +hash, principal ID, expiration, revocation state, and metadata. Token verification rejects malformed, unknown, expired, revoked, or mismatched -tokens. Successful verification returns an `authkit.Identity`; it does not bypass -principal resolution. +tokens. Successful verification returns principal-bearing API-token metadata for +an exchange service to issue a short-lived authkit access JWT. ## OIDC JWT Bearer Tokens diff --git a/docs/docs/how-to/compose-http-auth.md b/docs/docs/how-to/compose-http-auth.md index cef95ed..1f92602 100644 --- a/docs/docs/how-to/compose-http-auth.md +++ b/docs/docs/how-to/compose-http-auth.md @@ -12,46 +12,36 @@ local role or Casbin policy setup, and management workflows. ## Prerequisites -- A principal resolver, such as `store/memory.Store` or `store/postgres.Store` -- At least one authenticator source, such as an API-token service or OIDC - provider source +- A principal finder, such as `store/memory.Store` or `store/postgres.Store` +- An `accessjwt.Verifier` configured for authkit-issued access JWTs - An authorizer, such as `roleauth.NewAuthorizer` or `casbin.NewAuthorizer` -## Create The Store And Token Service +API tokens are exchange credentials. Keep API-token verification in an +application-owned exchange route and protect resource routes with access JWTs. + +## Create The Store ```go store := memory.NewStore() - -tokenService, err := apikey.NewService(store) -if err != nil { - return err -} ``` Use `store/postgres` instead of `store/memory` for production persistence after running the Postgres migrations. -## Configure Provider Trust - -Use a static provider source, a mutable store, or an application-owned source: +## Configure Access JWT Verification ```go -oidcSource, err := oidc.NewStaticProviderSource(oidc.Provider{ - Issuer: "https://issuer.example", - Audiences: []string{"notes-api"}, - JWKSURL: "https://issuer.example/.well-known/jwks.json", - ForwardedClaims: []authkit.ClaimPath{ - {"email"}, - {"groups"}, - }, +accessVerifier, err := accessjwt.NewVerifier(accessjwt.VerifierOptions{ + Issuer: "https://auth.example", + Audience: "notes-api", + KeySet: keySet, }) if err != nil { return err } ``` -For mutable provider trust and provisioning rules, see -[How to auto-provision OIDC principals](auto-provision-oidc-principals.md). +Your exchange routes should issue matching tokens with `accessjwt.Issuer`. ## Configure Authorization @@ -81,11 +71,9 @@ if err != nil { ```go kit, err := compose.NewHTTP(compose.HTTPOptions{ - Authenticators: []compose.AuthenticatorSpec{ - compose.APIToken(tokenService), - compose.OIDC(oidcSource), + PrincipalAuthenticators: []compose.PrincipalAuthenticatorSpec{ + compose.AccessJWT(accessVerifier, store), }, - Resolver: store, Authorizer: authorizer, }) if err != nil { diff --git a/docs/docs/how-to/use-explicit-composition.md b/docs/docs/how-to/use-explicit-composition.md index f4022cb..e361ff8 100644 --- a/docs/docs/how-to/use-explicit-composition.md +++ b/docs/docs/how-to/use-explicit-composition.md @@ -11,33 +11,23 @@ construction, ordering, middleware options, or non-standard runtime wiring. ## Build Authenticators ```go -tokenAuthenticator, err := apikey.NewAuthenticator(tokenService) -if err != nil { - return err -} - -oidcAuthenticator, err := oidc.NewAuthenticator( - providerSource, - oidc.WithForwardedClaims("email", "name"), -) +accessAuthenticator, err := accessjwtauth.NewAuthenticator(accessVerifier, principalFinder) if err != nil { return err } ``` -Authenticator order matters because API tokens and OIDC JWTs both use the -`Authorization: Bearer ...` header. The pipeline tries authenticators in the -order supplied. +API tokens are exchanged for access JWTs before they reach protected-resource +middleware. Runtime resource routes should authenticate authkit-issued access +JWTs. ## Build The Pipeline ```go pipeline, err := authkit.NewPipeline(authkit.PipelineOptions{ - Authenticators: []authkit.Authenticator{ - tokenAuthenticator, - oidcAuthenticator, + PrincipalAuthenticators: []authkit.PrincipalAuthenticator{ + accessAuthenticator, }, - Resolver: principalResolver, Authorizer: authorizer, }) if err != nil { @@ -45,9 +35,9 @@ if err != nil { } ``` -The resolver maps external identities to internal principals. The authorizer -receives an authorization check containing the resolved principal, action, -resource, and optional facts. +The access JWT authenticator verifies the bearer token and loads the principal. +The authorizer receives an authorization check containing that principal, +action, resource, and optional facts. ## Build HTTP Middleware diff --git a/docs/docs/reference/core-contracts.md b/docs/docs/reference/core-contracts.md index fbff3f7..dd5695b 100644 --- a/docs/docs/reference/core-contracts.md +++ b/docs/docs/reference/core-contracts.md @@ -15,7 +15,7 @@ succeeds. | Field | Type | Description | |-------|------|-------------| -| `Provider` | `string` | Authority or credential class that produced the identity. API tokens use `api-token`; OIDC uses the issuer URL. | +| `Provider` | `string` | Authority or credential class that produced the identity. OIDC uses the issuer URL. | | `Subject` | `string` | Provider-scoped subject. | | `CredentialID` | `string` | Concrete credential identifier when the authenticator exposes one. | | `Claims` | `map[string]any` | Optional verified metadata forwarded by the authenticator. | diff --git a/docs/docs/reference/deferred-scope.md b/docs/docs/reference/deferred-scope.md index 5188944..d2765a1 100644 --- a/docs/docs/reference/deferred-scope.md +++ b/docs/docs/reference/deferred-scope.md @@ -14,8 +14,9 @@ that integration before committing to a compatibility promise. authkit currently supports: - core identity, principal, resource, decision, authorization fact, and port contracts -- explicit `Identity -> Principal -> Authorizer` request pipeline +- JWT-first `Principal -> Authorizer` request pipeline plus temporary identity-based OIDC support - opaque API-token issuing, verification, revocation, expiration, and last-used tracking +- API-token-to-access-JWT exchange primitives - memory and Postgres storage for principals, local roles, provisioning rules, identity links, API tokens, and OIDC provider trust - Go-level management service for setup flows - OIDC-issued JWT bearer-token authentication diff --git a/docs/docs/reference/extension-points.md b/docs/docs/reference/extension-points.md index 91aa1f8..d4ff374 100644 --- a/docs/docs/reference/extension-points.md +++ b/docs/docs/reference/extension-points.md @@ -16,13 +16,22 @@ For root data types and request shapes, see ### `authkit.Authenticator` Verifies a request credential and returns an external identity. Implement this -for a custom credential source. +for an external credential source that has not yet moved behind an exchange +flow. Provided adapters: -- `apikey.NewAuthenticator` - `oidc.NewAuthenticator` +### `authkit.PrincipalAuthenticator` + +Verifies a request credential and returns an internal principal. Use this for +protected-resource routes that accept authkit-issued access JWTs. + +Provided adapters: + +- `accessjwtauth.NewAuthenticator` + ### `authkit.PrincipalResolver` Maps an authenticated external identity to an internal principal. Implement this @@ -89,7 +98,7 @@ scripts, or migrations. Links external identities to internal principals. The `management` package composes these ports with API-token issuing and -revocation for setup workflows. +revocation for setup and exchange workflows. ## Explicit Onboarding diff --git a/docs/docs/tutorials/notes-service.md b/docs/docs/tutorials/notes-service.md index 0a360dc..b5e270e 100644 --- a/docs/docs/tutorials/notes-service.md +++ b/docs/docs/tutorials/notes-service.md @@ -8,13 +8,13 @@ description: Learn the authkit request path by running the notes example. In this tutorial, we will run the notes example and make authenticated requests through the same path a real API service uses. -The example creates a service principal, issues an opaque API token, links the -token identity to the principal, installs a Casbin policy, and protects a -`GET /notes/{noteID}` route. +The example creates a service principal, issues an opaque API token for that +principal, installs a Casbin policy, exposes an exchange route, and protects a +`GET /notes/{noteID}` route with authkit access JWTs. -This tutorial follows the minimal API-token and Casbin path. Local roles, OIDC -auto-provisioning, and authorization facts are covered in task guides after the -tutorial. +This tutorial follows the minimal API-token exchange and Casbin path. Local +roles, OIDC auto-provisioning, and authorization facts are covered in task +guides after the tutorial. ## Run The Example @@ -40,12 +40,23 @@ In another terminal, put the printed token in `TOKEN`: TOKEN='ak_...' ``` +## Exchange The Seed Token + +Exchange the opaque API token for an authkit access JWT: + +```sh +ACCESS_TOKEN="$( + curl -s -X POST -H "Authorization: Bearer $TOKEN" \ + http://localhost:8080/auth/token | jq -r .access_token +)" +``` + ## Call An Allowed Route Request the note that the seeded policy allows: ```sh -curl -H "Authorization: Bearer $TOKEN" \ +curl -H "Authorization: Bearer $ACCESS_TOKEN" \ http://localhost:8080/notes/allowed ``` @@ -63,11 +74,11 @@ This note is readable by the seeded service principal. Request a note outside the seeded policy: ```sh -curl -i -H "Authorization: Bearer $TOKEN" \ +curl -i -H "Authorization: Bearer $ACCESS_TOKEN" \ http://localhost:8080/notes/denied ``` -The service returns `403 Forbidden`. The same token authenticated successfully, +The service returns `403 Forbidden`. The access JWT authenticated successfully, but the resolved principal was not authorized for that resource. ## Try A Missing Credential @@ -86,7 +97,7 @@ request. You have exercised the core authkit lifecycle: ```text -credential -> Identity -> Principal -> authorization decision -> handler +API token -> access JWT -> Principal -> authorization decision -> handler ``` For task-oriented setup guidance, see diff --git a/examples/notes/main.go b/examples/notes/main.go index 4bd2e98..56078b7 100644 --- a/examples/notes/main.go +++ b/examples/notes/main.go @@ -2,22 +2,29 @@ package main import ( "context" + "crypto/rand" + "crypto/rsa" + "encoding/json" "errors" "fmt" "io" "net/http" "os" + "strings" "time" casbinv3 "github.com/casbin/casbin/v3" casbinmodel "github.com/casbin/casbin/v3/model" + "github.com/lestrrat-go/jwx/v3/jwa" + "github.com/lestrrat-go/jwx/v3/jwk" "github.com/meigma/authkit" + "github.com/meigma/authkit/accessjwt" "github.com/meigma/authkit/apikey" authkitcasbin "github.com/meigma/authkit/casbin" "github.com/meigma/authkit/compose" + "github.com/meigma/authkit/exchange" "github.com/meigma/authkit/httpauth" - "github.com/meigma/authkit/oidc" "github.com/meigma/authkit/store/memory" ) @@ -28,6 +35,11 @@ const ( noteIDPathValue = "noteID" allowedNoteID = "allowed" seedTokenTTL = time.Hour + accessJWTTTL = 15 * time.Minute + accessJWTIssuer = "https://notes.example.local/authkit" + accessJWTAudience = "notes-api" + accessJWTKeyID = "notes-example-key" + rsaKeyBits = 2048 serverReadHeaderTimeout = 5 * time.Second serverReadTimeout = 10 * time.Second serverWriteTimeout = 10 * time.Second @@ -52,16 +64,15 @@ type notesApp struct { handler http.Handler store *memory.Store tokenService *apikey.Service + exchanger *exchange.APITokenExchanger principal authkit.Principal - seedIdentity authkit.ExternalIdentity seedToken string tokenExpiresAt time.Time notes map[string]string } type notesAppOptions struct { - clock func() time.Time - oidcHTTPClient *http.Client + clock func() time.Time } type notesAppOption func(*notesAppOptions) @@ -72,12 +83,6 @@ func withClock(clock func() time.Time) notesAppOption { } } -func withOIDCHTTPClient(client *http.Client) notesAppOption { - return func(opts *notesAppOptions) { - opts.oidcHTTPClient = client - } -} - func main() { if err := run(context.Background(), os.Stdout); err != nil { _, _ = fmt.Fprintln(os.Stderr, err) @@ -145,12 +150,20 @@ func newNotesApp(ctx context.Context, opts ...notesAppOption) (*notesApp, error) return nil, err } - seedIdentity, err := store.LinkIdentity(ctx, issued.IdentityLink) + accessIssuer, accessVerifier, err := newAccessJWTIssuerAndVerifier(cfg.clock) + if err != nil { + return nil, err + } + exchanger, err := exchange.NewAPITokenExchanger(exchange.APITokenOptions{ + APITokens: tokenService, + Principals: store, + AccessTokens: accessIssuer, + }) if err != nil { return nil, err } - middleware, err := newNotesMiddleware(store, tokenService, cfg, principal.ID) + middleware, err := newNotesMiddleware(store, accessVerifier, principal.ID) if err != nil { return nil, err } @@ -158,8 +171,8 @@ func newNotesApp(ctx context.Context, opts ...notesAppOption) (*notesApp, error) app := ¬esApp{ store: store, tokenService: tokenService, + exchanger: exchanger, principal: principal, - seedIdentity: seedIdentity, seedToken: issued.Plaintext, tokenExpiresAt: issued.ExpiresAt, notes: map[string]string{ @@ -169,6 +182,7 @@ func newNotesApp(ctx context.Context, opts ...notesAppOption) (*notesApp, error) } mux := http.NewServeMux() + mux.Handle("POST /auth/token", http.HandlerFunc(app.exchangeAPIToken)) mux.Handle( "GET /notes/{noteID}", middleware.RequireFunc(readNoteAction, func(req *http.Request) (authkit.Resource, error) { @@ -185,8 +199,7 @@ func newNotesApp(ctx context.Context, opts ...notesAppOption) (*notesApp, error) func newNotesMiddleware( store *memory.Store, - tokenService *apikey.Service, - cfg notesAppOptions, + verifier *accessjwt.Verifier, principalID string, ) (*httpauth.Middleware, error) { enforcer, err := newCasbinEnforcer() @@ -205,18 +218,10 @@ func newNotesMiddleware( if err != nil { return nil, err } - oidcOptions := []oidc.Option{ - oidc.WithClock(cfg.clock), - } - if cfg.oidcHTTPClient != nil { - oidcOptions = append(oidcOptions, oidc.WithHTTPClient(cfg.oidcHTTPClient)) - } auth, err := compose.NewHTTP(compose.HTTPOptions{ - Authenticators: []compose.AuthenticatorSpec{ - compose.APIToken(tokenService), - compose.OIDC(store, oidcOptions...), + PrincipalAuthenticators: []compose.PrincipalAuthenticatorSpec{ + compose.AccessJWT(verifier, store), }, - Resolver: store, Authorizer: authorizer, }) if err != nil { @@ -230,6 +235,34 @@ func (a *notesApp) ServeHTTP(w http.ResponseWriter, req *http.Request) { a.handler.ServeHTTP(w, req) } +func (a *notesApp) exchangeAPIToken(w http.ResponseWriter, req *http.Request) { + rawToken, err := bearerToken(req) + if err != nil { + http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) + + return + } + + result, err := a.exchanger.Exchange(req.Context(), exchange.APITokenRequest{ + Plaintext: rawToken, + }) + if err != nil { + writeAuthExchangeError(w, err) + + return + } + + w.Header().Set("Content-Type", "application/json") + //nolint:gosec // The exchange endpoint intentionally returns the newly issued access JWT once. + if err := json.NewEncoder(w).Encode(exchangeResponse{ + Value: result.AccessToken.Plaintext, + TokenType: "Bearer", + ExpiresAt: result.AccessToken.ExpiresAt, + }); err != nil { + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + } +} + func (a *notesApp) serveNote(w http.ResponseWriter, req *http.Request) { principal, ok := httpauth.PrincipalFromContext(req.Context()) if !ok || principal.ID == "" { @@ -249,6 +282,31 @@ func (a *notesApp) serveNote(w http.ResponseWriter, req *http.Request) { _, _ = fmt.Fprintln(w, body) } +type exchangeResponse struct { + Value string `json:"access_token"` + TokenType string `json:"token_type"` + ExpiresAt time.Time `json:"expires_at"` +} + +func bearerToken(req *http.Request) (string, error) { + header := req.Header.Get("Authorization") + parts := strings.Fields(header) + if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") { + return "", fmt.Errorf("%w: bearer token is required", authkit.ErrUnauthenticated) + } + + return parts[1], nil +} + +func writeAuthExchangeError(w http.ResponseWriter, err error) { + status := http.StatusInternalServerError + if errors.Is(err, authkit.ErrUnauthenticated) { + status = http.StatusUnauthorized + } + + http.Error(w, http.StatusText(status), status) +} + func newCasbinEnforcer() (*casbinv3.Enforcer, error) { model, err := casbinmodel.NewModelFromString(casbinModel) if err != nil { @@ -263,6 +321,55 @@ func newCasbinEnforcer() (*casbinv3.Enforcer, error) { return enforcer, nil } +func newAccessJWTIssuerAndVerifier( + clock func() time.Time, +) (*accessjwt.Issuer, *accessjwt.Verifier, error) { + rawKey, err := rsa.GenerateKey(rand.Reader, rsaKeyBits) + if err != nil { + return nil, nil, fmt.Errorf("notes: generate access JWT key: %w", err) + } + signingKey, err := jwk.Import(rawKey) + if err != nil { + return nil, nil, fmt.Errorf("notes: import access JWT key: %w", err) + } + if setErr := signingKey.Set(jwk.KeyIDKey, accessJWTKeyID); setErr != nil { + return nil, nil, fmt.Errorf("notes: set access JWT key ID: %w", setErr) + } + if setErr := signingKey.Set(jwk.AlgorithmKey, jwa.RS256()); setErr != nil { + return nil, nil, fmt.Errorf("notes: set access JWT key algorithm: %w", setErr) + } + publicKey, err := jwk.PublicKeyOf(signingKey) + if err != nil { + return nil, nil, fmt.Errorf("notes: derive access JWT public key: %w", err) + } + keySet := jwk.NewSet() + if addErr := keySet.AddKey(publicKey); addErr != nil { + return nil, nil, fmt.Errorf("notes: build access JWT key set: %w", addErr) + } + + issuer, err := accessjwt.NewIssuer(accessjwt.IssuerOptions{ + Issuer: accessJWTIssuer, + Audience: accessJWTAudience, + TTL: accessJWTTTL, + SigningKey: signingKey, + Clock: clock, + }) + if err != nil { + return nil, nil, fmt.Errorf("notes: create access JWT issuer: %w", err) + } + verifier, err := accessjwt.NewVerifier(accessjwt.VerifierOptions{ + Issuer: accessJWTIssuer, + Audience: accessJWTAudience, + KeySet: keySet, + Clock: clock, + }) + if err != nil { + return nil, nil, fmt.Errorf("notes: create access JWT verifier: %w", err) + } + + return issuer, verifier, nil +} + func noteObject(noteID string) string { return noteType + ":" + noteID } diff --git a/examples/notes/main_test.go b/examples/notes/main_test.go index 958f3cd..77c46f3 100644 --- a/examples/notes/main_test.go +++ b/examples/notes/main_test.go @@ -16,19 +16,17 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/meigma/authkit" "github.com/meigma/authkit/apikey" - "github.com/meigma/authkit/oidc" ) const ( - linkedOIDCSubject = "oidc-user-123" - unlinkedOIDCSubject = "oidc-user-456" - testAudience = "notes-api" + linkedOIDCSubject = "oidc-user-123" + testAudience = "notes-api" ) func TestNotesAppAuthorizesRequests(t *testing.T) { tc := newMixedTestContext(t) + accessToken := exchangeAccessToken(t, tc.app, tc.app.seedToken) tests := []struct { name string @@ -38,30 +36,16 @@ func TestNotesAppAuthorizesRequests(t *testing.T) { wantBody string }{ { - name: "allows seeded API token to read allowed note", + name: "allows access JWT to read allowed note", path: "/notes/allowed", - authorization: bearer(tc.app.seedToken), - wantStatus: http.StatusOK, - wantBody: "This note is readable by the seeded service principal.\n", - }, - { - name: "allows linked OIDC token to read allowed note", - path: "/notes/allowed", - authorization: bearer(tc.linkedOIDCToken), + authorization: bearer(accessToken), wantStatus: http.StatusOK, wantBody: "This note is readable by the seeded service principal.\n", }, { - name: "denies seeded API token for note outside policy", + name: "denies access JWT for note outside policy", path: "/notes/denied", - authorization: bearer(tc.app.seedToken), - wantStatus: http.StatusForbidden, - wantBody: "Forbidden\n", - }, - { - name: "denies linked OIDC token for note outside policy", - path: "/notes/denied", - authorization: bearer(tc.linkedOIDCToken), + authorization: bearer(accessToken), wantStatus: http.StatusForbidden, wantBody: "Forbidden\n", }, @@ -79,30 +63,23 @@ func TestNotesAppAuthorizesRequests(t *testing.T) { wantBody: "Unauthorized\n", }, { - name: "rejects valid API token without identity link", - path: "/notes/allowed", - authorization: bearer(tc.unlinkedAPIToken), - wantStatus: http.StatusUnauthorized, - wantBody: "Unauthorized\n", - }, - { - name: "rejects OIDC token with wrong audience", + name: "rejects direct seed API token", path: "/notes/allowed", - authorization: bearer(tc.wrongAudienceOIDCToken), + authorization: bearer(tc.app.seedToken), wantStatus: http.StatusUnauthorized, wantBody: "Unauthorized\n", }, { - name: "rejects OIDC token from untrusted issuer", + name: "rejects direct unlinked API token", path: "/notes/allowed", - authorization: bearer(tc.untrustedIssuerOIDCToken), + authorization: bearer(tc.unlinkedAPIToken), wantStatus: http.StatusUnauthorized, wantBody: "Unauthorized\n", }, { - name: "rejects valid OIDC token without identity link", + name: "rejects direct OIDC bearer token", path: "/notes/allowed", - authorization: bearer(tc.unlinkedOIDCToken), + authorization: bearer(tc.linkedOIDCToken), wantStatus: http.StatusUnauthorized, wantBody: "Unauthorized\n", }, @@ -124,30 +101,35 @@ func TestNotesAppAuthorizesRequests(t *testing.T) { } } -func TestNotesAppLinksAPIAndOIDCIdentitiesToSamePrincipal(t *testing.T) { +func TestNotesAppExchangesAPITokenForAccessJWT(t *testing.T) { tc := newMixedTestContext(t) - apiPrincipal := resolveIdentity(t, tc.app, authkit.Identity{ - Provider: tc.app.seedIdentity.Provider, - Subject: tc.app.seedIdentity.Subject, - }) - oidcPrincipal := resolveIdentity(t, tc.app, authkit.Identity{ - Provider: tc.issuer.issuer, - Subject: linkedOIDCSubject, - }) + accessToken := exchangeAccessToken(t, tc.app, tc.app.seedToken) + req := httptest.NewRequest(http.MethodGet, "/notes/allowed", nil) + req.Header.Set("Authorization", bearer(accessToken)) - assert.Equal(t, tc.app.principal.ID, apiPrincipal.ID) - assert.Equal(t, tc.app.principal.ID, oidcPrincipal.ID) - assert.Equal(t, apiPrincipal, oidcPrincipal) + recorder := httptest.NewRecorder() + tc.app.ServeHTTP(recorder, req) + + assert.Equal(t, http.StatusOK, recorder.Code) + assert.Equal(t, "This note is readable by the seeded service principal.\n", recorder.Body.String()) +} + +func TestNotesAppRejectsInvalidExchangeCredential(t *testing.T) { + tc := newMixedTestContext(t) + req := httptest.NewRequest(http.MethodPost, "/auth/token", nil) + req.Header.Set("Authorization", bearer("invalid")) + + recorder := httptest.NewRecorder() + tc.app.ServeHTTP(recorder, req) + + assert.Equal(t, http.StatusUnauthorized, recorder.Code) + assert.Equal(t, "Unauthorized\n", recorder.Body.String()) } type mixedTestContext struct { - app *notesApp - issuer *testIssuer - linkedOIDCToken string - wrongAudienceOIDCToken string - untrustedIssuerOIDCToken string - unlinkedOIDCToken string - unlinkedAPIToken string + app *notesApp + linkedOIDCToken string + unlinkedAPIToken string } func newMixedTestContext(t *testing.T) mixedTestContext { @@ -156,44 +138,20 @@ func newMixedTestContext(t *testing.T) mixedTestContext { ctx := context.Background() now := fixedTestTime() issuer := newTestIssuer(t) - untrustedIssuer := newTestIssuer(t) app, err := newNotesApp( ctx, withClock(func() time.Time { return now }), - withOIDCHTTPClient(issuer.server.Client()), ) require.NoError(t, err) - _, err = app.store.TrustProvider(ctx, issuer.provider(testAudience)) - require.NoError(t, err) - _, err = app.store.LinkIdentity(ctx, authkit.LinkIdentityRequest{ - Provider: issuer.issuer, - Subject: linkedOIDCSubject, - PrincipalID: app.principal.ID, - }) - require.NoError(t, err) - return mixedTestContext{ - app: app, - issuer: issuer, + app: app, linkedOIDCToken: issuer.sign( t, tokenRequest{subject: linkedOIDCSubject, audiences: []string{testAudience}}, ), - wrongAudienceOIDCToken: issuer.sign( - t, - tokenRequest{subject: linkedOIDCSubject, audiences: []string{"other-api"}}, - ), - untrustedIssuerOIDCToken: untrustedIssuer.sign( - t, - tokenRequest{subject: linkedOIDCSubject, audiences: []string{testAudience}}, - ), - unlinkedOIDCToken: issuer.sign( - t, - tokenRequest{subject: unlinkedOIDCSubject, audiences: []string{testAudience}}, - ), unlinkedAPIToken: issueUnlinkedToken(t, app), } } @@ -211,14 +169,23 @@ func issueUnlinkedToken(t *testing.T, app *notesApp) string { return issued.Plaintext } -func resolveIdentity(t *testing.T, app *notesApp, identity authkit.Identity) authkit.Principal { +func exchangeAccessToken(t *testing.T, app *notesApp, apiToken string) string { t.Helper() - principal, err := app.store.ResolveIdentity(context.Background(), identity) - require.NoError(t, err) - require.NotNil(t, principal) + req := httptest.NewRequest(http.MethodPost, "/auth/token", nil) + req.Header.Set("Authorization", bearer(apiToken)) + + recorder := httptest.NewRecorder() + app.ServeHTTP(recorder, req) + + require.Equal(t, http.StatusOK, recorder.Code) + var response exchangeResponse + require.NoError(t, json.NewDecoder(recorder.Body).Decode(&response)) + require.NotEmpty(t, response.Value) + assert.Equal(t, "Bearer", response.TokenType) + assert.Equal(t, fixedTestTime().Add(accessJWTTTL), response.ExpiresAt) - return *principal + return response.Value } type testIssuer struct { @@ -268,14 +235,6 @@ func newTestIssuer(t *testing.T) *testIssuer { return issuer } -func (i *testIssuer) provider(audience string) oidc.Provider { - return oidc.Provider{ - Issuer: i.issuer, - Audiences: []string{audience}, - JWKSURL: i.jwksURL, - } -} - func (i *testIssuer) sign(t *testing.T, req tokenRequest) string { t.Helper() diff --git a/exchange/apitoken.go b/exchange/apitoken.go new file mode 100644 index 0000000..15127ee --- /dev/null +++ b/exchange/apitoken.go @@ -0,0 +1,88 @@ +package exchange + +import ( + "context" + "errors" + "fmt" + + "github.com/meigma/authkit" + "github.com/meigma/authkit/accessjwt" +) + +// APITokenExchanger exchanges opaque API tokens for authkit access JWTs. +type APITokenExchanger struct { + apiTokens APITokenVerifier + principals authkit.PrincipalFinder + accessTokens AccessTokenIssuer +} + +// NewAPITokenExchanger constructs an APITokenExchanger from opts. +func NewAPITokenExchanger(opts APITokenOptions) (*APITokenExchanger, error) { + if opts.APITokens == nil { + return nil, errors.New("exchange: API token verifier is required") + } + if opts.Principals == nil { + return nil, errors.New("exchange: principal finder is required") + } + if opts.AccessTokens == nil { + return nil, errors.New("exchange: access token issuer is required") + } + + return &APITokenExchanger{ + apiTokens: opts.APITokens, + principals: opts.Principals, + accessTokens: opts.AccessTokens, + }, nil +} + +// Exchange verifies req.Plaintext and issues an access JWT for the token principal. +func (e *APITokenExchanger) Exchange(ctx context.Context, req APITokenRequest) (APITokenResult, error) { + if err := ctx.Err(); err != nil { + return APITokenResult{}, err + } + + apiToken, err := e.apiTokens.VerifyAPIToken(ctx, req.Plaintext) + if err != nil { + return APITokenResult{}, exchangeError("verify API token", err) + } + + principal, err := e.principals.FindPrincipal(ctx, apiToken.PrincipalID) + if errors.Is(err, authkit.ErrPrincipalNotFound) { + return APITokenResult{}, unauthenticated("principal not found") + } + if err != nil { + return APITokenResult{}, exchangeError("find principal", err) + } + + accessToken, err := e.accessTokens.IssueToken(ctx, accessjwt.IssueRequest{ + PrincipalID: principal.ID, + }) + if err != nil { + return APITokenResult{}, exchangeError("issue access token", err) + } + + return APITokenResult{ + APIToken: apiToken, + Principal: principal, + AccessToken: accessToken, + }, nil +} + +func exchangeError(operation string, err error) error { + if isContextError(err) { + return err + } + if errors.Is(err, authkit.ErrUnauthenticated) { + return err + } + + return fmt.Errorf("%w: %s: %w", authkit.ErrInternal, operation, err) +} + +func unauthenticated(reason string) error { + return fmt.Errorf("%w: %s", authkit.ErrUnauthenticated, reason) +} + +func isContextError(err error) bool { + return errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) +} diff --git a/exchange/apitoken_test.go b/exchange/apitoken_test.go new file mode 100644 index 0000000..c6ab522 --- /dev/null +++ b/exchange/apitoken_test.go @@ -0,0 +1,321 @@ +package exchange_test + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/meigma/authkit" + "github.com/meigma/authkit/accessjwt" + "github.com/meigma/authkit/apikey" + "github.com/meigma/authkit/exchange" + "github.com/meigma/authkit/internal/authtest" + "github.com/meigma/authkit/store/memory" +) + +const ( + testPrincipalID = "principal_1" + testTokenID = "access-token-123" +) + +func TestNewAPITokenExchangerValidatesDependencies(t *testing.T) { + apiTokens := fakeAPITokenVerifier{} + principals := fakePrincipalFinder{} + accessTokens := fakeAccessTokenIssuer{} + + tests := []struct { + name string + opts exchange.APITokenOptions + }{ + { + name: "missing API token verifier", + opts: exchange.APITokenOptions{ + Principals: principals, + AccessTokens: accessTokens, + }, + }, + { + name: "missing principal finder", + opts: exchange.APITokenOptions{ + APITokens: apiTokens, + AccessTokens: accessTokens, + }, + }, + { + name: "missing access token issuer", + opts: exchange.APITokenOptions{ + APITokens: apiTokens, + Principals: principals, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + exchanger, err := exchange.NewAPITokenExchanger(tt.opts) + + require.Error(t, err) + assert.Nil(t, exchanger) + }) + } +} + +func TestAPITokenExchangerExchangesTokenForAccessJWT(t *testing.T) { + ctx := context.Background() + store := memory.NewStore() + principal, err := store.CreatePrincipal(ctx, authkit.CreatePrincipalRequest{ + Kind: authkit.PrincipalKindService, + DisplayName: "notes service", + }) + require.NoError(t, err) + apiTokens, err := apikey.NewService(store, apikey.WithClock(fixedTime)) + require.NoError(t, err) + apiToken, err := apiTokens.IssueToken(ctx, apikey.IssueRequest{ + PrincipalID: principal.ID, + Name: "bootstrap token", + ExpiresAt: fixedTime().Add(time.Hour), + }) + require.NoError(t, err) + accessTokens, verifier := newAccessJWTIssuerAndVerifier(t) + exchanger := newAPITokenExchanger(t, exchange.APITokenOptions{ + APITokens: apiTokens, + Principals: store, + AccessTokens: accessTokens, + }) + + result, err := exchanger.Exchange(ctx, exchange.APITokenRequest{ + Plaintext: apiToken.Plaintext, + }) + + require.NoError(t, err) + assert.Equal(t, apikey.VerifiedToken{ + ID: apiToken.ID, + PrincipalID: principal.ID, + ExpiresAt: apiToken.ExpiresAt, + }, result.APIToken) + assert.Equal(t, principal, result.Principal) + assert.Equal(t, principal.ID, result.AccessToken.PrincipalID) + verified, err := verifier.VerifyToken(ctx, result.AccessToken.Plaintext) + require.NoError(t, err) + assert.Equal(t, principal.ID, verified.PrincipalID) +} + +func TestAPITokenExchangerDoesNotRequireIdentityLink(t *testing.T) { + ctx := context.Background() + store := memory.NewStore() + principal, err := store.CreatePrincipal(ctx, authkit.CreatePrincipalRequest{ + Kind: authkit.PrincipalKindService, + DisplayName: "notes service", + }) + require.NoError(t, err) + apiTokens, err := apikey.NewService(store, apikey.WithClock(fixedTime)) + require.NoError(t, err) + apiToken, err := apiTokens.IssueToken(ctx, apikey.IssueRequest{ + PrincipalID: principal.ID, + Name: "bootstrap token", + ExpiresAt: fixedTime().Add(time.Hour), + }) + require.NoError(t, err) + accessTokens, _ := newAccessJWTIssuerAndVerifier(t) + exchanger := newAPITokenExchanger(t, exchange.APITokenOptions{ + APITokens: apiTokens, + Principals: store, + AccessTokens: accessTokens, + }) + + result, err := exchanger.Exchange(ctx, exchange.APITokenRequest{ + Plaintext: apiToken.Plaintext, + }) + + require.NoError(t, err) + assert.Equal(t, principal.ID, result.Principal.ID) +} + +func TestAPITokenExchangerRejectsMissingPrincipal(t *testing.T) { + ctx := context.Background() + store := memory.NewStore() + accessTokens, _ := newAccessJWTIssuerAndVerifier(t) + exchanger := newAPITokenExchanger(t, exchange.APITokenOptions{ + APITokens: fakeAPITokenVerifier{ + token: apikey.VerifiedToken{ + ID: "api-token-1", + PrincipalID: "missing", + ExpiresAt: fixedTime().Add(time.Hour), + }, + }, + Principals: store, + AccessTokens: accessTokens, + }) + + result, err := exchanger.Exchange(ctx, exchange.APITokenRequest{ + Plaintext: "ak_token_secret", + }) + + require.ErrorIs(t, err, authkit.ErrUnauthenticated) + assert.Empty(t, result) +} + +func TestAPITokenExchangerRejectsInvalidAPIToken(t *testing.T) { + store := memory.NewStore() + apiTokens, err := apikey.NewService(store, apikey.WithClock(fixedTime)) + require.NoError(t, err) + accessTokens, _ := newAccessJWTIssuerAndVerifier(t) + exchanger := newAPITokenExchanger(t, exchange.APITokenOptions{ + APITokens: apiTokens, + Principals: store, + AccessTokens: accessTokens, + }) + + result, err := exchanger.Exchange(context.Background(), exchange.APITokenRequest{ + Plaintext: "invalid", + }) + + require.ErrorIs(t, err, authkit.ErrUnauthenticated) + assert.Empty(t, result) +} + +func TestAPITokenExchangerWrapsInternalFailures(t *testing.T) { + issuerErr := errors.New("issuer failed") + storeErr := errors.New("store failed") + apiToken := apikey.VerifiedToken{ + ID: "api-token-1", + PrincipalID: testPrincipalID, + ExpiresAt: fixedTime().Add(time.Hour), + } + principal := authkit.Principal{ + ID: testPrincipalID, + Kind: authkit.PrincipalKindService, + DisplayName: "notes service", + } + + tests := []struct { + name string + opts exchange.APITokenOptions + want error + }{ + { + name: "principal finder failure", + opts: exchange.APITokenOptions{ + APITokens: fakeAPITokenVerifier{ + token: apiToken, + }, + Principals: fakePrincipalFinder{ + err: storeErr, + }, + AccessTokens: fakeAccessTokenIssuer{}, + }, + want: storeErr, + }, + { + name: "access token issuer failure", + opts: exchange.APITokenOptions{ + APITokens: fakeAPITokenVerifier{ + token: apiToken, + }, + Principals: fakePrincipalFinder{ + principal: principal, + }, + AccessTokens: fakeAccessTokenIssuer{ + err: issuerErr, + }, + }, + want: issuerErr, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + exchanger := newAPITokenExchanger(t, tt.opts) + + result, err := exchanger.Exchange(context.Background(), exchange.APITokenRequest{ + Plaintext: "ak_token_secret", + }) + + require.ErrorIs(t, err, authkit.ErrInternal) + require.ErrorIs(t, err, tt.want) + assert.Empty(t, result) + }) + } +} + +func TestAPITokenExchangerPassesThroughContextErrors(t *testing.T) { + exchanger := newAPITokenExchanger(t, exchange.APITokenOptions{ + APITokens: fakeAPITokenVerifier{}, + Principals: fakePrincipalFinder{}, + AccessTokens: fakeAccessTokenIssuer{}, + }) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + result, err := exchanger.Exchange(ctx, exchange.APITokenRequest{ + Plaintext: "ak_token_secret", + }) + + require.ErrorIs(t, err, context.Canceled) + require.NotErrorIs(t, err, authkit.ErrInternal) + assert.Empty(t, result) +} + +func newAPITokenExchanger(t *testing.T, opts exchange.APITokenOptions) *exchange.APITokenExchanger { + t.Helper() + + exchanger, err := exchange.NewAPITokenExchanger(opts) + require.NoError(t, err) + + return exchanger +} + +func newAccessJWTIssuerAndVerifier(t *testing.T) (*accessjwt.Issuer, *accessjwt.Verifier) { + t.Helper() + + return authtest.NewAccessJWTIssuerAndVerifier( + t, + authtest.WithAccessJWTTokenID(func() (string, error) { + return testTokenID, nil + }), + ) +} + +type fakeAPITokenVerifier struct { + token apikey.VerifiedToken + err error +} + +func (f fakeAPITokenVerifier) VerifyAPIToken( + context.Context, + string, +) (apikey.VerifiedToken, error) { + return f.token, f.err +} + +type fakePrincipalFinder struct { + principal authkit.Principal + err error +} + +func (f fakePrincipalFinder) FindPrincipal( + context.Context, + string, +) (authkit.Principal, error) { + return f.principal, f.err +} + +type fakeAccessTokenIssuer struct { + token accessjwt.IssuedToken + err error +} + +func (f fakeAccessTokenIssuer) IssueToken( + context.Context, + accessjwt.IssueRequest, +) (accessjwt.IssuedToken, error) { + return f.token, f.err +} + +func fixedTime() time.Time { + return authtest.FixedTime() +} diff --git a/exchange/doc.go b/exchange/doc.go new file mode 100644 index 0000000..5e4390a --- /dev/null +++ b/exchange/doc.go @@ -0,0 +1,2 @@ +// Package exchange converts verified credentials into authkit access JWTs. +package exchange diff --git a/exchange/types.go b/exchange/types.go new file mode 100644 index 0000000..963a894 --- /dev/null +++ b/exchange/types.go @@ -0,0 +1,51 @@ +package exchange + +import ( + "context" + + "github.com/meigma/authkit" + "github.com/meigma/authkit/accessjwt" + "github.com/meigma/authkit/apikey" +) + +// APITokenVerifier verifies opaque API tokens. +type APITokenVerifier interface { + // VerifyAPIToken verifies plaintext and returns its authenticated token metadata. + VerifyAPIToken(ctx context.Context, plaintext string) (apikey.VerifiedToken, error) +} + +// AccessTokenIssuer issues authkit access JWTs. +type AccessTokenIssuer interface { + // IssueToken issues an access JWT for req.PrincipalID. + IssueToken(ctx context.Context, req accessjwt.IssueRequest) (accessjwt.IssuedToken, error) +} + +// APITokenOptions configures an APITokenExchanger. +type APITokenOptions struct { + // APITokens verifies opaque API tokens. + APITokens APITokenVerifier + + // Principals loads principals authenticated by API tokens. + Principals authkit.PrincipalFinder + + // AccessTokens issues authkit access JWTs. + AccessTokens AccessTokenIssuer +} + +// APITokenRequest describes an API-token exchange request. +type APITokenRequest struct { + // Plaintext is the opaque API token presented for exchange. + Plaintext string +} + +// APITokenResult describes a completed API-token exchange. +type APITokenResult struct { + // APIToken is the verified opaque API token metadata. + APIToken apikey.VerifiedToken + + // Principal is the principal authenticated by APIToken. + Principal authkit.Principal + + // AccessToken is the authkit access JWT issued for Principal. + AccessToken accessjwt.IssuedToken +} diff --git a/httpauth/context.go b/httpauth/context.go index cb6eb7e..d8f3a0f 100644 --- a/httpauth/context.go +++ b/httpauth/context.go @@ -25,6 +25,12 @@ func IdentityFromContext(ctx context.Context) (authkit.Identity, bool) { if !ok { return authkit.Identity{}, false } + if authentication.Identity.Provider == "" && + authentication.Identity.Subject == "" && + authentication.Identity.CredentialID == "" && + len(authentication.Identity.Claims) == 0 { + return authkit.Identity{}, false + } return authentication.Identity, true } diff --git a/httpauth/middleware_test.go b/httpauth/middleware_test.go index 7d49c6b..874300e 100644 --- a/httpauth/middleware_test.go +++ b/httpauth/middleware_test.go @@ -7,15 +7,12 @@ import ( "net/http" "net/http/httptest" "testing" - "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/meigma/authkit" - "github.com/meigma/authkit/apikey" "github.com/meigma/authkit/httpauth" - "github.com/meigma/authkit/store/memory" ) func TestNewMiddlewareValidatesPipeline(t *testing.T) { @@ -57,6 +54,39 @@ func TestMiddlewareAuthenticatePopulatesContext(t *testing.T) { assert.Equal(t, http.StatusNoContent, recorder.Code) } +func TestMiddlewareAuthenticatePopulatesPrincipalContextWithoutIdentity(t *testing.T) { + pipeline := newTestPipelineWithOptions(t, authkit.PipelineOptions{ + PrincipalAuthenticators: []authkit.PrincipalAuthenticator{allowPrincipalAuthenticator()}, + Authorizer: allowAuthorizer(), + }) + middleware := newMiddleware(t, pipeline) + handlerCalled := false + handler := middleware.Authenticate(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + handlerCalled = true + + authentication, ok := httpauth.AuthenticationFromContext(req.Context()) + if assert.True(t, ok) { + assert.Equal(t, "principal-test", authentication.AuthenticatorName) + assert.Empty(t, authentication.Identity) + } + + _, ok = httpauth.IdentityFromContext(req.Context()) + assert.False(t, ok) + + principal, ok := httpauth.PrincipalFromContext(req.Context()) + if assert.True(t, ok) { + assert.Equal(t, testPrincipal(), principal) + } + w.WriteHeader(http.StatusNoContent) + })) + + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/", nil)) + + assert.True(t, handlerCalled) + assert.Equal(t, http.StatusNoContent, recorder.Code) +} + func TestMiddlewareAuthenticateRendersUnauthenticated(t *testing.T) { pipeline := newTestPipelineWithAuthenticator(t, denyAuthenticator("test"), allowAuthorizer()) middleware := newMiddleware(t, pipeline) @@ -299,54 +329,6 @@ func TestMiddlewareUsesCustomRenderer(t *testing.T) { assert.Equal(t, "custom\n", recorder.Body.String()) } -func TestMiddlewareAuthenticatesAPITokenThroughHTTPPath(t *testing.T) { - now := time.Date(2026, time.May, 7, 19, 45, 0, 0, time.UTC) - store := memory.NewStore() - tokenService, err := apikey.NewService(store, apikey.WithClock(func() time.Time { - return now - })) - require.NoError(t, err) - tokenAuthenticator, err := apikey.NewAuthenticator(tokenService) - require.NoError(t, err) - principal, err := store.CreatePrincipal(context.Background(), authkit.CreatePrincipalRequest{ - Kind: authkit.PrincipalKindService, - DisplayName: "deploy service", - }) - require.NoError(t, err) - issued, err := tokenService.IssueToken(context.Background(), apikey.IssueRequest{ - PrincipalID: principal.ID, - Name: "deploy token", - ExpiresAt: now.Add(time.Hour), - }) - require.NoError(t, err) - _, err = store.LinkIdentity(context.Background(), issued.IdentityLink) - require.NoError(t, err) - pipeline, err := authkit.NewPipeline(authkit.PipelineOptions{ - Authenticators: []authkit.Authenticator{tokenAuthenticator}, - Resolver: store, - Authorizer: allowAuthorizer(), - }) - require.NoError(t, err) - middleware := newMiddleware(t, pipeline) - handler := middleware.Require("deploy", authkit.Resource{Type: "service", ID: "deploy"})( - http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { - got, ok := httpauth.PrincipalFromContext(req.Context()) - if assert.True(t, ok) { - assert.Equal(t, principal, got) - } - - w.WriteHeader(http.StatusNoContent) - }), - ) - req := httptest.NewRequest(http.MethodPost, "/deploy", nil) - req.Header.Set("Authorization", "Bearer "+issued.Plaintext) - - recorder := httptest.NewRecorder() - handler.ServeHTTP(recorder, req) - - assert.Equal(t, http.StatusNoContent, recorder.Code) -} - func newMiddleware(t *testing.T, pipeline *authkit.Pipeline) *httpauth.Middleware { t.Helper() @@ -469,6 +451,22 @@ func (a testAuthenticator) Authenticate(ctx context.Context, req *http.Request) return a.authenticate(ctx, req) } +type testPrincipalAuthenticator struct { + name string + authenticate func(context.Context, *http.Request) (*authkit.PrincipalAuthentication, error) +} + +func (a testPrincipalAuthenticator) Name() string { + return a.name +} + +func (a testPrincipalAuthenticator) AuthenticatePrincipal( + ctx context.Context, + req *http.Request, +) (*authkit.PrincipalAuthentication, error) { + return a.authenticate(ctx, req) +} + type testResolver struct { resolve func(context.Context, authkit.Identity) (*authkit.Principal, error) } @@ -496,6 +494,17 @@ func allowAuthenticator() testAuthenticator { } } +func allowPrincipalAuthenticator() testPrincipalAuthenticator { + return testPrincipalAuthenticator{ + name: "principal-test", + authenticate: func(context.Context, *http.Request) (*authkit.PrincipalAuthentication, error) { + return &authkit.PrincipalAuthentication{ + Principal: testPrincipal(), + }, nil + }, + } +} + func denyAuthenticator(name string) testAuthenticator { return failingAuthenticator(name, fmt.Errorf("%w: credential missing", authkit.ErrUnauthenticated)) } diff --git a/internal/authtest/accessjwt.go b/internal/authtest/accessjwt.go new file mode 100644 index 0000000..bb5e64f --- /dev/null +++ b/internal/authtest/accessjwt.go @@ -0,0 +1,100 @@ +package authtest + +import ( + "crypto/rand" + "crypto/rsa" + "testing" + "time" + + "github.com/lestrrat-go/jwx/v3/jwa" + "github.com/lestrrat-go/jwx/v3/jwk" + "github.com/stretchr/testify/require" + + "github.com/meigma/authkit/accessjwt" +) + +const ( + testRSAKeyBits = 2048 + + // AccessJWTIssuer is the default test issuer for authkit access JWTs. + AccessJWTIssuer = "https://auth.example.test" + + // AccessJWTAudience is the default test audience for authkit access JWTs. + AccessJWTAudience = "notes-api" + + // AccessJWTKeyID is the default test signing key ID for authkit access JWTs. + AccessJWTKeyID = "test-key" +) + +// FixedTime returns the shared stable clock value used by authkit test helpers. +func FixedTime() time.Time { + return time.Date(2026, time.May, 13, 22, 0, 0, 0, time.UTC) +} + +// AccessJWTOption configures NewAccessJWTIssuerAndVerifier. +type AccessJWTOption func(*accessJWTConfig) + +type accessJWTConfig struct { + issuer string + audience string + keyID string + clock func() time.Time + tokenID func() (string, error) +} + +// WithAccessJWTTokenID sets a deterministic access JWT ID generator. +func WithAccessJWTTokenID(tokenID func() (string, error)) AccessJWTOption { + return func(cfg *accessJWTConfig) { + cfg.tokenID = tokenID + } +} + +// NewAccessJWTIssuerAndVerifier constructs a matching test access JWT issuer and verifier. +func NewAccessJWTIssuerAndVerifier( + t testing.TB, + opts ...AccessJWTOption, +) (*accessjwt.Issuer, *accessjwt.Verifier) { + t.Helper() + + cfg := accessJWTConfig{ + issuer: AccessJWTIssuer, + audience: AccessJWTAudience, + keyID: AccessJWTKeyID, + clock: FixedTime, + } + for _, opt := range opts { + if opt != nil { + opt(&cfg) + } + } + + rawKey, err := rsa.GenerateKey(rand.Reader, testRSAKeyBits) + require.NoError(t, err) + signingKey, err := jwk.Import(rawKey) + require.NoError(t, err) + require.NoError(t, signingKey.Set(jwk.KeyIDKey, cfg.keyID)) + require.NoError(t, signingKey.Set(jwk.AlgorithmKey, jwa.RS256())) + publicKey, err := jwk.PublicKeyOf(signingKey) + require.NoError(t, err) + keySet := jwk.NewSet() + require.NoError(t, keySet.AddKey(publicKey)) + + issuer, err := accessjwt.NewIssuer(accessjwt.IssuerOptions{ + Issuer: cfg.issuer, + Audience: cfg.audience, + TTL: time.Hour, + SigningKey: signingKey, + Clock: cfg.clock, + TokenID: cfg.tokenID, + }) + require.NoError(t, err) + verifier, err := accessjwt.NewVerifier(accessjwt.VerifierOptions{ + Issuer: cfg.issuer, + Audience: cfg.audience, + KeySet: keySet, + Clock: cfg.clock, + }) + require.NoError(t, err) + + return issuer, verifier +} diff --git a/internal/storetest/storetest.go b/internal/storetest/storetest.go index 8536fff..6fdbee1 100644 --- a/internal/storetest/storetest.go +++ b/internal/storetest/storetest.go @@ -1340,10 +1340,12 @@ func Run(t *testing.T, newStore func(t *testing.T) Store) { t.Run("token missing behavior", func(t *testing.T) { store := newStore(t) now := fixedStoreTime() + token := tokenFixture(now, "missing") found, err := store.FindToken(context.Background(), "missing") require.ErrorIs(t, err, apikey.ErrTokenNotFound) assert.Empty(t, found) + require.ErrorIs(t, store.CreateToken(context.Background(), token), authkit.ErrPrincipalNotFound) require.ErrorIs( t, @@ -1375,17 +1377,14 @@ func Run(t *testing.T, newStore func(t *testing.T) Store) { ExpiresAt: now.Add(time.Hour), }) require.NoError(t, err) - _, err = store.LinkIdentity(context.Background(), issued.IdentityLink) - require.NoError(t, err) - - identity, err := service.VerifyToken(context.Background(), issued.Plaintext) - require.NoError(t, err) - require.NotNil(t, identity) - resolved, err := store.ResolveIdentity(context.Background(), *identity) + verified, err := service.VerifyAPIToken(context.Background(), issued.Plaintext) require.NoError(t, err) - require.NotNil(t, resolved) - assert.Equal(t, principal, *resolved) + assert.Equal(t, apikey.VerifiedToken{ + ID: issued.ID, + PrincipalID: principal.ID, + ExpiresAt: issued.ExpiresAt, + }, verified) }) } diff --git a/management/service.go b/management/service.go index a730bd3..5d583a9 100644 --- a/management/service.go +++ b/management/service.go @@ -321,17 +321,22 @@ func (s *Service) LinkIdentity( return identity, nil } -// IssueAPIToken issues an API token and links its API-token identity. +// IssueAPIToken issues an API token for an existing principal. func (s *Service) IssueAPIToken(ctx context.Context, req IssueAPITokenRequest) (IssuedAPIToken, error) { if s.apiTokens == nil { return IssuedAPIToken{}, errors.New("management: API tokens service is required") } - if s.identityLinker == nil { - return IssuedAPIToken{}, errors.New("management: identity linker is required") + if s.principalFinder == nil { + return IssuedAPIToken{}, errors.New("management: principal finder is required") + } + + principal, err := s.principalFinder.FindPrincipal(ctx, req.PrincipalID) + if err != nil { + return IssuedAPIToken{}, fmt.Errorf("management: find API token principal: %w", err) } issued, err := s.apiTokens.IssueToken(ctx, apikey.IssueRequest{ - PrincipalID: req.PrincipalID, + PrincipalID: principal.ID, Name: req.Name, ExpiresAt: req.ExpiresAt, }) @@ -339,18 +344,11 @@ func (s *Service) IssueAPIToken(ctx context.Context, req IssueAPITokenRequest) ( return IssuedAPIToken{}, fmt.Errorf("management: issue API token: %w", err) } - identity, err := s.identityLinker.LinkIdentity(ctx, issued.IdentityLink) - if err != nil { - _ = s.apiTokens.RevokeToken(ctx, issued.ID) - - return IssuedAPIToken{}, fmt.Errorf("management: link API token identity: %w", err) - } - return IssuedAPIToken{ - ID: issued.ID, - Plaintext: issued.Plaintext, - ExpiresAt: issued.ExpiresAt, - Identity: identity, + ID: issued.ID, + PrincipalID: principal.ID, + Plaintext: issued.Plaintext, + ExpiresAt: issued.ExpiresAt, }, nil } diff --git a/management/service_test.go b/management/service_test.go index 60b48c1..34affe3 100644 --- a/management/service_test.go +++ b/management/service_test.go @@ -155,7 +155,7 @@ func TestServiceCoreMethodsRequirePorts(t *testing.T) { } } -func TestServiceIssueAPITokenRequiresIdentityLinkerBeforeIssuing(t *testing.T) { +func TestServiceIssueAPITokenRequiresPrincipalFinderBeforeIssuing(t *testing.T) { apiTokens := newFakeAPITokens() service := management.NewService(management.Options{ APITokens: apiTokens, @@ -166,7 +166,7 @@ func TestServiceIssueAPITokenRequiresIdentityLinkerBeforeIssuing(t *testing.T) { ExpiresAt: fixedTime().Add(time.Hour), }) - require.EqualError(t, err, "management: identity linker is required") + require.EqualError(t, err, "management: principal finder is required") assert.Empty(t, issued) assert.Empty(t, apiTokens.issueRequests) } @@ -646,7 +646,7 @@ func TestServiceLinkIdentity(t *testing.T) { assert.Equal(t, []authkit.LinkIdentityRequest{req}, linker.requests) } -func TestServiceIssueAPITokenLinksIdentity(t *testing.T) { +func TestServiceIssueAPITokenIssuesForExistingPrincipal(t *testing.T) { now := fixedTime() expiresAt := now.Add(time.Hour) apiTokens := newFakeAPITokens() @@ -654,15 +654,14 @@ func TestServiceIssueAPITokenLinksIdentity(t *testing.T) { ID: testTokenID, Plaintext: testTokenSecret, ExpiresAt: expiresAt, - IdentityLink: authkit.LinkIdentityRequest{ - Provider: apikey.Provider, - Subject: testTokenID, - PrincipalID: testPrincipalID, - }, } - linker := newFakeIdentityLinker() - linker.identity = authkit.ExternalIdentity(apiTokens.issued.IdentityLink) - service := newService(t, newFakePrincipalCreator(), linker, apiTokens) + principals := newFakePrincipalCreator() + principals.principal = authkit.Principal{ + ID: testPrincipalID, + Kind: authkit.PrincipalKindService, + DisplayName: testPrincipalName, + } + service := newService(t, principals, newFakeIdentityLinker(), apiTokens) req := management.IssueAPITokenRequest{ PrincipalID: testPrincipalID, Name: testTokenName, @@ -672,64 +671,55 @@ func TestServiceIssueAPITokenLinksIdentity(t *testing.T) { issued, err := service.IssueAPIToken(context.Background(), req) require.NoError(t, err) + assert.Equal(t, []string{testPrincipalID}, principals.findIDs) assert.Equal(t, []apikey.IssueRequest{{ PrincipalID: testPrincipalID, Name: testTokenName, ExpiresAt: expiresAt, }}, apiTokens.issueRequests) - assert.Equal(t, []authkit.LinkIdentityRequest{apiTokens.issued.IdentityLink}, linker.requests) assert.Equal(t, management.IssuedAPIToken{ - ID: testTokenID, - Plaintext: testTokenSecret, - ExpiresAt: expiresAt, - Identity: linker.identity, + ID: testTokenID, + PrincipalID: testPrincipalID, + Plaintext: testTokenSecret, + ExpiresAt: expiresAt, }, issued) } -func TestServiceIssueAPITokenReturnsIssueErrorWithoutLinking(t *testing.T) { - issueErr := errors.New("issue failed") +func TestServiceIssueAPITokenRejectsMissingPrincipal(t *testing.T) { + principals := newFakePrincipalCreator() + principals.err = authkit.ErrPrincipalNotFound apiTokens := newFakeAPITokens() - apiTokens.issueErr = issueErr - linker := newFakeIdentityLinker() - service := newService(t, newFakePrincipalCreator(), linker, apiTokens) + service := newService(t, principals, newFakeIdentityLinker(), apiTokens) issued, err := service.IssueAPIToken(context.Background(), management.IssueAPITokenRequest{ PrincipalID: testPrincipalID, ExpiresAt: fixedTime().Add(time.Hour), }) - require.ErrorIs(t, err, issueErr) + require.ErrorIs(t, err, authkit.ErrPrincipalNotFound) assert.Empty(t, issued) - assert.Empty(t, linker.requests) - assert.Empty(t, apiTokens.revokedIDs) + assert.Equal(t, []string{testPrincipalID}, principals.findIDs) + assert.Empty(t, apiTokens.issueRequests) } -func TestServiceIssueAPITokenRevokesWhenLinkingFails(t *testing.T) { - linkErr := errors.New("link failed") +func TestServiceIssueAPITokenReturnsIssueErrorWithoutLinkingIdentity(t *testing.T) { + issueErr := errors.New("issue failed") apiTokens := newFakeAPITokens() - apiTokens.issued = apikey.IssuedToken{ - ID: testTokenID, - Plaintext: testTokenSecret, - ExpiresAt: fixedTime().Add(time.Hour), - IdentityLink: authkit.LinkIdentityRequest{ - Provider: apikey.Provider, - Subject: testTokenID, - PrincipalID: testPrincipalID, - }, - } - apiTokens.revokeErr = errors.New("cleanup failed") + apiTokens.issueErr = issueErr linker := newFakeIdentityLinker() - linker.err = linkErr - service := newService(t, newFakePrincipalCreator(), linker, apiTokens) + principals := newFakePrincipalCreator() + principals.principal = authkit.Principal{ID: testPrincipalID} + service := newService(t, principals, linker, apiTokens) issued, err := service.IssueAPIToken(context.Background(), management.IssueAPITokenRequest{ PrincipalID: testPrincipalID, ExpiresAt: fixedTime().Add(time.Hour), }) - require.ErrorIs(t, err, linkErr) + require.ErrorIs(t, err, issueErr) assert.Empty(t, issued) - assert.Equal(t, []string{testTokenID}, apiTokens.revokedIDs) + assert.Empty(t, linker.requests) + assert.Empty(t, apiTokens.revokedIDs) } func TestServiceRevokeAPIToken(t *testing.T) { @@ -825,7 +815,11 @@ func TestServicePropagatesContextCancellation(t *testing.T) { { name: "link identity", run: func() error { - _, runErr := service.LinkIdentity(ctx, token.IdentityLink) + _, runErr := service.LinkIdentity(ctx, authkit.LinkIdentityRequest{ + Provider: testProvider, + Subject: testSubject, + PrincipalID: principal.ID, + }) return runErr }, @@ -967,34 +961,30 @@ func TestServiceIssueAPITokenResolvesThroughMemoryStore(t *testing.T) { ExpiresAt: now.Add(time.Hour), }) require.NoError(t, err) - identity, err := tokenService.VerifyToken(context.Background(), issued.Plaintext) - require.NoError(t, err) - require.NotNil(t, identity) - resolved, err := store.ResolveIdentity(context.Background(), *identity) + verified, err := tokenService.VerifyAPIToken(context.Background(), issued.Plaintext) require.NoError(t, err) - require.NotNil(t, resolved) - assert.Equal(t, principal, *resolved) - assert.Equal(t, authkit.ExternalIdentity{ - Provider: apikey.Provider, - Subject: issued.ID, + assert.Equal(t, principal.ID, issued.PrincipalID) + assert.Equal(t, apikey.VerifiedToken{ + ID: issued.ID, PrincipalID: principal.ID, - }, issued.Identity) + ExpiresAt: issued.ExpiresAt, + }, verified) } func newService( t *testing.T, - creator authkit.PrincipalCreator, + principals principalStore, linker authkit.IdentityLinker, apiTokens management.APITokens, ) *management.Service { t.Helper() - return newServiceWithRoles(t, creator, newFakeRoleStore(), linker, apiTokens) + return newServiceWithRoles(t, principals, newFakeRoleStore(), linker, apiTokens) } func newServiceWithRoles( t *testing.T, - creator authkit.PrincipalCreator, + principals principalStore, roles roleStore, linker authkit.IdentityLinker, apiTokens management.APITokens, @@ -1002,7 +992,8 @@ func newServiceWithRoles( t.Helper() service := management.NewService(management.Options{ - PrincipalCreator: creator, + PrincipalCreator: principals, + PrincipalFinder: principals, RoleCreator: roles, RoleActionGranter: roles, PrincipalRoleAssigner: roles, @@ -1017,7 +1008,7 @@ func newServiceWithRoles( func newServiceWithProvisioningRules( t *testing.T, - creator authkit.PrincipalCreator, + principals principalStore, roles roleStore, rules provisioningRuleStore, linker authkit.IdentityLinker, @@ -1026,7 +1017,8 @@ func newServiceWithProvisioningRules( t.Helper() service := management.NewService(management.Options{ - PrincipalCreator: creator, + PrincipalCreator: principals, + PrincipalFinder: principals, RoleCreator: roles, RoleActionGranter: roles, PrincipalRoleAssigner: roles, @@ -1079,6 +1071,11 @@ type roleStore interface { authkit.PrincipalRoleAssignmentLister } +type principalStore interface { + authkit.PrincipalCreator + authkit.PrincipalFinder +} + type provisioningRuleStore interface { authkit.ProvisioningRuleCreator authkit.ProvisioningRuleUpdater diff --git a/management/types.go b/management/types.go index 1b59464..f815108 100644 --- a/management/types.go +++ b/management/types.go @@ -1,12 +1,8 @@ package management -import ( - "time" +import "time" - "github.com/meigma/authkit" -) - -// IssueAPITokenRequest describes a request to issue and link an API token. +// IssueAPITokenRequest describes a request to issue an API token. type IssueAPITokenRequest struct { // PrincipalID identifies the principal the token should authenticate as. PrincipalID string @@ -18,17 +14,17 @@ type IssueAPITokenRequest struct { ExpiresAt time.Time } -// IssuedAPIToken describes an issued and linked API token. +// IssuedAPIToken describes an issued API token. type IssuedAPIToken struct { // ID is the stable lookup identifier embedded in the token. ID string + // PrincipalID identifies the principal the token authenticates as. + PrincipalID string + // Plaintext is the full token secret shown once to the caller. Plaintext string // ExpiresAt is the time after which the token must no longer authenticate. ExpiresAt time.Time - - // Identity is the external identity link persisted for this token. - Identity authkit.ExternalIdentity } diff --git a/pipeline.go b/pipeline.go index 7b89d7e..305a244 100644 --- a/pipeline.go +++ b/pipeline.go @@ -9,6 +9,9 @@ import ( // PipelineOptions configures a Pipeline. type PipelineOptions struct { + // PrincipalAuthenticators verify request credentials that authenticate directly to principals. + PrincipalAuthenticators []PrincipalAuthenticator + // Authenticators verify request credentials in order. Authenticators []Authenticator @@ -21,9 +24,10 @@ type PipelineOptions struct { // Pipeline composes authentication, principal resolution, and authorization. type Pipeline struct { - authenticators []Authenticator - resolver PrincipalResolver - authorizer Authorizer + principalAuthenticators []PrincipalAuthenticator + authenticators []Authenticator + resolver PrincipalResolver + authorizer Authorizer } // Authentication describes a successfully authenticated and resolved request. @@ -52,33 +56,50 @@ type Authorization struct { // NewPipeline constructs a request auth pipeline from opts. func NewPipeline(opts PipelineOptions) (*Pipeline, error) { - if len(opts.Authenticators) == 0 { + if len(opts.PrincipalAuthenticators) == 0 && len(opts.Authenticators) == 0 { return nil, errors.New("authkit: at least one authenticator is required") } + for i, authenticator := range opts.PrincipalAuthenticators { + if authenticator == nil { + return nil, fmt.Errorf("authkit: principal authenticator %d is required", i) + } + } for i, authenticator := range opts.Authenticators { if authenticator == nil { return nil, fmt.Errorf("authkit: authenticator %d is required", i) } } - if opts.Resolver == nil { + if len(opts.Authenticators) > 0 && opts.Resolver == nil { return nil, errors.New("authkit: principal resolver is required") } if opts.Authorizer == nil { return nil, errors.New("authkit: authorizer is required") } + principalAuthenticators := make([]PrincipalAuthenticator, len(opts.PrincipalAuthenticators)) + copy(principalAuthenticators, opts.PrincipalAuthenticators) authenticators := make([]Authenticator, len(opts.Authenticators)) copy(authenticators, opts.Authenticators) return &Pipeline{ - authenticators: authenticators, - resolver: opts.Resolver, - authorizer: opts.Authorizer, + principalAuthenticators: principalAuthenticators, + authenticators: authenticators, + resolver: opts.Resolver, + authorizer: opts.Authorizer, }, nil } -// Authenticate authenticates req and resolves the resulting identity to a principal. +// Authenticate authenticates req and returns the resulting principal. func (p *Pipeline) Authenticate(ctx context.Context, req *http.Request) (Authentication, error) { + for _, authenticator := range p.principalAuthenticators { + authentication, authenticated, err := p.authenticatePrincipalWith(ctx, req, authenticator) + if err != nil { + return authentication, err + } + if authenticated { + return authentication, nil + } + } for _, authenticator := range p.authenticators { authentication, authenticated, err := p.authenticateWith(ctx, req, authenticator) if err != nil { @@ -92,6 +113,48 @@ func (p *Pipeline) Authenticate(ctx context.Context, req *http.Request) (Authent return Authentication{}, fmt.Errorf("%w: no authenticator accepted request", ErrUnauthenticated) } +func (p *Pipeline) authenticatePrincipalWith( + ctx context.Context, + req *http.Request, + authenticator PrincipalAuthenticator, +) (Authentication, bool, error) { + principalAuthentication, err := authenticator.AuthenticatePrincipal(ctx, req) + if err != nil { + if isContextError(err) { + return Authentication{}, false, err + } + if errors.Is(err, ErrUnauthenticated) { + return Authentication{}, false, nil + } + + return Authentication{}, false, fmt.Errorf( + "%w: principal authenticator %q failed: %w", + ErrInternal, + authenticator.Name(), + err, + ) + } + if principalAuthentication == nil { + return Authentication{}, false, fmt.Errorf( + "%w: principal authenticator %q returned nil authentication", + ErrInternal, + authenticator.Name(), + ) + } + if principalAuthentication.Principal.ID == "" { + return Authentication{}, false, fmt.Errorf( + "%w: principal authenticator %q returned principal without ID", + ErrInternal, + authenticator.Name(), + ) + } + + return Authentication{ + AuthenticatorName: authenticator.Name(), + Principal: principalAuthentication.Principal, + }, true, nil +} + func (p *Pipeline) authenticateWith( ctx context.Context, req *http.Request, diff --git a/pipeline_test.go b/pipeline_test.go index fd21cd4..9110a4b 100644 --- a/pipeline_test.go +++ b/pipeline_test.go @@ -7,14 +7,11 @@ import ( "net/http" "net/http/httptest" "testing" - "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/meigma/authkit" - "github.com/meigma/authkit/apikey" - "github.com/meigma/authkit/store/memory" ) func testIdentity() authkit.Identity { @@ -61,6 +58,13 @@ func TestNewPipelineValidatesRequiredDependencies(t *testing.T) { Authorizer: allowAuthorizer(), }, }, + { + name: "nil principal authenticator", + opts: authkit.PipelineOptions{ + PrincipalAuthenticators: []authkit.PrincipalAuthenticator{nil}, + Authorizer: allowAuthorizer(), + }, + }, { name: "missing resolver", opts: authkit.PipelineOptions{ @@ -87,6 +91,54 @@ func TestNewPipelineValidatesRequiredDependencies(t *testing.T) { } } +func TestNewPipelineAllowsPrincipalAuthenticatorWithoutResolver(t *testing.T) { + pipeline, err := authkit.NewPipeline(authkit.PipelineOptions{ + PrincipalAuthenticators: []authkit.PrincipalAuthenticator{allowPrincipalAuthenticator()}, + Authorizer: allowAuthorizer(), + }) + + require.NoError(t, err) + assert.NotNil(t, pipeline) +} + +func TestPipelineAuthenticateUsesFirstSuccessfulPrincipalAuthenticator(t *testing.T) { + firstCalls := 0 + secondCalls := 0 + req := httptest.NewRequest(http.MethodGet, "/", nil) + pipeline := newTestPipeline(t, authkit.PipelineOptions{ + PrincipalAuthenticators: []authkit.PrincipalAuthenticator{ + testPrincipalAuthenticator{ + name: "first", + authenticate: func(context.Context, *http.Request) (*authkit.PrincipalAuthentication, error) { + firstCalls++ + + return nil, fmtUnauthenticated("missing first credential") + }, + }, + testPrincipalAuthenticator{ + name: "second", + authenticate: func(context.Context, *http.Request) (*authkit.PrincipalAuthentication, error) { + secondCalls++ + + return &authkit.PrincipalAuthentication{ + Principal: testPrincipal(), + }, nil + }, + }, + }, + Authorizer: allowAuthorizer(), + }) + + authentication, err := pipeline.Authenticate(context.Background(), req) + + require.NoError(t, err) + assert.Equal(t, "second", authentication.AuthenticatorName) + assert.Empty(t, authentication.Identity) + assert.Equal(t, testPrincipal(), authentication.Principal) + assert.Equal(t, 1, firstCalls) + assert.Equal(t, 1, secondCalls) +} + func TestPipelineAuthenticateUsesFirstSuccessfulAuthenticator(t *testing.T) { firstCalls := 0 secondCalls := 0 @@ -140,6 +192,9 @@ func TestPipelineAuthenticateUsesFirstSuccessfulAuthenticator(t *testing.T) { func TestPipelineAuthenticateReturnsUnauthenticatedWhenAllAuthenticatorsReject(t *testing.T) { resolverCalls := 0 pipeline := newTestPipeline(t, authkit.PipelineOptions{ + PrincipalAuthenticators: []authkit.PrincipalAuthenticator{ + denyPrincipalAuthenticator("principal"), + }, Authenticators: []authkit.Authenticator{ denyAuthenticator("first"), denyAuthenticator("second"), @@ -163,6 +218,27 @@ func TestPipelineAuthenticateReturnsUnauthenticatedWhenAllAuthenticatorsReject(t assert.Equal(t, 0, resolverCalls) } +func TestPipelineAuthenticateWrapsUnexpectedPrincipalAuthenticatorErrors(t *testing.T) { + authenticatorErr := errors.New("provider failed") + pipeline := newTestPipeline(t, authkit.PipelineOptions{ + PrincipalAuthenticators: []authkit.PrincipalAuthenticator{ + testPrincipalAuthenticator{ + name: "provider", + authenticate: func(context.Context, *http.Request) (*authkit.PrincipalAuthentication, error) { + return nil, authenticatorErr + }, + }, + }, + Authorizer: allowAuthorizer(), + }) + + authentication, err := pipeline.Authenticate(context.Background(), httptest.NewRequest(http.MethodGet, "/", nil)) + + require.ErrorIs(t, err, authkit.ErrInternal) + require.ErrorIs(t, err, authenticatorErr) + assert.Empty(t, authentication) +} + func TestPipelineAuthenticatePreservesUnresolvedIdentity(t *testing.T) { pipeline := newTestPipeline(t, authkit.PipelineOptions{ Authenticators: []authkit.Authenticator{allowAuthenticator()}, @@ -258,6 +334,35 @@ func TestPipelineAuthenticateWrapsBadCollaboratorContracts(t *testing.T) { Authorizer: allowAuthorizer(), }, }, + { + name: "principal authenticator returns nil authentication without error", + opts: authkit.PipelineOptions{ + PrincipalAuthenticators: []authkit.PrincipalAuthenticator{ + testPrincipalAuthenticator{ + name: "bad", + authenticate: func(context.Context, *http.Request) (*authkit.PrincipalAuthentication, error) { + //nolint:nilnil // Intentionally exercises bad collaborator contract handling. + return nil, nil + }, + }, + }, + Authorizer: allowAuthorizer(), + }, + }, + { + name: "principal authenticator returns principal without ID", + opts: authkit.PipelineOptions{ + PrincipalAuthenticators: []authkit.PrincipalAuthenticator{ + testPrincipalAuthenticator{ + name: "bad", + authenticate: func(context.Context, *http.Request) (*authkit.PrincipalAuthentication, error) { + return &authkit.PrincipalAuthentication{}, nil + }, + }, + }, + Authorizer: allowAuthorizer(), + }, + }, } for _, tt := range tests { @@ -489,48 +594,6 @@ func TestPipelineAuthorizePassesThroughContextErrors(t *testing.T) { assert.NotErrorIs(t, err, authkit.ErrInternal) } -func TestPipelineAuthenticateWithAPITokenAndMemoryStore(t *testing.T) { - now := time.Date(2026, time.May, 7, 18, 0, 0, 0, time.UTC) - store := memory.NewStore() - tokenService, err := apikey.NewService(store, apikey.WithClock(func() time.Time { - return now - })) - require.NoError(t, err) - tokenAuthenticator, err := apikey.NewAuthenticator(tokenService) - require.NoError(t, err) - principal, err := store.CreatePrincipal(context.Background(), authkit.CreatePrincipalRequest{ - Kind: authkit.PrincipalKindService, - DisplayName: "deploy service", - }) - require.NoError(t, err) - issued, err := tokenService.IssueToken(context.Background(), apikey.IssueRequest{ - PrincipalID: principal.ID, - Name: "deploy token", - ExpiresAt: now.Add(time.Hour), - }) - require.NoError(t, err) - _, err = store.LinkIdentity(context.Background(), issued.IdentityLink) - require.NoError(t, err) - pipeline := newTestPipeline(t, authkit.PipelineOptions{ - Authenticators: []authkit.Authenticator{tokenAuthenticator}, - Resolver: store, - Authorizer: allowAuthorizer(), - }) - req := httptest.NewRequest(http.MethodGet, "/", nil) - req.Header.Set("Authorization", "Bearer "+issued.Plaintext) - - authentication, err := pipeline.Authenticate(context.Background(), req) - - require.NoError(t, err) - assert.Equal(t, apikey.Provider, authentication.AuthenticatorName) - assert.Equal(t, authkit.Identity{ - Provider: apikey.Provider, - Subject: issued.ID, - CredentialID: issued.ID, - }, authentication.Identity) - assert.Equal(t, principal, authentication.Principal) -} - func newTestPipeline(t *testing.T, opts authkit.PipelineOptions) *authkit.Pipeline { t.Helper() @@ -553,6 +616,22 @@ func (a testAuthenticator) Authenticate(ctx context.Context, req *http.Request) return a.authenticate(ctx, req) } +type testPrincipalAuthenticator struct { + name string + authenticate func(context.Context, *http.Request) (*authkit.PrincipalAuthentication, error) +} + +func (a testPrincipalAuthenticator) Name() string { + return a.name +} + +func (a testPrincipalAuthenticator) AuthenticatePrincipal( + ctx context.Context, + req *http.Request, +) (*authkit.PrincipalAuthentication, error) { + return a.authenticate(ctx, req) +} + type testResolver struct { resolve func(context.Context, authkit.Identity) (*authkit.Principal, error) } @@ -580,6 +659,17 @@ func allowAuthenticator() testAuthenticator { } } +func allowPrincipalAuthenticator() testPrincipalAuthenticator { + return testPrincipalAuthenticator{ + name: "test", + authenticate: func(context.Context, *http.Request) (*authkit.PrincipalAuthentication, error) { + return &authkit.PrincipalAuthentication{ + Principal: testPrincipal(), + }, nil + }, + } +} + func denyAuthenticator(name string) testAuthenticator { return testAuthenticator{ name: name, @@ -589,6 +679,15 @@ func denyAuthenticator(name string) testAuthenticator { } } +func denyPrincipalAuthenticator(name string) testPrincipalAuthenticator { + return testPrincipalAuthenticator{ + name: name, + authenticate: func(context.Context, *http.Request) (*authkit.PrincipalAuthentication, error) { + return nil, fmtUnauthenticated("not found") + }, + } +} + func allowResolver() testResolver { return testResolver{ resolve: func(context.Context, authkit.Identity) (*authkit.Principal, error) { diff --git a/ports.go b/ports.go index 84f96d5..8166e9f 100644 --- a/ports.go +++ b/ports.go @@ -14,6 +14,21 @@ type Authenticator interface { Authenticate(ctx context.Context, r *http.Request) (*Identity, error) } +// PrincipalAuthentication describes a request credential that authenticated directly to a principal. +type PrincipalAuthentication struct { + // Principal is the internal principal authenticated by the request credential. + Principal Principal +} + +// PrincipalAuthenticator verifies credentials from an HTTP request and returns an internal principal. +type PrincipalAuthenticator interface { + // Name returns a stable name for the authenticator. + Name() string + + // AuthenticatePrincipal verifies the request credential and returns its principal. + AuthenticatePrincipal(ctx context.Context, r *http.Request) (*PrincipalAuthentication, error) +} + // PrincipalResolver maps authenticated external identities to internal principals. type PrincipalResolver interface { // ResolveIdentity returns the principal linked to identity. diff --git a/roleauth/authorizer_test.go b/roleauth/authorizer_test.go index fbfbe07..44ea7eb 100644 --- a/roleauth/authorizer_test.go +++ b/roleauth/authorizer_test.go @@ -6,14 +6,15 @@ import ( "net/http" "net/http/httptest" "testing" - "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/meigma/authkit" - "github.com/meigma/authkit/apikey" + "github.com/meigma/authkit/accessjwt" + "github.com/meigma/authkit/accessjwtauth" "github.com/meigma/authkit/httpauth" + "github.com/meigma/authkit/internal/authtest" "github.com/meigma/authkit/roleauth" "github.com/meigma/authkit/store/memory" ) @@ -120,47 +121,39 @@ func TestAuthorizerRespectsContextCancellation(t *testing.T) { assert.Empty(t, decision) } -func TestAuthorizerAllowsHTTPMiddlewareThroughRoleAction(t *testing.T) { - now := time.Date(2026, time.May, 9, 9, 0, 0, 0, time.UTC) +func TestAuthorizerAllowsHTTPMiddlewareThroughAccessJWT(t *testing.T) { + ctx := context.Background() store := memory.NewStore() - principal, err := store.CreatePrincipal(context.Background(), authkit.CreatePrincipalRequest{ + principal, err := store.CreatePrincipal(ctx, authkit.CreatePrincipalRequest{ Kind: authkit.PrincipalKindUser, DisplayName: "Ada Lovelace", }) require.NoError(t, err) - _, err = store.CreateRole(context.Background(), authkit.CreateRoleRequest{ + _, err = store.CreateRole(ctx, authkit.CreateRoleRequest{ ID: testRoleID, DisplayName: "Notes reader", }) require.NoError(t, err) - require.NoError(t, store.GrantRoleAction(context.Background(), authkit.GrantRoleActionRequest{ + require.NoError(t, store.GrantRoleAction(ctx, authkit.GrantRoleActionRequest{ RoleID: testRoleID, Action: testAction, })) - require.NoError(t, store.AssignPrincipalRole(context.Background(), authkit.AssignPrincipalRoleRequest{ + require.NoError(t, store.AssignPrincipalRole(ctx, authkit.AssignPrincipalRoleRequest{ PrincipalID: principal.ID, RoleID: testRoleID, })) - - tokenService, err := apikey.NewService(store, apikey.WithClock(func() time.Time { - return now - })) - require.NoError(t, err) - issued, err := tokenService.IssueToken(context.Background(), apikey.IssueRequest{ + issuer, verifier := authtest.NewAccessJWTIssuerAndVerifier(t) + issued, err := issuer.IssueToken(ctx, accessjwt.IssueRequest{ PrincipalID: principal.ID, - ExpiresAt: now.Add(time.Hour), }) require.NoError(t, err) - _, err = store.LinkIdentity(context.Background(), issued.IdentityLink) - require.NoError(t, err) - authenticator, err := apikey.NewAuthenticator(tokenService) + authenticator, err := accessjwtauth.NewAuthenticator(verifier, store) require.NoError(t, err) authorizer, err := roleauth.NewAuthorizer(store) require.NoError(t, err) pipeline, err := authkit.NewPipeline(authkit.PipelineOptions{ - Authenticators: []authkit.Authenticator{authenticator}, - Resolver: store, - Authorizer: authorizer, + PrincipalAuthenticators: []authkit.PrincipalAuthenticator{authenticator}, + Authorizer: authorizer, }) require.NoError(t, err) middleware, err := httpauth.NewMiddleware(pipeline) diff --git a/store/memory/store_test.go b/store/memory/store_test.go index 086473d..3001b2f 100644 --- a/store/memory/store_test.go +++ b/store/memory/store_test.go @@ -379,12 +379,13 @@ func createPrincipal(t *testing.T, store *Store) authkit.Principal { func TestStoreTokenStorage(t *testing.T) { store := NewStore() + principal := createPrincipal(t, store) now := fixedStoreTime() usedAt := now.Add(time.Hour) wantUsedAt := usedAt token := apikey.StoredToken{ ID: "token_1", - PrincipalID: testProvider, + PrincipalID: principal.ID, Name: "deploy", SecretHash: sha256.Sum256([]byte("secret")), ExpiresAt: now.Add(time.Hour), @@ -436,10 +437,13 @@ func TestStoreTokenLastUsedAndRevocation(t *testing.T) { func TestStoreTokenMissingBehavior(t *testing.T) { store := NewStore() now := fixedStoreTime() + token := tokenFixture(now) + token.PrincipalID = "missing" found, err := store.FindToken(context.Background(), "missing") require.ErrorIs(t, err, apikey.ErrTokenNotFound) assert.Empty(t, found) + require.ErrorIs(t, store.CreateToken(context.Background(), token), authkit.ErrPrincipalNotFound) require.ErrorIs( t, @@ -465,7 +469,9 @@ func TestStoreTokenContextCancellation(t *testing.T) { func createToken(t *testing.T, store *Store, now time.Time) apikey.StoredToken { t.Helper() + principal := createPrincipal(t, store) token := tokenFixture(now) + token.PrincipalID = principal.ID require.NoError(t, store.CreateToken(context.Background(), token)) return token diff --git a/store/memory/token.go b/store/memory/token.go index 9da182e..678e16e 100644 --- a/store/memory/token.go +++ b/store/memory/token.go @@ -23,6 +23,9 @@ func (s *Store) CreateToken(ctx context.Context, token apikey.StoredToken) error s.mu.Lock() defer s.mu.Unlock() + if _, ok := s.principals[token.PrincipalID]; !ok { + return authkit.ErrPrincipalNotFound + } if _, ok := s.tokens[token.ID]; ok { return errors.New("memory: token already exists") } diff --git a/store/postgres/store.go b/store/postgres/store.go index e7b7d15..da0311a 100644 --- a/store/postgres/store.go +++ b/store/postgres/store.go @@ -800,7 +800,11 @@ func (s *Store) CreateToken(ctx context.Context, token apikey.StoredToken) error token.RevokedAt, ); err != nil { if isPostgresCode(err, foreignKeyViolation) { - return fmt.Errorf("postgres: principal %q does not exist", token.PrincipalID) + return fmt.Errorf( + "%w: postgres: principal %q does not exist", + authkit.ErrPrincipalNotFound, + token.PrincipalID, + ) } return fmt.Errorf("postgres: create token: %w", err)