From 3effcf99af7b67e3d4554c5f086052534773865c Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Wed, 27 May 2026 14:41:49 -0700 Subject: [PATCH 1/6] refactor(exchange): colocate request/result/options with their exchanger Move APITokenVerifier, APITokenOptions, APITokenRequest, and APITokenResult into apitoken.go alongside APITokenExchanger. Move IdentityOptions, IdentityRequest, and IdentityResult into identity.go alongside IdentityExchanger. Extract the shared AccessTokenIssuer port into a new tokens.go. Delete types.go. Public surface, behaviour, and error wording unchanged. --- exchange/apitoken.go | 37 +++++++++++++++++++++ exchange/identity.go | 27 +++++++++++++++ exchange/tokens.go | 13 ++++++++ exchange/types.go | 78 -------------------------------------------- 4 files changed, 77 insertions(+), 78 deletions(-) create mode 100644 exchange/tokens.go delete mode 100644 exchange/types.go diff --git a/exchange/apitoken.go b/exchange/apitoken.go index c7fa4d2..253cea2 100644 --- a/exchange/apitoken.go +++ b/exchange/apitoken.go @@ -7,8 +7,45 @@ import ( "github.com/meigma/authkit" "github.com/meigma/authkit/access/jwt" + "github.com/meigma/authkit/proof/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) +} + +// 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 jwt.IssuedToken +} + // APITokenExchanger exchanges opaque API tokens for authkit access JWTs. type APITokenExchanger struct { apiTokens APITokenVerifier diff --git a/exchange/identity.go b/exchange/identity.go index 3da41bd..9ed387b 100644 --- a/exchange/identity.go +++ b/exchange/identity.go @@ -9,6 +9,33 @@ import ( "github.com/meigma/authkit/access/jwt" ) +// IdentityOptions configures an IdentityExchanger. +type IdentityOptions struct { + // Resolver resolves or provisions verified external identities. + Resolver authkit.PrincipalResolver + + // AccessTokens issues authkit access JWTs. + AccessTokens AccessTokenIssuer +} + +// IdentityRequest describes an identity exchange request. +type IdentityRequest struct { + // Identity is the verified external identity presented for exchange. + Identity authkit.Identity +} + +// IdentityResult describes a completed identity exchange. +type IdentityResult struct { + // Identity is the verified external identity exchanged for an access JWT. + Identity authkit.Identity + + // Principal is the principal resolved or provisioned for Identity. + Principal authkit.Principal + + // AccessToken is the authkit access JWT issued for Principal. + AccessToken jwt.IssuedToken +} + // IdentityExchanger exchanges verified external identities for authkit access JWTs. type IdentityExchanger struct { resolver authkit.PrincipalResolver diff --git a/exchange/tokens.go b/exchange/tokens.go new file mode 100644 index 0000000..2f4da1b --- /dev/null +++ b/exchange/tokens.go @@ -0,0 +1,13 @@ +package exchange + +import ( + "context" + + "github.com/meigma/authkit/access/jwt" +) + +// AccessTokenIssuer issues authkit access JWTs. +type AccessTokenIssuer interface { + // IssueToken issues an access JWT for req.PrincipalID. + IssueToken(ctx context.Context, req jwt.IssueRequest) (jwt.IssuedToken, error) +} diff --git a/exchange/types.go b/exchange/types.go deleted file mode 100644 index 074e762..0000000 --- a/exchange/types.go +++ /dev/null @@ -1,78 +0,0 @@ -package exchange - -import ( - "context" - - "github.com/meigma/authkit" - "github.com/meigma/authkit/access/jwt" - "github.com/meigma/authkit/proof/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 jwt.IssueRequest) (jwt.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 jwt.IssuedToken -} - -// IdentityOptions configures an IdentityExchanger. -type IdentityOptions struct { - // Resolver resolves or provisions verified external identities. - Resolver authkit.PrincipalResolver - - // AccessTokens issues authkit access JWTs. - AccessTokens AccessTokenIssuer -} - -// IdentityRequest describes an identity exchange request. -type IdentityRequest struct { - // Identity is the verified external identity presented for exchange. - Identity authkit.Identity -} - -// IdentityResult describes a completed identity exchange. -type IdentityResult struct { - // Identity is the verified external identity exchanged for an access JWT. - Identity authkit.Identity - - // Principal is the principal resolved or provisioned for Identity. - Principal authkit.Principal - - // AccessToken is the authkit access JWT issued for Principal. - AccessToken jwt.IssuedToken -} From 6a54ba1a07076e2ac906643094b7c9fac371f080 Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Wed, 27 May 2026 14:42:20 -0700 Subject: [PATCH 2/6] refactor(exchange): extract error helpers into errors.go Move exchangeError, unauthenticated, and isContextError from apitoken.go into a new errors.go so identity.go (which also calls exchangeError) sees them in a neutral location. Add godocs documenting the sentinel-preservation contract and the context-error fast path. --- exchange/apitoken.go | 19 ------------------- exchange/errors.go | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 19 deletions(-) create mode 100644 exchange/errors.go diff --git a/exchange/apitoken.go b/exchange/apitoken.go index 253cea2..718ef05 100644 --- a/exchange/apitoken.go +++ b/exchange/apitoken.go @@ -3,7 +3,6 @@ package exchange import ( "context" "errors" - "fmt" "github.com/meigma/authkit" "github.com/meigma/authkit/access/jwt" @@ -105,21 +104,3 @@ func (e *APITokenExchanger) Exchange(ctx context.Context, req APITokenRequest) ( }, nil } -func exchangeError(operation string, err error) error { - if isContextError(err) { - return err - } - if errors.Is(err, authkit.ErrUnauthenticated) || errors.Is(err, authkit.ErrUnresolvedIdentity) { - 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/errors.go b/exchange/errors.go new file mode 100644 index 0000000..1a6b386 --- /dev/null +++ b/exchange/errors.go @@ -0,0 +1,35 @@ +package exchange + +import ( + "context" + "errors" + "fmt" + + "github.com/meigma/authkit" +) + +// exchangeError classifies err raised during operation. Context-cancellation errors and authkit +// sentinel errors (ErrUnauthenticated, ErrUnresolvedIdentity) are returned unchanged so callers +// can detect them with [errors.Is]; anything else is wrapped as ErrInternal with operation as +// breadcrumb. +func exchangeError(operation string, err error) error { + if isContextError(err) { + return err + } + if errors.Is(err, authkit.ErrUnauthenticated) || errors.Is(err, authkit.ErrUnresolvedIdentity) { + return err + } + + return fmt.Errorf("%w: %s: %w", authkit.ErrInternal, operation, err) +} + +// unauthenticated wraps reason with authkit.ErrUnauthenticated so callers can recognise the +// failure via [errors.Is]. +func unauthenticated(reason string) error { + return fmt.Errorf("%w: %s", authkit.ErrUnauthenticated, reason) +} + +// isContextError reports whether err is a context cancellation or deadline error. +func isContextError(err error) bool { + return errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) +} From 320ba3d67e4092aa8d8d0aa1b945fcd4f381868c Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Wed, 27 May 2026 14:43:09 -0700 Subject: [PATCH 3/6] refactor(exchange): tighten port godocs and annotate defensive checks Expand APITokenVerifier and AccessTokenIssuer godocs to spell out the sentinel-error contract and the signing/expiry responsibility. Annotate the ErrPrincipalNotFound branch in APITokenExchanger.Exchange and the nil/empty- ID branches in IdentityExchanger.Exchange to name what each check defends against (existence-leak, downstream-authorization key). Annotate the two early-return branches in exchangeError so the sentinel-preservation strategy is visible at the call site. Matches the access/middleware/authenticator.go inline-comment style from PR #61. --- exchange/apitoken.go | 6 +++++- exchange/errors.go | 4 ++++ exchange/identity.go | 5 +++++ exchange/tokens.go | 4 +++- 4 files changed, 17 insertions(+), 2 deletions(-) diff --git a/exchange/apitoken.go b/exchange/apitoken.go index 718ef05..fd3900e 100644 --- a/exchange/apitoken.go +++ b/exchange/apitoken.go @@ -9,7 +9,9 @@ import ( "github.com/meigma/authkit/proof/apikey" ) -// APITokenVerifier verifies opaque API tokens. +// APITokenVerifier verifies opaque API tokens and returns their authenticated metadata. +// Implementations must return authkit.ErrUnauthenticated (directly or wrapped) when plaintext is +// not a currently valid token; other errors are treated as internal failures. type APITokenVerifier interface { // VerifyAPIToken verifies plaintext and returns its authenticated token metadata. VerifyAPIToken(ctx context.Context, plaintext string) (apikey.VerifiedToken, error) @@ -83,6 +85,8 @@ func (e *APITokenExchanger) Exchange(ctx context.Context, req APITokenRequest) ( } principal, err := e.principals.FindPrincipal(ctx, apiToken.PrincipalID) + // Missing principal collapses to the generic unauthenticated response so the API does not + // leak whether a specific PrincipalID exists. if errors.Is(err, authkit.ErrPrincipalNotFound) { return APITokenResult{}, unauthenticated("principal not found") } diff --git a/exchange/errors.go b/exchange/errors.go index 1a6b386..8d1d660 100644 --- a/exchange/errors.go +++ b/exchange/errors.go @@ -13,9 +13,13 @@ import ( // can detect them with [errors.Is]; anything else is wrapped as ErrInternal with operation as // breadcrumb. func exchangeError(operation string, err error) error { + // Context errors are returned unchanged so callers can distinguish cancellation/deadline from + // a legitimate collaborator failure. if isContextError(err) { return err } + // Authentication and identity-resolution sentinels are returned unchanged so callers can + // branch on them via [errors.Is]; wrapping would hide the sentinel behind ErrInternal. if errors.Is(err, authkit.ErrUnauthenticated) || errors.Is(err, authkit.ErrUnresolvedIdentity) { return err } diff --git a/exchange/identity.go b/exchange/identity.go index 9ed387b..14d797b 100644 --- a/exchange/identity.go +++ b/exchange/identity.go @@ -67,9 +67,14 @@ func (e *IdentityExchanger) Exchange(ctx context.Context, req IdentityRequest) ( if err != nil { return IdentityResult{}, exchangeError("resolve identity", err) } + // Defend against a misbehaving PrincipalResolver that returns (nil, nil): without a principal + // there is nothing to issue a JWT for, and continuing would dereference nil below. if principal == nil { return IdentityResult{}, fmt.Errorf("%w: resolve identity returned nil principal", authkit.ErrInternal) } + // Defend against a resolver returning a principal with no ID; a principal with no ID would + // produce a JWT with an empty subject and bypass downstream authorization that keys off + // Principal.ID. if principal.ID == "" { return IdentityResult{}, fmt.Errorf("%w: resolved principal ID is required", authkit.ErrInternal) } diff --git a/exchange/tokens.go b/exchange/tokens.go index 2f4da1b..bdbc335 100644 --- a/exchange/tokens.go +++ b/exchange/tokens.go @@ -6,7 +6,9 @@ import ( "github.com/meigma/authkit/access/jwt" ) -// AccessTokenIssuer issues authkit access JWTs. +// AccessTokenIssuer issues short-lived authkit access JWTs bound to a principal. Implementations +// own signing, the kid header, and expiry policy; req.PrincipalID is required. The returned +// IssuedToken carries both the JWT string and its parsed metadata. type AccessTokenIssuer interface { // IssueToken issues an access JWT for req.PrincipalID. IssueToken(ctx context.Context, req jwt.IssueRequest) (jwt.IssuedToken, error) From c8eaf2879ff53f7f1dbb80121ea52f178599cac4 Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Wed, 27 May 2026 14:44:47 -0700 Subject: [PATCH 4/6] test(exchange): split shared helpers into helpers_test.go Lift testPrincipalID, testTokenID, fixedTime, newAccessJWTIssuerAndVerifier, newAPITokenExchanger, newIdentityExchanger, testExchangeIdentity, and testExchangePrincipal into a new helpers_test.go. Move the two adapter-local fakes (fakeAPITokenVerifier, fakeAccessTokenIssuer) alongside them since both test files reference them. Mechanical lift; no test logic changes. Matches the helpers_test.go precedent from PR #61 (access/jwt) and PR #62 (authz/casbin). --- exchange/apitoken_test.go | 55 ----------------------- exchange/helpers_test.go | 95 +++++++++++++++++++++++++++++++++++++++ exchange/identity_test.go | 27 ----------- 3 files changed, 95 insertions(+), 82 deletions(-) create mode 100644 exchange/helpers_test.go diff --git a/exchange/apitoken_test.go b/exchange/apitoken_test.go index 1991ffb..964e722 100644 --- a/exchange/apitoken_test.go +++ b/exchange/apitoken_test.go @@ -10,18 +10,11 @@ import ( "github.com/stretchr/testify/require" "github.com/meigma/authkit" - "github.com/meigma/authkit/access/jwt" "github.com/meigma/authkit/exchange" - "github.com/meigma/authkit/internal/authtest" "github.com/meigma/authkit/proof/apikey" "github.com/meigma/authkit/store/memory" ) -const ( - testPrincipalID = "principal_1" - testTokenID = "access-token-123" -) - func TestNewAPITokenExchangerValidatesDependencies(t *testing.T) { apiTokens := fakeAPITokenVerifier{} principals := fakePrincipalFinder{} @@ -260,38 +253,6 @@ func TestAPITokenExchangerPassesThroughContextErrors(t *testing.T) { 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) (*jwt.Issuer, *jwt.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 @@ -303,19 +264,3 @@ func (f fakePrincipalFinder) FindPrincipal( ) (authkit.Principal, error) { return f.principal, f.err } - -type fakeAccessTokenIssuer struct { - token jwt.IssuedToken - err error -} - -func (f fakeAccessTokenIssuer) IssueToken( - context.Context, - jwt.IssueRequest, -) (jwt.IssuedToken, error) { - return f.token, f.err -} - -func fixedTime() time.Time { - return authtest.FixedTime() -} diff --git a/exchange/helpers_test.go b/exchange/helpers_test.go new file mode 100644 index 0000000..3dd8d47 --- /dev/null +++ b/exchange/helpers_test.go @@ -0,0 +1,95 @@ +package exchange_test + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/meigma/authkit" + "github.com/meigma/authkit/access/jwt" + "github.com/meigma/authkit/exchange" + "github.com/meigma/authkit/internal/authtest" + "github.com/meigma/authkit/proof/apikey" +) + +const ( + testPrincipalID = "principal_1" + testTokenID = "access-token-123" +) + +func newAPITokenExchanger(t *testing.T, opts exchange.APITokenOptions) *exchange.APITokenExchanger { + t.Helper() + + exchanger, err := exchange.NewAPITokenExchanger(opts) + require.NoError(t, err) + + return exchanger +} + +func newIdentityExchanger(t *testing.T, opts exchange.IdentityOptions) *exchange.IdentityExchanger { + t.Helper() + + exchanger, err := exchange.NewIdentityExchanger(opts) + require.NoError(t, err) + + return exchanger +} + +func newAccessJWTIssuerAndVerifier(t *testing.T) (*jwt.Issuer, *jwt.Verifier) { + t.Helper() + + return authtest.NewAccessJWTIssuerAndVerifier( + t, + authtest.WithAccessJWTTokenID(func() (string, error) { + return testTokenID, nil + }), + ) +} + +func testExchangeIdentity() authkit.Identity { + return authkit.Identity{ + Provider: "https://issuer.example", + Subject: "user-123", + Claims: map[string]any{ + "email": "ada@example.test", + }, + } +} + +func testExchangePrincipal() authkit.Principal { + return authkit.Principal{ + ID: testPrincipalID, + Kind: authkit.PrincipalKindUser, + DisplayName: "Ada Lovelace", + } +} + +func fixedTime() time.Time { + return authtest.FixedTime() +} + +type fakeAPITokenVerifier struct { + token apikey.VerifiedToken + err error +} + +func (f fakeAPITokenVerifier) VerifyAPIToken( + context.Context, + string, +) (apikey.VerifiedToken, error) { + return f.token, f.err +} + +type fakeAccessTokenIssuer struct { + token jwt.IssuedToken + err error +} + +func (f fakeAccessTokenIssuer) IssueToken( + context.Context, + jwt.IssueRequest, +) (jwt.IssuedToken, error) { + return f.token, f.err +} diff --git a/exchange/identity_test.go b/exchange/identity_test.go index a01a58d..629ae6c 100644 --- a/exchange/identity_test.go +++ b/exchange/identity_test.go @@ -156,33 +156,6 @@ func TestIdentityExchangerPassesThroughContextErrors(t *testing.T) { assert.Empty(t, result) } -func newIdentityExchanger(t *testing.T, opts exchange.IdentityOptions) *exchange.IdentityExchanger { - t.Helper() - - exchanger, err := exchange.NewIdentityExchanger(opts) - require.NoError(t, err) - - return exchanger -} - -func testExchangeIdentity() authkit.Identity { - return authkit.Identity{ - Provider: "https://issuer.example", - Subject: "user-123", - Claims: map[string]any{ - "email": "ada@example.test", - }, - } -} - -func testExchangePrincipal() authkit.Principal { - return authkit.Principal{ - ID: testPrincipalID, - Kind: authkit.PrincipalKindUser, - DisplayName: "Ada Lovelace", - } -} - type fakeIdentityResolver struct { principal authkit.Principal err error From b728c38ce4e1174d7f2cda3a9a324597102932c3 Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Wed, 27 May 2026 14:46:41 -0700 Subject: [PATCH 5/6] test(exchange): migrate root-port fakes to mockery Add authkit.PrincipalResolver to .mockery.yaml and regenerate; the generated mock lands at mocks/authkit/principal_resolver.go in package authkitmocks. Swap fakePrincipalFinder for authkitmocks.PrincipalFinder in apitoken_test.go and fakeIdentityResolver for authkitmocks.PrincipalResolver in identity_test.go. Both hand-rolled types are deleted. fakeAPITokenVerifier and fakeAccessTokenIssuer stay because they back package-local adapter ports (the established mockery pattern). Drop TestAPITokenExchangerDoesNotRequireIdentityLink: its assertions are a strict subset of TestAPITokenExchangerExchangesTokenForAccessJWT (both create a principal with no identity link and exchange) and the test name promised a stronger property than the body actually verifies. Keep TestAPITokenExchangerExchangesTokenForAccessJWT integration-style with real memory.Store + real apikey.Service so one anchor still proves the full chain end-to-end. --- .mockery.yaml | 7 ++ exchange/apitoken_test.go | 107 ++++++++++------------------ exchange/identity_test.go | 77 ++++++++++---------- mocks/authkit/principal_resolver.go | 107 ++++++++++++++++++++++++++++ 4 files changed, 187 insertions(+), 111 deletions(-) create mode 100644 mocks/authkit/principal_resolver.go diff --git a/.mockery.yaml b/.mockery.yaml index 20d2471..b2b6a5c 100644 --- a/.mockery.yaml +++ b/.mockery.yaml @@ -15,3 +15,10 @@ packages: pkgname: authkitmocks dir: mocks/authkit filename: principal_action_resolver.go + PrincipalResolver: + config: + template: testify + structname: PrincipalResolver + pkgname: authkitmocks + dir: mocks/authkit + filename: principal_resolver.go diff --git a/exchange/apitoken_test.go b/exchange/apitoken_test.go index 964e722..3b89db5 100644 --- a/exchange/apitoken_test.go +++ b/exchange/apitoken_test.go @@ -7,17 +7,19 @@ import ( "time" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" "github.com/meigma/authkit" "github.com/meigma/authkit/exchange" + authkitmocks "github.com/meigma/authkit/mocks/authkit" "github.com/meigma/authkit/proof/apikey" "github.com/meigma/authkit/store/memory" ) func TestNewAPITokenExchangerValidatesDependencies(t *testing.T) { apiTokens := fakeAPITokenVerifier{} - principals := fakePrincipalFinder{} + principals := authkitmocks.NewPrincipalFinder(t) accessTokens := fakeAccessTokenIssuer{} tests := []struct { @@ -97,40 +99,11 @@ func TestAPITokenExchangerExchangesTokenForAccessJWT(t *testing.T) { 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() + principals := authkitmocks.NewPrincipalFinder(t) + principals.EXPECT(). + FindPrincipal(mock.Anything, "missing"). + Return(authkit.Principal{}, authkit.ErrPrincipalNotFound) accessTokens, _ := newAccessJWTIssuerAndVerifier(t) exchanger := newAPITokenExchanger(t, exchange.APITokenOptions{ APITokens: fakeAPITokenVerifier{ @@ -140,11 +113,11 @@ func TestAPITokenExchangerRejectsMissingPrincipal(t *testing.T) { ExpiresAt: fixedTime().Add(time.Hour), }, }, - Principals: store, + Principals: principals, AccessTokens: accessTokens, }) - result, err := exchanger.Exchange(ctx, exchange.APITokenRequest{ + result, err := exchanger.Exchange(context.Background(), exchange.APITokenRequest{ Plaintext: "ak_token_secret", }) @@ -186,35 +159,39 @@ func TestAPITokenExchangerWrapsInternalFailures(t *testing.T) { } tests := []struct { - name string - opts exchange.APITokenOptions - want error + name string + setupOpts func(t *testing.T) exchange.APITokenOptions + want error }{ { name: "principal finder failure", - opts: exchange.APITokenOptions{ - APITokens: fakeAPITokenVerifier{ - token: apiToken, - }, - Principals: fakePrincipalFinder{ - err: storeErr, - }, - AccessTokens: fakeAccessTokenIssuer{}, + setupOpts: func(t *testing.T) exchange.APITokenOptions { + t.Helper() + principals := authkitmocks.NewPrincipalFinder(t) + principals.EXPECT(). + FindPrincipal(mock.Anything, testPrincipalID). + Return(authkit.Principal{}, storeErr) + return exchange.APITokenOptions{ + APITokens: fakeAPITokenVerifier{token: apiToken}, + Principals: principals, + AccessTokens: fakeAccessTokenIssuer{}, + } }, want: storeErr, }, { name: "access token issuer failure", - opts: exchange.APITokenOptions{ - APITokens: fakeAPITokenVerifier{ - token: apiToken, - }, - Principals: fakePrincipalFinder{ - principal: principal, - }, - AccessTokens: fakeAccessTokenIssuer{ - err: issuerErr, - }, + setupOpts: func(t *testing.T) exchange.APITokenOptions { + t.Helper() + principals := authkitmocks.NewPrincipalFinder(t) + principals.EXPECT(). + FindPrincipal(mock.Anything, testPrincipalID). + Return(principal, nil) + return exchange.APITokenOptions{ + APITokens: fakeAPITokenVerifier{token: apiToken}, + Principals: principals, + AccessTokens: fakeAccessTokenIssuer{err: issuerErr}, + } }, want: issuerErr, }, @@ -222,7 +199,7 @@ func TestAPITokenExchangerWrapsInternalFailures(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - exchanger := newAPITokenExchanger(t, tt.opts) + exchanger := newAPITokenExchanger(t, tt.setupOpts(t)) result, err := exchanger.Exchange(context.Background(), exchange.APITokenRequest{ Plaintext: "ak_token_secret", @@ -238,7 +215,7 @@ func TestAPITokenExchangerWrapsInternalFailures(t *testing.T) { func TestAPITokenExchangerPassesThroughContextErrors(t *testing.T) { exchanger := newAPITokenExchanger(t, exchange.APITokenOptions{ APITokens: fakeAPITokenVerifier{}, - Principals: fakePrincipalFinder{}, + Principals: authkitmocks.NewPrincipalFinder(t), AccessTokens: fakeAccessTokenIssuer{}, }) ctx, cancel := context.WithCancel(context.Background()) @@ -252,15 +229,3 @@ func TestAPITokenExchangerPassesThroughContextErrors(t *testing.T) { require.NotErrorIs(t, err, authkit.ErrInternal) assert.Empty(t, result) } - -type fakePrincipalFinder struct { - principal authkit.Principal - err error -} - -func (f fakePrincipalFinder) FindPrincipal( - context.Context, - string, -) (authkit.Principal, error) { - return f.principal, f.err -} diff --git a/exchange/identity_test.go b/exchange/identity_test.go index 629ae6c..ed25b76 100644 --- a/exchange/identity_test.go +++ b/exchange/identity_test.go @@ -7,15 +7,17 @@ import ( "time" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" "github.com/meigma/authkit" "github.com/meigma/authkit/access/jwt" "github.com/meigma/authkit/exchange" + authkitmocks "github.com/meigma/authkit/mocks/authkit" ) func TestNewIdentityExchangerValidatesDependencies(t *testing.T) { - resolver := fakeIdentityResolver{} + resolver := authkitmocks.NewPrincipalResolver(t) accessTokens := fakeAccessTokenIssuer{} tests := []struct { @@ -55,10 +57,10 @@ func TestIdentityExchangerExchangesResolvedIdentityForAccessJWT(t *testing.T) { JWT: "access.jwt", ExpiresAt: fixedTime().Add(time.Hour), } + resolver := authkitmocks.NewPrincipalResolver(t) + resolver.EXPECT().ResolveIdentity(mock.Anything, identity).Return(&principal, nil) exchanger := newIdentityExchanger(t, exchange.IdentityOptions{ - Resolver: fakeIdentityResolver{ - principal: principal, - }, + Resolver: resolver, AccessTokens: fakeAccessTokenIssuer{ token: accessToken, }, @@ -75,10 +77,12 @@ func TestIdentityExchangerExchangesResolvedIdentityForAccessJWT(t *testing.T) { } func TestIdentityExchangerPassesThroughUnresolvedIdentity(t *testing.T) { + resolver := authkitmocks.NewPrincipalResolver(t) + resolver.EXPECT(). + ResolveIdentity(mock.Anything, mock.Anything). + Return(nil, authkit.ErrUnresolvedIdentity) exchanger := newIdentityExchanger(t, exchange.IdentityOptions{ - Resolver: fakeIdentityResolver{ - err: authkit.ErrUnresolvedIdentity, - }, + Resolver: resolver, AccessTokens: fakeAccessTokenIssuer{}, }) @@ -94,31 +98,40 @@ func TestIdentityExchangerPassesThroughUnresolvedIdentity(t *testing.T) { func TestIdentityExchangerWrapsInternalFailures(t *testing.T) { resolverErr := errors.New("resolver failed") issuerErr := errors.New("issuer failed") + principal := testExchangePrincipal() tests := []struct { - name string - opts exchange.IdentityOptions - want error + name string + setupOpts func(t *testing.T) exchange.IdentityOptions + want error }{ { name: "resolver failure", - opts: exchange.IdentityOptions{ - Resolver: fakeIdentityResolver{ - err: resolverErr, - }, - AccessTokens: fakeAccessTokenIssuer{}, + setupOpts: func(t *testing.T) exchange.IdentityOptions { + t.Helper() + resolver := authkitmocks.NewPrincipalResolver(t) + resolver.EXPECT(). + ResolveIdentity(mock.Anything, mock.Anything). + Return(nil, resolverErr) + return exchange.IdentityOptions{ + Resolver: resolver, + AccessTokens: fakeAccessTokenIssuer{}, + } }, want: resolverErr, }, { name: "issuer failure", - opts: exchange.IdentityOptions{ - Resolver: fakeIdentityResolver{ - principal: testExchangePrincipal(), - }, - AccessTokens: fakeAccessTokenIssuer{ - err: issuerErr, - }, + setupOpts: func(t *testing.T) exchange.IdentityOptions { + t.Helper() + resolver := authkitmocks.NewPrincipalResolver(t) + resolver.EXPECT(). + ResolveIdentity(mock.Anything, mock.Anything). + Return(&principal, nil) + return exchange.IdentityOptions{ + Resolver: resolver, + AccessTokens: fakeAccessTokenIssuer{err: issuerErr}, + } }, want: issuerErr, }, @@ -126,7 +139,7 @@ func TestIdentityExchangerWrapsInternalFailures(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - exchanger := newIdentityExchanger(t, tt.opts) + exchanger := newIdentityExchanger(t, tt.setupOpts(t)) result, err := exchanger.Exchange(context.Background(), exchange.IdentityRequest{ Identity: testExchangeIdentity(), @@ -141,7 +154,7 @@ func TestIdentityExchangerWrapsInternalFailures(t *testing.T) { func TestIdentityExchangerPassesThroughContextErrors(t *testing.T) { exchanger := newIdentityExchanger(t, exchange.IdentityOptions{ - Resolver: fakeIdentityResolver{}, + Resolver: authkitmocks.NewPrincipalResolver(t), AccessTokens: fakeAccessTokenIssuer{}, }) ctx, cancel := context.WithCancel(context.Background()) @@ -155,19 +168,3 @@ func TestIdentityExchangerPassesThroughContextErrors(t *testing.T) { require.NotErrorIs(t, err, authkit.ErrInternal) assert.Empty(t, result) } - -type fakeIdentityResolver struct { - principal authkit.Principal - err error -} - -func (f fakeIdentityResolver) ResolveIdentity( - context.Context, - authkit.Identity, -) (*authkit.Principal, error) { - if f.err != nil { - return nil, f.err - } - - return &f.principal, nil -} diff --git a/mocks/authkit/principal_resolver.go b/mocks/authkit/principal_resolver.go new file mode 100644 index 0000000..cee154b --- /dev/null +++ b/mocks/authkit/principal_resolver.go @@ -0,0 +1,107 @@ +// Code generated by mockery; DO NOT EDIT. +// github.com/vektra/mockery +// template: testify + +package authkitmocks + +import ( + "context" + + "github.com/meigma/authkit" + mock "github.com/stretchr/testify/mock" +) + +// NewPrincipalResolver creates a new instance of PrincipalResolver. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewPrincipalResolver(t interface { + mock.TestingT + Cleanup(func()) +}) *PrincipalResolver { + mock := &PrincipalResolver{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} + +// PrincipalResolver is an autogenerated mock type for the PrincipalResolver type +type PrincipalResolver struct { + mock.Mock +} + +type PrincipalResolver_Expecter struct { + mock *mock.Mock +} + +func (_m *PrincipalResolver) EXPECT() *PrincipalResolver_Expecter { + return &PrincipalResolver_Expecter{mock: &_m.Mock} +} + +// ResolveIdentity provides a mock function for the type PrincipalResolver +func (_mock *PrincipalResolver) ResolveIdentity(ctx context.Context, identity authkit.Identity) (*authkit.Principal, error) { + ret := _mock.Called(ctx, identity) + + if len(ret) == 0 { + panic("no return value specified for ResolveIdentity") + } + + var r0 *authkit.Principal + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, authkit.Identity) (*authkit.Principal, error)); ok { + return returnFunc(ctx, identity) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, authkit.Identity) *authkit.Principal); ok { + r0 = returnFunc(ctx, identity) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*authkit.Principal) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context, authkit.Identity) error); ok { + r1 = returnFunc(ctx, identity) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// PrincipalResolver_ResolveIdentity_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ResolveIdentity' +type PrincipalResolver_ResolveIdentity_Call struct { + *mock.Call +} + +// ResolveIdentity is a helper method to define mock.On call +// - ctx context.Context +// - identity authkit.Identity +func (_e *PrincipalResolver_Expecter) ResolveIdentity(ctx interface{}, identity interface{}) *PrincipalResolver_ResolveIdentity_Call { + return &PrincipalResolver_ResolveIdentity_Call{Call: _e.mock.On("ResolveIdentity", ctx, identity)} +} + +func (_c *PrincipalResolver_ResolveIdentity_Call) Run(run func(ctx context.Context, identity authkit.Identity)) *PrincipalResolver_ResolveIdentity_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 authkit.Identity + if args[1] != nil { + arg1 = args[1].(authkit.Identity) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *PrincipalResolver_ResolveIdentity_Call) Return(principal *authkit.Principal, err error) *PrincipalResolver_ResolveIdentity_Call { + _c.Call.Return(principal, err) + return _c +} + +func (_c *PrincipalResolver_ResolveIdentity_Call) RunAndReturn(run func(ctx context.Context, identity authkit.Identity) (*authkit.Principal, error)) *PrincipalResolver_ResolveIdentity_Call { + _c.Call.Return(run) + return _c +} From c82be99501d8900f1b89aebd855772653f724bbd Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Wed, 27 May 2026 14:47:51 -0700 Subject: [PATCH 6/6] chore(exchange): drop trailing blank line introduced by file rewrite A trailing blank line slipped into apitoken.go during the file-reorg commit and gofmt flags it. Pure formatting fix; no logic change. --- exchange/apitoken.go | 1 - 1 file changed, 1 deletion(-) diff --git a/exchange/apitoken.go b/exchange/apitoken.go index fd3900e..f40b31d 100644 --- a/exchange/apitoken.go +++ b/exchange/apitoken.go @@ -107,4 +107,3 @@ func (e *APITokenExchanger) Exchange(ctx context.Context, req APITokenRequest) ( AccessToken: accessToken, }, nil } -