diff --git a/internal/storetest/context.go b/internal/storetest/context.go new file mode 100644 index 0000000..6cc4205 --- /dev/null +++ b/internal/storetest/context.go @@ -0,0 +1,253 @@ +package storetest + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/meigma/authkit" +) + +// runContextCancellationSuite verifies that every store method honors +// `ctx.Err()` and returns `context.Canceled` (or a wrapping error) when called +// with an already-cancelled context. It deliberately spans every domain so a +// new method that forgets the cancellation check shows up here. +// +//nolint:funlen // Cross-domain sweep over every store method; adding new methods is expected to lengthen this list. +func runContextCancellationSuite(t *testing.T, newStore func(t *testing.T) Store) { + t.Helper() + + t.Run("returns context error", func(t *testing.T) { + store := newStore(t) + principal := createPrincipal(t, store) + _, err := store.LinkIdentity(context.Background(), authkit.LinkIdentityRequest{ + Provider: testProvider, + Subject: testSubject, + PrincipalID: principal.ID, + }) + require.NoError(t, err) + token := tokenFixture(fixedStoreTime(), principal.ID) + require.NoError(t, store.CreateToken(context.Background(), token)) + registration := passkeyRegistration(principal.ID, "credential-1") + _, err = store.CreateRegistration(context.Background(), registration) + require.NoError(t, err) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + tests := []struct { + name string + run func() error + }{ + { + name: "create principal", + run: func() error { + _, runErr := store.CreatePrincipal(ctx, authkit.CreatePrincipalRequest{ + Kind: authkit.PrincipalKindUser, + }) + + return runErr + }, + }, + { + name: "find principal", + run: func() error { + _, runErr := store.FindPrincipal(ctx, principal.ID) + + return runErr + }, + }, + { + name: "list principals", + run: func() error { + _, runErr := store.ListPrincipals(ctx) + + return runErr + }, + }, + { + name: "unassign principal role", + run: func() error { + return store.UnassignPrincipalRole(ctx, authkit.UnassignPrincipalRoleRequest{ + PrincipalID: principal.ID, + RoleID: testRoleID, + }) + }, + }, + { + name: "list principal role assignments", + run: func() error { + _, runErr := store.ListPrincipalRoleAssignments(ctx, principal.ID) + + return runErr + }, + }, + { + name: "link identity", + run: func() error { + _, runErr := store.LinkIdentity(ctx, authkit.LinkIdentityRequest{ + Provider: "api-token", + Subject: "token-123", + PrincipalID: principal.ID, + }) + + return runErr + }, + }, + { + name: "resolve identity", + run: func() error { + _, runErr := store.ResolveIdentity(ctx, authkit.Identity{ + Provider: testProvider, + Subject: testSubject, + }) + + return runErr + }, + }, + { + name: "provision identity", + run: func() error { + _, runErr := store.ProvisionIdentity(ctx, provisionRequest()) + + return runErr + }, + }, + { + name: "create token", + run: func() error { + return store.CreateToken(ctx, tokenFixture(fixedStoreTime(), principal.ID)) + }, + }, + { + name: "find token", + run: func() error { + _, runErr := store.FindToken(ctx, token.ID) + + return runErr + }, + }, + { + name: "list principal token metadata", + run: func() error { + _, runErr := store.ListPrincipalTokenMetadata(ctx, principal.ID) + + return runErr + }, + }, + { + name: "update token last used", + run: func() error { + return store.UpdateTokenLastUsed(ctx, token.ID, fixedStoreTime()) + }, + }, + { + name: "revoke token", + run: func() error { + return store.RevokeToken(ctx, token.ID, fixedStoreTime()) + }, + }, + { + name: "trust provider", + run: func() error { + _, runErr := store.TrustProvider(ctx, providerFixture()) + + return runErr + }, + }, + { + name: "create provisioning rule", + run: func() error { + _, runErr := store.CreateProvisioningRule(ctx, provisioningRuleRequest()) + + return runErr + }, + }, + { + name: "update provisioning rule", + run: func() error { + _, runErr := store.UpdateProvisioningRule(ctx, authkit.UpdateProvisioningRuleRequest{ + ID: testProvisioningRuleID, + }) + + return runErr + }, + }, + { + name: "delete provisioning rule", + run: func() error { + return store.DeleteProvisioningRule(ctx, testProvisioningRuleID) + }, + }, + { + name: "find provisioning rule", + run: func() error { + _, runErr := store.FindProvisioningRule(ctx, testProvisioningRuleID) + + return runErr + }, + }, + { + name: "list provisioning rules", + run: func() error { + _, runErr := store.ListProvisioningRules(ctx) + + return runErr + }, + }, + { + name: "find provider", + run: func() error { + _, runErr := store.FindProvider(ctx, "https://issuer.example") + + return runErr + }, + }, + { + name: "find passkey user by principal", + run: func() error { + _, runErr := store.FindUserByPrincipal(ctx, registration.User.RPID, principal.ID) + + return runErr + }, + }, + { + name: "find passkey user by handle", + run: func() error { + _, runErr := store.FindUserByHandle(ctx, registration.User.RPID, registration.User.Handle) + + return runErr + }, + }, + { + name: "list passkey credentials", + run: func() error { + _, runErr := store.ListCredentials(ctx, registration.User.RPID, registration.User.Handle) + + return runErr + }, + }, + { + name: "create passkey registration", + run: func() error { + _, runErr := store.CreateRegistration(ctx, passkeyRegistration(principal.ID, "credential-2")) + + return runErr + }, + }, + { + name: "update passkey credential after login", + run: func() error { + return store.UpdateCredentialAfterLogin(ctx, registration.Credential) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.ErrorIs(t, tt.run(), context.Canceled) + }) + } + }) +} diff --git a/internal/storetest/fixtures.go b/internal/storetest/fixtures.go new file mode 100644 index 0000000..f9d03f0 --- /dev/null +++ b/internal/storetest/fixtures.go @@ -0,0 +1,188 @@ +package storetest + +import ( + "context" + "crypto/sha256" + "encoding/base64" + "testing" + "time" + + "github.com/go-webauthn/webauthn/webauthn" + "github.com/stretchr/testify/require" + + "github.com/meigma/authkit" + "github.com/meigma/authkit/apikey" + "github.com/meigma/authkit/oidc" + "github.com/meigma/authkit/passkey" +) + +// createPrincipal creates a user principal with the standard test display name +// and attributes. It fails the test if the store rejects the request. +func createPrincipal(t *testing.T, store Store) authkit.Principal { + t.Helper() + + principal, err := store.CreatePrincipal(context.Background(), authkit.CreatePrincipalRequest{ + Kind: authkit.PrincipalKindUser, + DisplayName: testDisplayName, + Attributes: map[string]any{ + "role": "operator", + }, + }) + require.NoError(t, err) + + return principal +} + +// createRole creates a role with the given ID and the standard fixture display +// name and description. +func createRole(t *testing.T, store Store, roleID string) authkit.Role { + t.Helper() + + role, err := store.CreateRole(context.Background(), authkit.CreateRoleRequest{ + ID: roleID, + DisplayName: "Notes reader", + Description: "Can read notes.", + }) + require.NoError(t, err) + + return role +} + +// trustProvider trusts provider through the store and returns the persisted copy. +func trustProvider(t *testing.T, store Store, provider oidc.Provider) oidc.Provider { + t.Helper() + + trusted, err := store.TrustProvider(context.Background(), provider) + require.NoError(t, err) + + return trusted +} + +// roleRequest returns the canonical CreateRoleRequest fixture. +func roleRequest() authkit.CreateRoleRequest { + return authkit.CreateRoleRequest{ + ID: testRoleID, + DisplayName: "Notes reader", + Description: "Can read notes.", + } +} + +// providerFixture returns the canonical trusted OIDC provider fixture. +func providerFixture() oidc.Provider { + return oidc.Provider{ + Issuer: "https://issuer.example", + Audiences: []string{"notes-api"}, + JWKSURL: "https://issuer.example/.well-known/jwks.json", + SupportedSigningAlgorithms: []string{"RS256"}, + ForwardedClaims: []authkit.ClaimPath{ + {"groups"}, + {"realm_access", "roles"}, + }, + } +} + +// provisioningRuleRequest returns the canonical CreateProvisioningRuleRequest fixture. +func provisioningRuleRequest() authkit.CreateProvisioningRuleRequest { + return authkit.CreateProvisioningRuleRequest{ + ID: testProvisioningRuleID, + DisplayName: "Engineering readers", + Provider: providerFixture().Issuer, + Condition: `hasAny(claims.groups, ["/engineering"])`, + AssignRoleIDs: []string{testRoleID}, + Enabled: true, + } +} + +// provisionRequest returns the canonical ProvisionIdentityRequest fixture. +func provisionRequest() authkit.ProvisionIdentityRequest { + return authkit.ProvisionIdentityRequest{ + Identity: authkit.Identity{ + Provider: testProvider, + Subject: testSubject, + }, + Principal: authkit.CreatePrincipalRequest{ + Kind: authkit.PrincipalKindUser, + DisplayName: testDisplayName, + Attributes: map[string]any{ + "email": "ada@example.test", + }, + }, + } +} + +// createToken stores a token in store and returns the persisted fixture. +func createToken(t *testing.T, store Store, now time.Time, principalID string) apikey.StoredToken { + t.Helper() + + token := tokenFixture(now, principalID) + require.NoError(t, store.CreateToken(context.Background(), token)) + + return token +} + +// tokenFixture returns the canonical StoredToken fixture bound to principalID. +func tokenFixture(now time.Time, principalID string) apikey.StoredToken { + return apikey.StoredToken{ + ID: "token_1", + PrincipalID: principalID, + Name: "deploy", + SecretHash: sha256.Sum256([]byte("secret")), + ExpiresAt: now.Add(time.Hour), + } +} + +// passkeyRegistration returns a full Registration fixture for principalID with +// the supplied credential identifier. Credential ID and WebAuthn ID are both +// derived from credentialID so callers can produce multiple distinct +// credentials by varying that one string. +func passkeyRegistration(principalID string, credentialID string) passkey.Registration { + userHandle := []byte("passkey-user-handle-1") + credentialIDBytes := []byte(credentialID) + user := passkey.User{ + RPID: "example.test", + PrincipalID: principalID, + Handle: userHandle, + Name: "ada@example.test", + DisplayName: testDisplayName, + } + + return passkey.Registration{ + User: user, + Credential: passkey.Credential{ + RPID: user.RPID, + PrincipalID: user.PrincipalID, + UserHandle: append([]byte(nil), userHandle...), + CredentialID: credentialIDBytes, + WebAuthn: webauthn.Credential{ + ID: append([]byte(nil), credentialIDBytes...), + PublicKey: []byte("public-key-" + credentialID), + Authenticator: webauthn.Authenticator{ + AAGUID: []byte("authenticator-aaguid"), + SignCount: 1, + }, + }, + }, + Identity: authkit.Identity{ + Provider: "passkey:" + user.RPID, + Subject: passkeyUserSubject(userHandle), + CredentialID: passkeyCredentialSubject(credentialIDBytes), + }, + } +} + +// passkeyUserSubject returns the base64url-encoded user handle that authkit +// uses as the `Identity.Subject` for passkey-derived identities. +func passkeyUserSubject(handle []byte) string { + return base64.RawURLEncoding.EncodeToString(handle) +} + +// passkeyCredentialSubject returns the base64url-encoded credential ID that +// authkit uses as the `Identity.CredentialID` for passkey-derived identities. +func passkeyCredentialSubject(credentialID []byte) string { + return base64.RawURLEncoding.EncodeToString(credentialID) +} + +// fixedStoreTime returns the deterministic timestamp used by suite fixtures. +func fixedStoreTime() time.Time { + return time.Date(2026, time.May, 7, 18, 0, 0, 0, time.UTC) +} diff --git a/internal/storetest/identity.go b/internal/storetest/identity.go new file mode 100644 index 0000000..e3696c5 --- /dev/null +++ b/internal/storetest/identity.go @@ -0,0 +1,424 @@ +package storetest + +import ( + "context" + "sync" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/meigma/authkit" +) + +// runIdentitySuite exercises LinkIdentity, ResolveIdentity, and the +// ProvisionIdentity create-or-attach lifecycle, including atomic role +// assignment and idempotency under concurrent provisioning attempts. +// +//nolint:funlen,gocognit // Domain sub-suite groups related t.Run blocks so the full identity contract reads top-to-bottom in one file. +func runIdentitySuite(t *testing.T, newStore func(t *testing.T) Store) { + t.Helper() + + t.Run("principal attributes are copied", func(t *testing.T) { + store := newStore(t) + attrs := map[string]any{ + "role": "operator", + } + + principal, err := store.CreatePrincipal(context.Background(), authkit.CreatePrincipalRequest{ + Kind: authkit.PrincipalKindUser, + DisplayName: testDisplayName, + Attributes: attrs, + }) + require.NoError(t, err) + + attrs["role"] = "changed before resolve" + principal.Attributes["role"] = "changed from returned principal" + + _, err = store.LinkIdentity(context.Background(), authkit.LinkIdentityRequest{ + Provider: testProvider, + Subject: testSubject, + PrincipalID: principal.ID, + }) + require.NoError(t, err) + + resolved, err := store.ResolveIdentity(context.Background(), authkit.Identity{ + Provider: testProvider, + Subject: testSubject, + }) + require.NoError(t, err) + require.NotNil(t, resolved) + assert.Equal(t, "operator", resolved.Attributes["role"]) + + resolved.Attributes["role"] = "changed from resolved principal" + resolvedAgain, err := store.ResolveIdentity(context.Background(), authkit.Identity{ + Provider: testProvider, + Subject: testSubject, + }) + require.NoError(t, err) + require.NotNil(t, resolvedAgain) + assert.Equal(t, "operator", resolvedAgain.Attributes["role"]) + }) + + t.Run("link identity", func(t *testing.T) { + store := newStore(t) + first := createPrincipal(t, store) + second := createPrincipal(t, store) + + link, err := store.LinkIdentity(context.Background(), authkit.LinkIdentityRequest{ + Provider: testProvider, + Subject: testSubject, + PrincipalID: first.ID, + }) + require.NoError(t, err) + assert.Equal(t, authkit.ExternalIdentity{ + Provider: testProvider, + Subject: testSubject, + PrincipalID: first.ID, + }, link) + + relinked, err := store.LinkIdentity(context.Background(), authkit.LinkIdentityRequest{ + Provider: testProvider, + Subject: testSubject, + PrincipalID: first.ID, + }) + require.NoError(t, err) + assert.Equal(t, link, relinked) + + conflicted, err := store.LinkIdentity(context.Background(), authkit.LinkIdentityRequest{ + Provider: testProvider, + Subject: testSubject, + PrincipalID: second.ID, + }) + require.Error(t, err) + assert.Empty(t, conflicted) + }) + + t.Run("link identity validates request", func(t *testing.T) { + store := newStore(t) + principal := createPrincipal(t, store) + + tests := []struct { + name string + req authkit.LinkIdentityRequest + }{ + { + name: "missing provider", + req: authkit.LinkIdentityRequest{ + Subject: testSubject, + PrincipalID: principal.ID, + }, + }, + { + name: "missing subject", + req: authkit.LinkIdentityRequest{ + Provider: testProvider, + PrincipalID: principal.ID, + }, + }, + { + name: "missing principal ID", + req: authkit.LinkIdentityRequest{ + Provider: testProvider, + Subject: testSubject, + }, + }, + { + name: "missing principal", + req: authkit.LinkIdentityRequest{ + Provider: testProvider, + Subject: testSubject, + PrincipalID: "missing", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + link, err := store.LinkIdentity(context.Background(), tt.req) + + require.Error(t, err) + assert.Empty(t, link) + }) + } + }) + + t.Run("resolve identity", func(t *testing.T) { + store := newStore(t) + principal := createPrincipal(t, store) + + _, err := store.LinkIdentity(context.Background(), authkit.LinkIdentityRequest{ + Provider: testProvider, + Subject: testSubject, + PrincipalID: principal.ID, + }) + require.NoError(t, err) + + resolved, err := store.ResolveIdentity(context.Background(), authkit.Identity{ + Provider: testProvider, + Subject: testSubject, + CredentialID: "id-123", + Claims: map[string]any{ + "ignored": true, + }, + }) + + require.NoError(t, err) + require.NotNil(t, resolved) + assert.Equal(t, principal, *resolved) + }) + + t.Run("resolve identity returns unresolved identity", func(t *testing.T) { + store := newStore(t) + + tests := []struct { + name string + identity authkit.Identity + }{ + { + name: "missing provider", + identity: authkit.Identity{ + Subject: testSubject, + }, + }, + { + name: "missing subject", + identity: authkit.Identity{ + Provider: testProvider, + }, + }, + { + name: "unlinked identity", + identity: authkit.Identity{ + Provider: testProvider, + Subject: testSubject, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + resolved, err := store.ResolveIdentity(context.Background(), tt.identity) + + require.ErrorIs(t, err, authkit.ErrUnresolvedIdentity) + assert.Nil(t, resolved) + }) + } + }) + + t.Run("provision identity creates principal and link", func(t *testing.T) { + store := newStore(t) + req := provisionRequest() + wantAttributes := map[string]any{ + "email": "ada@example.test", + } + + result, err := store.ProvisionIdentity(context.Background(), req) + require.NoError(t, err) + assert.True(t, result.Created) + assert.NotEmpty(t, result.Principal.ID) + assert.Equal(t, authkit.PrincipalKindUser, result.Principal.Kind) + assert.Equal(t, testDisplayName, result.Principal.DisplayName) + assert.Equal(t, wantAttributes, result.Principal.Attributes) + assert.Equal(t, authkit.ExternalIdentity{ + Provider: testProvider, + Subject: testSubject, + PrincipalID: result.Principal.ID, + }, result.Link) + + req.Principal.Attributes["email"] = "changed before resolve" + result.Principal.Attributes["email"] = "changed from returned principal" + + resolved, err := store.ResolveIdentity(context.Background(), req.Identity) + require.NoError(t, err) + require.NotNil(t, resolved) + assert.Equal(t, wantAttributes, resolved.Attributes) + assert.Equal(t, result.Principal.ID, resolved.ID) + }) + + t.Run("provision identity assigns initial roles", func(t *testing.T) { + store := newStore(t) + createRole(t, store, testRoleID) + require.NoError(t, store.GrantRoleAction(context.Background(), authkit.GrantRoleActionRequest{ + RoleID: testRoleID, + Action: testAction, + })) + req := provisionRequest() + req.InitialRoleIDs = []string{testRoleID} + + result, err := store.ProvisionIdentity(context.Background(), req) + require.NoError(t, err) + require.True(t, result.Created) + + actions, err := store.ResolvePrincipalActions(context.Background(), result.Principal.ID) + require.NoError(t, err) + assert.Equal(t, []string{testAction}, actions) + }) + + t.Run("provision identity fails when initial role is missing", func(t *testing.T) { + store := newStore(t) + req := provisionRequest() + req.InitialRoleIDs = []string{"missing"} + + result, err := store.ProvisionIdentity(context.Background(), req) + require.Error(t, err) + assert.Empty(t, result) + + resolved, err := store.ResolveIdentity(context.Background(), req.Identity) + require.ErrorIs(t, err, authkit.ErrUnresolvedIdentity) + assert.Nil(t, resolved) + }) + + t.Run("provision identity does not assign roles to existing links", func(t *testing.T) { + store := newStore(t) + first, err := store.ProvisionIdentity(context.Background(), provisionRequest()) + require.NoError(t, err) + require.True(t, first.Created) + createRole(t, store, testRoleID) + require.NoError(t, store.GrantRoleAction(context.Background(), authkit.GrantRoleActionRequest{ + RoleID: testRoleID, + Action: testAction, + })) + req := provisionRequest() + req.InitialRoleIDs = []string{testRoleID} + + second, err := store.ProvisionIdentity(context.Background(), req) + require.NoError(t, err) + assert.False(t, second.Created) + + actions, err := store.ResolvePrincipalActions(context.Background(), first.Principal.ID) + require.NoError(t, err) + assert.Nil(t, actions) + }) + + t.Run("provision identity returns existing link without updating principal", func(t *testing.T) { + store := newStore(t) + first, err := store.ProvisionIdentity(context.Background(), provisionRequest()) + require.NoError(t, err) + require.True(t, first.Created) + + secondReq := provisionRequest() + secondReq.Principal.DisplayName = "Changed Name" + secondReq.Principal.Attributes = map[string]any{ + "email": "changed@example.test", + } + second, err := store.ProvisionIdentity(context.Background(), secondReq) + + require.NoError(t, err) + assert.False(t, second.Created) + assert.Equal(t, first.Link, second.Link) + assert.Equal(t, first.Principal, second.Principal) + assert.Equal(t, testDisplayName, second.Principal.DisplayName) + assert.Equal(t, "ada@example.test", second.Principal.Attributes["email"]) + }) + + t.Run("provision identity validates request", func(t *testing.T) { + store := newStore(t) + + tests := []struct { + name string + req authkit.ProvisionIdentityRequest + assertErr func(t *testing.T, err error) + }{ + { + name: "missing provider", + req: authkit.ProvisionIdentityRequest{ + Identity: authkit.Identity{Subject: testSubject}, + Principal: authkit.CreatePrincipalRequest{ + Kind: authkit.PrincipalKindUser, + }, + }, + assertErr: func(t *testing.T, err error) { + require.ErrorIs(t, err, authkit.ErrUnresolvedIdentity) + }, + }, + { + name: "missing subject", + req: authkit.ProvisionIdentityRequest{ + Identity: authkit.Identity{Provider: testProvider}, + Principal: authkit.CreatePrincipalRequest{ + Kind: authkit.PrincipalKindUser, + }, + }, + assertErr: func(t *testing.T, err error) { + require.ErrorIs(t, err, authkit.ErrUnresolvedIdentity) + }, + }, + { + name: "invalid principal kind", + req: authkit.ProvisionIdentityRequest{ + Identity: authkit.Identity{ + Provider: testProvider, + Subject: testSubject, + }, + Principal: authkit.CreatePrincipalRequest{ + Kind: authkit.PrincipalKind("team"), + }, + }, + assertErr: func(t *testing.T, err error) { + require.Error(t, err) + require.NotErrorIs(t, err, authkit.ErrUnresolvedIdentity) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := store.ProvisionIdentity(context.Background(), tt.req) + + tt.assertErr(t, err) + assert.Empty(t, result) + }) + } + }) + + t.Run("provision identity is idempotent under concurrency", func(t *testing.T) { + store := newStore(t) + ctx := context.Background() + start := make(chan struct{}) + results := make(chan authkit.ProvisionIdentityResult, concurrentProvisionAttempts) + errs := make(chan error, concurrentProvisionAttempts) + var wg sync.WaitGroup + + for range cap(results) { + wg.Go(func() { + <-start + result, err := store.ProvisionIdentity(ctx, provisionRequest()) + if err != nil { + errs <- err + + return + } + results <- result + }) + } + + close(start) + wg.Wait() + close(results) + close(errs) + + for err := range errs { + require.NoError(t, err) + } + + created := 0 + principalID := "" + for result := range results { + if result.Created { + created++ + } + if principalID == "" { + principalID = result.Principal.ID + } + assert.Equal(t, principalID, result.Principal.ID) + assert.Equal(t, principalID, result.Link.PrincipalID) + } + assert.Equal(t, 1, created) + + resolved, err := store.ResolveIdentity(ctx, provisionRequest().Identity) + require.NoError(t, err) + require.NotNil(t, resolved) + assert.Equal(t, principalID, resolved.ID) + }) +} diff --git a/internal/storetest/oidc.go b/internal/storetest/oidc.go new file mode 100644 index 0000000..697531a --- /dev/null +++ b/internal/storetest/oidc.go @@ -0,0 +1,91 @@ +package storetest + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/meigma/authkit" + "github.com/meigma/authkit/oidc" +) + +// runOIDCSuite exercises trusted OIDC provider storage including upsert, +// missing-issuer lookups, configuration validation, and defensive copying of +// nested audience and claim-path slices. +func runOIDCSuite(t *testing.T, newStore func(t *testing.T) Store) { + t.Helper() + + t.Run("trusted providers", func(t *testing.T) { + store := newStore(t) + provider := providerFixture() + want := providerFixture() + + trusted, err := store.TrustProvider(context.Background(), provider) + require.NoError(t, err) + assert.Equal(t, want, trusted) + + provider.Audiences[0] = "changed before find" + provider.ForwardedClaims[0][0] = "changed-before-find" + trusted.Audiences[0] = "changed from returned provider" + trusted.SupportedSigningAlgorithms[0] = "ES256" + trusted.ForwardedClaims[0][0] = "changed-from-returned-provider" + + found, err := store.FindProvider(context.Background(), want.Issuer) + require.NoError(t, err) + assert.Equal(t, want, found) + + found.Audiences[0] = "changed from found provider" + found.SupportedSigningAlgorithms[0] = "ES256" + found.ForwardedClaims[0][0] = "changed-from-found-provider" + foundAgain, err := store.FindProvider(context.Background(), want.Issuer) + require.NoError(t, err) + assert.Equal(t, want, foundAgain) + }) + + t.Run("trusted providers can be updated", func(t *testing.T) { + store := newStore(t) + provider := providerFixture() + _, err := store.TrustProvider(context.Background(), provider) + require.NoError(t, err) + + updated := oidc.Provider{ + Issuer: provider.Issuer, + Audiences: []string{"updated-api"}, + JWKSURL: "https://issuer.example/updated-jwks.json", + SupportedSigningAlgorithms: []string{"RS512"}, + ForwardedClaims: []authkit.ClaimPath{{"email"}}, + } + trusted, err := store.TrustProvider(context.Background(), updated) + require.NoError(t, err) + assert.Equal(t, updated, trusted) + + found, err := store.FindProvider(context.Background(), provider.Issuer) + require.NoError(t, err) + assert.Equal(t, updated, found) + }) + + t.Run("trusted providers missing behavior", func(t *testing.T) { + store := newStore(t) + + found, err := store.FindProvider(context.Background(), "https://issuer.example") + + require.ErrorIs(t, err, oidc.ErrProviderNotFound) + assert.Empty(t, found) + }) + + t.Run("trusted providers reject invalid configuration", func(t *testing.T) { + store := newStore(t) + invalid := providerFixture() + invalid.Audiences = nil + + trusted, err := store.TrustProvider(context.Background(), invalid) + require.Error(t, err) + assert.Empty(t, trusted) + + found, err := store.FindProvider(context.Background(), invalid.Issuer) + require.ErrorIs(t, err, oidc.ErrProviderNotFound) + assert.Empty(t, found) + }) +} diff --git a/internal/storetest/passkey.go b/internal/storetest/passkey.go new file mode 100644 index 0000000..540d1e8 --- /dev/null +++ b/internal/storetest/passkey.go @@ -0,0 +1,254 @@ +package storetest + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/meigma/authkit" + "github.com/meigma/authkit/passkey" +) + +// runPasskeySuite exercises passkey user and credential storage including +// atomic registration with identity link, duplicate and conflict detection, +// login metadata updates, and defensive copying of byte-slice fields. +// +//nolint:funlen // Domain sub-suite groups related t.Run blocks so the full passkey contract reads top-to-bottom in one file. +func runPasskeySuite(t *testing.T, newStore func(t *testing.T) Store) { + t.Helper() + + t.Run("passkey registration creates user credential and link", func(t *testing.T) { + store := newStore(t) + principal := createPrincipal(t, store) + registration := passkeyRegistration(principal.ID, "credential-1") + + result, err := store.CreateRegistration(context.Background(), registration) + require.NoError(t, err) + assert.Equal(t, registration.User, result.User) + assert.Equal(t, registration.Credential, result.Credential) + assert.Equal(t, authkit.ExternalIdentity{ + Provider: registration.Identity.Provider, + Subject: registration.Identity.Subject, + PrincipalID: principal.ID, + }, result.Link) + + foundByPrincipal, err := store.FindUserByPrincipal( + context.Background(), + registration.User.RPID, + principal.ID, + ) + require.NoError(t, err) + assert.Equal(t, registration.User, foundByPrincipal) + + foundByHandle, err := store.FindUserByHandle( + context.Background(), + registration.User.RPID, + registration.User.Handle, + ) + require.NoError(t, err) + assert.Equal(t, registration.User, foundByHandle) + + credentials, err := store.ListCredentials( + context.Background(), + registration.User.RPID, + registration.User.Handle, + ) + require.NoError(t, err) + require.Len(t, credentials, 1) + assert.Equal(t, registration.Credential, credentials[0]) + + resolved, err := store.ResolveIdentity(context.Background(), registration.Identity) + require.NoError(t, err) + require.NotNil(t, resolved) + assert.Equal(t, principal.ID, resolved.ID) + }) + + t.Run("passkey missing user behavior", func(t *testing.T) { + store := newStore(t) + + foundByPrincipal, err := store.FindUserByPrincipal(context.Background(), "example.test", "missing") + require.ErrorIs(t, err, passkey.ErrUserNotFound) + assert.Empty(t, foundByPrincipal) + + foundByHandle, err := store.FindUserByHandle(context.Background(), "example.test", []byte("missing")) + require.ErrorIs(t, err, passkey.ErrUserNotFound) + assert.Empty(t, foundByHandle) + + credentials, err := store.ListCredentials(context.Background(), "example.test", []byte("missing")) + require.NoError(t, err) + assert.Empty(t, credentials) + }) + + t.Run("passkey registration allows additional credential for same user", func(t *testing.T) { + store := newStore(t) + principal := createPrincipal(t, store) + first := passkeyRegistration(principal.ID, "credential-1") + second := passkeyRegistration(principal.ID, "credential-2") + second.Credential.CredentialID = []byte("credential-2") + second.Credential.WebAuthn.ID = []byte("credential-2") + second.Identity.CredentialID = passkeyCredentialSubject([]byte("credential-2")) + + _, err := store.CreateRegistration(context.Background(), first) + require.NoError(t, err) + result, err := store.CreateRegistration(context.Background(), second) + require.NoError(t, err) + assert.Equal(t, first.Identity.Provider, result.Link.Provider) + assert.Equal(t, first.Identity.Subject, result.Link.Subject) + assert.Equal(t, principal.ID, result.Link.PrincipalID) + + credentials, err := store.ListCredentials( + context.Background(), + first.User.RPID, + first.User.Handle, + ) + require.NoError(t, err) + require.Len(t, credentials, passkeyCredentialCount) + assert.Equal(t, []byte("credential-1"), credentials[0].CredentialID) + assert.Equal(t, []byte("credential-2"), credentials[1].CredentialID) + }) + + t.Run("passkey registration rejects duplicate credential", func(t *testing.T) { + store := newStore(t) + principal := createPrincipal(t, store) + registration := passkeyRegistration(principal.ID, "credential-1") + + _, err := store.CreateRegistration(context.Background(), registration) + require.NoError(t, err) + result, err := store.CreateRegistration(context.Background(), registration) + + require.ErrorIs(t, err, passkey.ErrCredentialExists) + assert.Empty(t, result) + }) + + t.Run("passkey registration rejects conflicting users", func(t *testing.T) { + tests := []struct { + name string + mutate func(passkey.Registration, string) passkey.Registration + }{ + { + name: "same principal different handle", + mutate: func(registration passkey.Registration, _ string) passkey.Registration { + registration.User.Handle = []byte("other-handle") + registration.Credential.UserHandle = []byte("other-handle") + registration.Identity.Subject = passkeyUserSubject(registration.User.Handle) + return registration + }, + }, + { + name: "same handle different principal", + mutate: func(registration passkey.Registration, otherPrincipalID string) passkey.Registration { + registration.User.PrincipalID = otherPrincipalID + registration.Credential.PrincipalID = otherPrincipalID + return registration + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + store := newStore(t) + principal := createPrincipal(t, store) + other := createPrincipal(t, store) + registration := passkeyRegistration(principal.ID, "credential-1") + _, err := store.CreateRegistration(context.Background(), registration) + require.NoError(t, err) + + conflict := tt.mutate(passkeyRegistration(principal.ID, "credential-2"), other.ID) + result, err := store.CreateRegistration(context.Background(), conflict) + + require.ErrorIs(t, err, passkey.ErrUserExists) + assert.Empty(t, result) + }) + } + }) + + t.Run("passkey registration rolls back on identity link conflict", func(t *testing.T) { + store := newStore(t) + principal := createPrincipal(t, store) + other := createPrincipal(t, store) + registration := passkeyRegistration(principal.ID, "credential-1") + _, err := store.LinkIdentity(context.Background(), authkit.LinkIdentityRequest{ + Provider: registration.Identity.Provider, + Subject: registration.Identity.Subject, + PrincipalID: other.ID, + }) + require.NoError(t, err) + + result, err := store.CreateRegistration(context.Background(), registration) + require.Error(t, err) + require.NotErrorIs(t, err, passkey.ErrUserExists) + require.NotErrorIs(t, err, passkey.ErrCredentialExists) + assert.Empty(t, result) + + found, err := store.FindUserByPrincipal(context.Background(), registration.User.RPID, principal.ID) + require.ErrorIs(t, err, passkey.ErrUserNotFound) + assert.Empty(t, found) + credentials, err := store.ListCredentials( + context.Background(), + registration.User.RPID, + registration.User.Handle, + ) + require.NoError(t, err) + assert.Empty(t, credentials) + }) + + t.Run("passkey credential update persists login metadata", func(t *testing.T) { + store := newStore(t) + principal := createPrincipal(t, store) + registration := passkeyRegistration(principal.ID, "credential-1") + _, err := store.CreateRegistration(context.Background(), registration) + require.NoError(t, err) + + updated := registration.Credential + updated.WebAuthn.PublicKey = []byte("updated-public-key") + updated.WebAuthn.Authenticator.SignCount = passkeyUpdatedSignCount + require.NoError(t, store.UpdateCredentialAfterLogin(context.Background(), updated)) + + credentials, err := store.ListCredentials( + context.Background(), + registration.User.RPID, + registration.User.Handle, + ) + require.NoError(t, err) + require.Len(t, credentials, 1) + assert.Equal(t, []byte("updated-public-key"), credentials[0].WebAuthn.PublicKey) + assert.Equal(t, uint32(passkeyUpdatedSignCount), credentials[0].WebAuthn.Authenticator.SignCount) + }) + + t.Run("passkey values are copied", func(t *testing.T) { + store := newStore(t) + principal := createPrincipal(t, store) + registration := passkeyRegistration(principal.ID, "credential-1") + wantUserHandle := append([]byte(nil), registration.User.Handle...) + wantPublicKey := append([]byte(nil), registration.Credential.WebAuthn.PublicKey...) + + result, err := store.CreateRegistration(context.Background(), registration) + require.NoError(t, err) + registration.User.Handle[0] = 'x' + registration.Credential.WebAuthn.PublicKey[0] = 'x' + result.User.Handle[0] = 'y' + result.Credential.WebAuthn.PublicKey[0] = 'y' + + found, err := store.FindUserByPrincipal(context.Background(), "example.test", principal.ID) + require.NoError(t, err) + assert.Equal(t, wantUserHandle, found.Handle) + found.Handle[0] = 'z' + + foundAgain, err := store.FindUserByPrincipal(context.Background(), "example.test", principal.ID) + require.NoError(t, err) + assert.Equal(t, wantUserHandle, foundAgain.Handle) + + credentials, err := store.ListCredentials(context.Background(), "example.test", wantUserHandle) + require.NoError(t, err) + require.Len(t, credentials, 1) + assert.Equal(t, wantPublicKey, credentials[0].WebAuthn.PublicKey) + credentials[0].WebAuthn.PublicKey[0] = 'z' + + credentialsAgain, err := store.ListCredentials(context.Background(), "example.test", wantUserHandle) + require.NoError(t, err) + require.Len(t, credentialsAgain, 1) + assert.Equal(t, wantPublicKey, credentialsAgain[0].WebAuthn.PublicKey) + }) +} diff --git a/internal/storetest/principals.go b/internal/storetest/principals.go new file mode 100644 index 0000000..66908c3 --- /dev/null +++ b/internal/storetest/principals.go @@ -0,0 +1,112 @@ +package storetest + +import ( + "context" + "sort" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/meigma/authkit" +) + +// runPrincipalSuite exercises principal create, find, and list behaviors, +// including defensive copying of attributes across the store boundary. +func runPrincipalSuite(t *testing.T, newStore func(t *testing.T) Store) { + t.Helper() + + t.Run("create principal", func(t *testing.T) { + store := newStore(t) + + tests := []struct { + name string + kind authkit.PrincipalKind + }{ + {name: "creates user principal", kind: authkit.PrincipalKindUser}, + {name: "creates service principal", kind: authkit.PrincipalKindService}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + principal, err := store.CreatePrincipal(context.Background(), authkit.CreatePrincipalRequest{ + Kind: tt.kind, + DisplayName: testDisplayName, + }) + + require.NoError(t, err) + assert.NotEmpty(t, principal.ID) + assert.Contains(t, principal.ID, "principal_") + assert.Equal(t, tt.kind, principal.Kind) + assert.Equal(t, testDisplayName, principal.DisplayName) + assert.Nil(t, principal.Attributes) + }) + } + }) + + t.Run("create principal rejects invalid kind", func(t *testing.T) { + store := newStore(t) + + principal, err := store.CreatePrincipal(context.Background(), authkit.CreatePrincipalRequest{ + Kind: authkit.PrincipalKind("team"), + DisplayName: testDisplayName, + }) + + require.Error(t, err) + assert.Empty(t, principal) + }) + + t.Run("find and list principals", func(t *testing.T) { + store := newStore(t) + first, err := store.CreatePrincipal(context.Background(), authkit.CreatePrincipalRequest{ + Kind: authkit.PrincipalKindUser, + DisplayName: testDisplayName, + Attributes: map[string]any{ + "team": "platform", + }, + }) + require.NoError(t, err) + second, err := store.CreatePrincipal(context.Background(), authkit.CreatePrincipalRequest{ + Kind: authkit.PrincipalKindService, + DisplayName: "Deploy service", + }) + require.NoError(t, err) + + found, err := store.FindPrincipal(context.Background(), first.ID) + require.NoError(t, err) + assert.Equal(t, first, found) + + first.Attributes["team"] = "changed" + found.Attributes["team"] = "changed from found" + + foundAgain, err := store.FindPrincipal(context.Background(), first.ID) + require.NoError(t, err) + assert.Equal(t, "platform", foundAgain.Attributes["team"]) + + principals, err := store.ListPrincipals(context.Background()) + require.NoError(t, err) + want := []authkit.Principal{foundAgain, second} + sort.Slice(want, func(i, j int) bool { + return want[i].ID < want[j].ID + }) + assert.Equal(t, want, principals) + + for i := range principals { + if principals[i].ID == first.ID { + principals[i].Attributes["team"] = "changed from list" + } + } + foundAfterListMutation, err := store.FindPrincipal(context.Background(), first.ID) + require.NoError(t, err) + assert.Equal(t, "platform", foundAfterListMutation.Attributes["team"]) + }) + + t.Run("find principal missing behavior", func(t *testing.T) { + store := newStore(t) + + found, err := store.FindPrincipal(context.Background(), "missing") + + require.ErrorIs(t, err, authkit.ErrPrincipalNotFound) + assert.Empty(t, found) + }) +} diff --git a/internal/storetest/provisioning.go b/internal/storetest/provisioning.go new file mode 100644 index 0000000..2914d96 --- /dev/null +++ b/internal/storetest/provisioning.go @@ -0,0 +1,163 @@ +package storetest + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/meigma/authkit" +) + +// runProvisioningRuleSuite exercises provisioning rule create, update, delete, +// find, list, defensive copying, and configuration validation. +// +//nolint:funlen // Domain sub-suite groups related t.Run blocks so the full provisioning-rule contract reads top-to-bottom in one file. +func runProvisioningRuleSuite(t *testing.T, newStore func(t *testing.T) Store) { + t.Helper() + + t.Run("provisioning rules", func(t *testing.T) { + store := newStore(t) + createRole(t, store, testRoleID) + trustProvider(t, store, providerFixture()) + req := provisioningRuleRequest() + + rule, err := store.CreateProvisioningRule(context.Background(), req) + require.NoError(t, err) + assert.Equal(t, authkit.ProvisioningRule(req), rule) + + req.AssignRoleIDs[0] = "changed" + rule.AssignRoleIDs[0] = "changed-from-returned" + + found, err := store.FindProvisioningRule(context.Background(), testProvisioningRuleID) + require.NoError(t, err) + assert.Equal(t, `hasAny(claims.groups, ["/engineering"])`, found.Condition) + assert.Equal(t, []string{testRoleID}, found.AssignRoleIDs) + + listed, err := store.ListProvisioningRules(context.Background()) + require.NoError(t, err) + assert.Equal(t, []authkit.ProvisioningRule{found}, listed) + + found.Condition = "false" + listed[0].AssignRoleIDs[0] = "changed-from-list" + foundAgain, err := store.FindProvisioningRule(context.Background(), testProvisioningRuleID) + require.NoError(t, err) + assert.Equal(t, `hasAny(claims.groups, ["/engineering"])`, foundAgain.Condition) + assert.Equal(t, []string{testRoleID}, foundAgain.AssignRoleIDs) + }) + + t.Run("provisioning rules can be updated and deleted", func(t *testing.T) { + store := newStore(t) + createRole(t, store, testRoleID) + createRole(t, store, "notes-writer") + trustProvider(t, store, providerFixture()) + _, err := store.CreateProvisioningRule(context.Background(), provisioningRuleRequest()) + require.NoError(t, err) + + updated := authkit.UpdateProvisioningRuleRequest{ + ID: testProvisioningRuleID, + DisplayName: "Platform writers", + Provider: providerFixture().Issuer, + Condition: `hasAny(claims.realm_access.roles, ["writer"])`, + AssignRoleIDs: []string{"notes-writer"}, + Enabled: false, + } + rule, err := store.UpdateProvisioningRule(context.Background(), updated) + require.NoError(t, err) + assert.Equal(t, authkit.ProvisioningRule(updated), rule) + + require.NoError(t, store.DeleteProvisioningRule(context.Background(), testProvisioningRuleID)) + _, err = store.FindProvisioningRule(context.Background(), testProvisioningRuleID) + require.ErrorIs(t, err, authkit.ErrProvisioningRuleNotFound) + require.ErrorIs( + t, + store.DeleteProvisioningRule(context.Background(), testProvisioningRuleID), + authkit.ErrProvisioningRuleNotFound, + ) + + _, err = store.UpdateProvisioningRule(context.Background(), authkit.UpdateProvisioningRuleRequest{ + ID: testProvisioningRuleID, + Provider: "https://untrusted.example", + Condition: `claims.missing == "missing"`, + AssignRoleIDs: []string{"missing"}, + Enabled: true, + }) + require.ErrorIs(t, err, authkit.ErrProvisioningRuleNotFound) + }) + + t.Run("provisioning rules validate configuration", func(t *testing.T) { + store := newStore(t) + createRole(t, store, testRoleID) + trustProvider(t, store, providerFixture()) + + tests := []struct { + name string + req authkit.CreateProvisioningRuleRequest + }{ + { + name: "missing ID", + req: func() authkit.CreateProvisioningRuleRequest { + req := provisioningRuleRequest() + req.ID = "" + + return req + }(), + }, + { + name: "untrusted provider", + req: func() authkit.CreateProvisioningRuleRequest { + req := provisioningRuleRequest() + req.Provider = "https://untrusted.example" + + return req + }(), + }, + { + name: "missing condition", + req: func() authkit.CreateProvisioningRuleRequest { + req := provisioningRuleRequest() + req.Condition = "" + + return req + }(), + }, + { + name: "syntax error", + req: func() authkit.CreateProvisioningRuleRequest { + req := provisioningRuleRequest() + req.Condition = "claims.groups ==" + + return req + }(), + }, + { + name: "non-bool condition", + req: func() authkit.CreateProvisioningRuleRequest { + req := provisioningRuleRequest() + req.Condition = "identity.subject" + + return req + }(), + }, + { + name: "missing role", + req: func() authkit.CreateProvisioningRuleRequest { + req := provisioningRuleRequest() + req.AssignRoleIDs = []string{"missing"} + + return req + }(), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rule, err := store.CreateProvisioningRule(context.Background(), tt.req) + + require.Error(t, err) + assert.Empty(t, rule) + }) + } + }) +} diff --git a/internal/storetest/roles.go b/internal/storetest/roles.go new file mode 100644 index 0000000..5036968 --- /dev/null +++ b/internal/storetest/roles.go @@ -0,0 +1,293 @@ +package storetest + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/meigma/authkit" +) + +// runRoleSuite exercises role creation, role-action grants, principal-role +// assignment lifecycle, and effective action resolution. +// +//nolint:funlen // Domain sub-suite groups related t.Run blocks so the full role contract reads top-to-bottom in one file. +func runRoleSuite(t *testing.T, newStore func(t *testing.T) Store) { + t.Helper() + + t.Run("create role", func(t *testing.T) { + store := newStore(t) + req := roleRequest() + + role, err := store.CreateRole(context.Background(), req) + + require.NoError(t, err) + assert.Equal(t, authkit.Role(req), role) + }) + + t.Run("create role validates request", func(t *testing.T) { + store := newStore(t) + + role, err := store.CreateRole(context.Background(), authkit.CreateRoleRequest{}) + + require.Error(t, err) + assert.Empty(t, role) + }) + + t.Run("create role rejects duplicate ID", func(t *testing.T) { + store := newStore(t) + _, err := store.CreateRole(context.Background(), roleRequest()) + require.NoError(t, err) + + role, err := store.CreateRole(context.Background(), roleRequest()) + + require.Error(t, err) + assert.Empty(t, role) + }) + + t.Run("grant role action is idempotent", func(t *testing.T) { + store := newStore(t) + principal := createPrincipal(t, store) + createRole(t, store, testRoleID) + + req := authkit.GrantRoleActionRequest{ + RoleID: testRoleID, + Action: testAction, + } + require.NoError(t, store.GrantRoleAction(context.Background(), req)) + require.NoError(t, store.GrantRoleAction(context.Background(), req)) + require.NoError(t, store.AssignPrincipalRole(context.Background(), authkit.AssignPrincipalRoleRequest{ + PrincipalID: principal.ID, + RoleID: testRoleID, + })) + + actions, err := store.ResolvePrincipalActions(context.Background(), principal.ID) + + require.NoError(t, err) + assert.Equal(t, []string{testAction}, actions) + }) + + t.Run("grant role action validates request", func(t *testing.T) { + store := newStore(t) + createRole(t, store, testRoleID) + + tests := []struct { + name string + req authkit.GrantRoleActionRequest + }{ + { + name: "missing role ID", + req: authkit.GrantRoleActionRequest{ + Action: testAction, + }, + }, + { + name: "missing action", + req: authkit.GrantRoleActionRequest{ + RoleID: testRoleID, + }, + }, + { + name: "missing role", + req: authkit.GrantRoleActionRequest{ + RoleID: "missing", + Action: testAction, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Error(t, store.GrantRoleAction(context.Background(), tt.req)) + }) + } + }) + + t.Run("assign principal role is idempotent", func(t *testing.T) { + store := newStore(t) + principal := createPrincipal(t, store) + createRole(t, store, testRoleID) + require.NoError(t, store.GrantRoleAction(context.Background(), authkit.GrantRoleActionRequest{ + RoleID: testRoleID, + Action: testAction, + })) + + req := authkit.AssignPrincipalRoleRequest{ + PrincipalID: principal.ID, + RoleID: testRoleID, + } + require.NoError(t, store.AssignPrincipalRole(context.Background(), req)) + require.NoError(t, store.AssignPrincipalRole(context.Background(), req)) + + actions, err := store.ResolvePrincipalActions(context.Background(), principal.ID) + + require.NoError(t, err) + assert.Equal(t, []string{testAction}, actions) + }) + + t.Run("list and unassign principal roles", func(t *testing.T) { + store := newStore(t) + principal := createPrincipal(t, store) + createRole(t, store, "writers") + createRole(t, store, "readers") + for _, assignment := range []authkit.AssignPrincipalRoleRequest{ + {PrincipalID: principal.ID, RoleID: "writers"}, + {PrincipalID: principal.ID, RoleID: "readers"}, + } { + require.NoError(t, store.AssignPrincipalRole(context.Background(), assignment)) + } + + assignments, err := store.ListPrincipalRoleAssignments(context.Background(), principal.ID) + require.NoError(t, err) + assert.Equal(t, []authkit.PrincipalRoleAssignment{ + {PrincipalID: principal.ID, RoleID: "readers"}, + {PrincipalID: principal.ID, RoleID: "writers"}, + }, assignments) + + require.NoError(t, store.UnassignPrincipalRole(context.Background(), authkit.UnassignPrincipalRoleRequest{ + PrincipalID: principal.ID, + RoleID: "writers", + })) + require.NoError(t, store.UnassignPrincipalRole(context.Background(), authkit.UnassignPrincipalRoleRequest{ + PrincipalID: principal.ID, + RoleID: "writers", + })) + + assignments, err = store.ListPrincipalRoleAssignments(context.Background(), principal.ID) + require.NoError(t, err) + assert.Equal(t, []authkit.PrincipalRoleAssignment{ + {PrincipalID: principal.ID, RoleID: "readers"}, + }, assignments) + }) + + t.Run("assign principal role validates request", func(t *testing.T) { + store := newStore(t) + principal := createPrincipal(t, store) + createRole(t, store, testRoleID) + + tests := []struct { + name string + req authkit.AssignPrincipalRoleRequest + }{ + { + name: "missing principal ID", + req: authkit.AssignPrincipalRoleRequest{ + RoleID: testRoleID, + }, + }, + { + name: "missing role ID", + req: authkit.AssignPrincipalRoleRequest{ + PrincipalID: principal.ID, + }, + }, + { + name: "missing principal", + req: authkit.AssignPrincipalRoleRequest{ + PrincipalID: "missing", + RoleID: testRoleID, + }, + }, + { + name: "missing role", + req: authkit.AssignPrincipalRoleRequest{ + PrincipalID: principal.ID, + RoleID: "missing", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Error(t, store.AssignPrincipalRole(context.Background(), tt.req)) + }) + } + }) + + t.Run("unassign and list principal roles validate request", func(t *testing.T) { + store := newStore(t) + principal := createPrincipal(t, store) + createRole(t, store, testRoleID) + + tests := []struct { + name string + req authkit.UnassignPrincipalRoleRequest + }{ + { + name: "missing principal ID", + req: authkit.UnassignPrincipalRoleRequest{ + RoleID: testRoleID, + }, + }, + { + name: "missing role ID", + req: authkit.UnassignPrincipalRoleRequest{ + PrincipalID: principal.ID, + }, + }, + { + name: "missing principal", + req: authkit.UnassignPrincipalRoleRequest{ + PrincipalID: "missing", + RoleID: testRoleID, + }, + }, + { + name: "missing role", + req: authkit.UnassignPrincipalRoleRequest{ + PrincipalID: principal.ID, + RoleID: "missing", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Error(t, store.UnassignPrincipalRole(context.Background(), tt.req)) + }) + } + + assignments, err := store.ListPrincipalRoleAssignments(context.Background(), "missing") + require.ErrorIs(t, err, authkit.ErrPrincipalNotFound) + assert.Nil(t, assignments) + }) + + t.Run("resolve principal actions returns distinct sorted actions", func(t *testing.T) { + store := newStore(t) + principal := createPrincipal(t, store) + createRole(t, store, "writers") + createRole(t, store, "readers") + for _, grant := range []authkit.GrantRoleActionRequest{ + {RoleID: "writers", Action: "notes:write"}, + {RoleID: "writers", Action: testAction}, + {RoleID: "readers", Action: testAction}, + } { + require.NoError(t, store.GrantRoleAction(context.Background(), grant)) + } + for _, assignment := range []authkit.AssignPrincipalRoleRequest{ + {PrincipalID: principal.ID, RoleID: "writers"}, + {PrincipalID: principal.ID, RoleID: "readers"}, + } { + require.NoError(t, store.AssignPrincipalRole(context.Background(), assignment)) + } + + actions, err := store.ResolvePrincipalActions(context.Background(), principal.ID) + + require.NoError(t, err) + assert.Equal(t, []string{testAction, "notes:write"}, actions) + }) + + t.Run("resolve principal actions validates request", func(t *testing.T) { + store := newStore(t) + + actions, err := store.ResolvePrincipalActions(context.Background(), "") + require.Error(t, err) + assert.Nil(t, actions) + + actions, err = store.ResolvePrincipalActions(context.Background(), "missing") + require.Error(t, err) + assert.Nil(t, actions) + }) +} diff --git a/internal/storetest/storetest.go b/internal/storetest/storetest.go index e6682c8..22bb855 100644 --- a/internal/storetest/storetest.go +++ b/internal/storetest/storetest.go @@ -1,18 +1,13 @@ +// Package storetest is the shared behavior suite that every authkit store +// adapter is expected to pass. It exercises principals, roles, provisioning +// rules, identity links, passkeys, API tokens, and OIDC provider trust so the +// memory and PostgreSQL adapters can be held to a single observable contract. package storetest import ( - "context" - "crypto/sha256" - "encoding/base64" - "sort" - "sync" "testing" "time" - "github.com/go-webauthn/webauthn/webauthn" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "github.com/meigma/authkit" "github.com/meigma/authkit/apikey" "github.com/meigma/authkit/oidc" @@ -60,1761 +55,19 @@ type Store interface { // Run runs the shared storage behavior suite against newStore. // -//nolint:funlen,gocognit // Keeping one top-level suite makes cross-store coverage easy to audit. +// Each sub-suite owns one domain (principals, roles, provisioning rules, +// identities, passkeys, API tokens, OIDC providers) plus a cross-cutting +// context-cancellation sweep. Adding a new domain means adding a sub-suite and +// a call below. func Run(t *testing.T, newStore func(t *testing.T) Store) { t.Helper() - t.Run("create principal", func(t *testing.T) { - store := newStore(t) - - tests := []struct { - name string - kind authkit.PrincipalKind - }{ - {name: "creates user principal", kind: authkit.PrincipalKindUser}, - {name: "creates service principal", kind: authkit.PrincipalKindService}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - principal, err := store.CreatePrincipal(context.Background(), authkit.CreatePrincipalRequest{ - Kind: tt.kind, - DisplayName: testDisplayName, - }) - - require.NoError(t, err) - assert.NotEmpty(t, principal.ID) - assert.Contains(t, principal.ID, "principal_") - assert.Equal(t, tt.kind, principal.Kind) - assert.Equal(t, testDisplayName, principal.DisplayName) - assert.Nil(t, principal.Attributes) - }) - } - }) - - t.Run("create principal rejects invalid kind", func(t *testing.T) { - store := newStore(t) - - principal, err := store.CreatePrincipal(context.Background(), authkit.CreatePrincipalRequest{ - Kind: authkit.PrincipalKind("team"), - DisplayName: testDisplayName, - }) - - require.Error(t, err) - assert.Empty(t, principal) - }) - - t.Run("find and list principals", func(t *testing.T) { - store := newStore(t) - first, err := store.CreatePrincipal(context.Background(), authkit.CreatePrincipalRequest{ - Kind: authkit.PrincipalKindUser, - DisplayName: testDisplayName, - Attributes: map[string]any{ - "team": "platform", - }, - }) - require.NoError(t, err) - second, err := store.CreatePrincipal(context.Background(), authkit.CreatePrincipalRequest{ - Kind: authkit.PrincipalKindService, - DisplayName: "Deploy service", - }) - require.NoError(t, err) - - found, err := store.FindPrincipal(context.Background(), first.ID) - require.NoError(t, err) - assert.Equal(t, first, found) - - first.Attributes["team"] = "changed" - found.Attributes["team"] = "changed from found" - - foundAgain, err := store.FindPrincipal(context.Background(), first.ID) - require.NoError(t, err) - assert.Equal(t, "platform", foundAgain.Attributes["team"]) - - principals, err := store.ListPrincipals(context.Background()) - require.NoError(t, err) - want := []authkit.Principal{foundAgain, second} - sort.Slice(want, func(i, j int) bool { - return want[i].ID < want[j].ID - }) - assert.Equal(t, want, principals) - - for i := range principals { - if principals[i].ID == first.ID { - principals[i].Attributes["team"] = "changed from list" - } - } - foundAfterListMutation, err := store.FindPrincipal(context.Background(), first.ID) - require.NoError(t, err) - assert.Equal(t, "platform", foundAfterListMutation.Attributes["team"]) - }) - - t.Run("find principal missing behavior", func(t *testing.T) { - store := newStore(t) - - found, err := store.FindPrincipal(context.Background(), "missing") - - require.ErrorIs(t, err, authkit.ErrPrincipalNotFound) - assert.Empty(t, found) - }) - - t.Run("create role", func(t *testing.T) { - store := newStore(t) - req := roleRequest() - - role, err := store.CreateRole(context.Background(), req) - - require.NoError(t, err) - assert.Equal(t, authkit.Role(req), role) - }) - - t.Run("create role validates request", func(t *testing.T) { - store := newStore(t) - - role, err := store.CreateRole(context.Background(), authkit.CreateRoleRequest{}) - - require.Error(t, err) - assert.Empty(t, role) - }) - - t.Run("create role rejects duplicate ID", func(t *testing.T) { - store := newStore(t) - _, err := store.CreateRole(context.Background(), roleRequest()) - require.NoError(t, err) - - role, err := store.CreateRole(context.Background(), roleRequest()) - - require.Error(t, err) - assert.Empty(t, role) - }) - - t.Run("grant role action is idempotent", func(t *testing.T) { - store := newStore(t) - principal := createPrincipal(t, store) - createRole(t, store, testRoleID) - - req := authkit.GrantRoleActionRequest{ - RoleID: testRoleID, - Action: testAction, - } - require.NoError(t, store.GrantRoleAction(context.Background(), req)) - require.NoError(t, store.GrantRoleAction(context.Background(), req)) - require.NoError(t, store.AssignPrincipalRole(context.Background(), authkit.AssignPrincipalRoleRequest{ - PrincipalID: principal.ID, - RoleID: testRoleID, - })) - - actions, err := store.ResolvePrincipalActions(context.Background(), principal.ID) - - require.NoError(t, err) - assert.Equal(t, []string{testAction}, actions) - }) - - t.Run("grant role action validates request", func(t *testing.T) { - store := newStore(t) - createRole(t, store, testRoleID) - - tests := []struct { - name string - req authkit.GrantRoleActionRequest - }{ - { - name: "missing role ID", - req: authkit.GrantRoleActionRequest{ - Action: testAction, - }, - }, - { - name: "missing action", - req: authkit.GrantRoleActionRequest{ - RoleID: testRoleID, - }, - }, - { - name: "missing role", - req: authkit.GrantRoleActionRequest{ - RoleID: "missing", - Action: testAction, - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - require.Error(t, store.GrantRoleAction(context.Background(), tt.req)) - }) - } - }) - - t.Run("assign principal role is idempotent", func(t *testing.T) { - store := newStore(t) - principal := createPrincipal(t, store) - createRole(t, store, testRoleID) - require.NoError(t, store.GrantRoleAction(context.Background(), authkit.GrantRoleActionRequest{ - RoleID: testRoleID, - Action: testAction, - })) - - req := authkit.AssignPrincipalRoleRequest{ - PrincipalID: principal.ID, - RoleID: testRoleID, - } - require.NoError(t, store.AssignPrincipalRole(context.Background(), req)) - require.NoError(t, store.AssignPrincipalRole(context.Background(), req)) - - actions, err := store.ResolvePrincipalActions(context.Background(), principal.ID) - - require.NoError(t, err) - assert.Equal(t, []string{testAction}, actions) - }) - - t.Run("list and unassign principal roles", func(t *testing.T) { - store := newStore(t) - principal := createPrincipal(t, store) - createRole(t, store, "writers") - createRole(t, store, "readers") - for _, assignment := range []authkit.AssignPrincipalRoleRequest{ - {PrincipalID: principal.ID, RoleID: "writers"}, - {PrincipalID: principal.ID, RoleID: "readers"}, - } { - require.NoError(t, store.AssignPrincipalRole(context.Background(), assignment)) - } - - assignments, err := store.ListPrincipalRoleAssignments(context.Background(), principal.ID) - require.NoError(t, err) - assert.Equal(t, []authkit.PrincipalRoleAssignment{ - {PrincipalID: principal.ID, RoleID: "readers"}, - {PrincipalID: principal.ID, RoleID: "writers"}, - }, assignments) - - require.NoError(t, store.UnassignPrincipalRole(context.Background(), authkit.UnassignPrincipalRoleRequest{ - PrincipalID: principal.ID, - RoleID: "writers", - })) - require.NoError(t, store.UnassignPrincipalRole(context.Background(), authkit.UnassignPrincipalRoleRequest{ - PrincipalID: principal.ID, - RoleID: "writers", - })) - - assignments, err = store.ListPrincipalRoleAssignments(context.Background(), principal.ID) - require.NoError(t, err) - assert.Equal(t, []authkit.PrincipalRoleAssignment{ - {PrincipalID: principal.ID, RoleID: "readers"}, - }, assignments) - }) - - t.Run("assign principal role validates request", func(t *testing.T) { - store := newStore(t) - principal := createPrincipal(t, store) - createRole(t, store, testRoleID) - - tests := []struct { - name string - req authkit.AssignPrincipalRoleRequest - }{ - { - name: "missing principal ID", - req: authkit.AssignPrincipalRoleRequest{ - RoleID: testRoleID, - }, - }, - { - name: "missing role ID", - req: authkit.AssignPrincipalRoleRequest{ - PrincipalID: principal.ID, - }, - }, - { - name: "missing principal", - req: authkit.AssignPrincipalRoleRequest{ - PrincipalID: "missing", - RoleID: testRoleID, - }, - }, - { - name: "missing role", - req: authkit.AssignPrincipalRoleRequest{ - PrincipalID: principal.ID, - RoleID: "missing", - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - require.Error(t, store.AssignPrincipalRole(context.Background(), tt.req)) - }) - } - }) - - t.Run("unassign and list principal roles validate request", func(t *testing.T) { - store := newStore(t) - principal := createPrincipal(t, store) - createRole(t, store, testRoleID) - - tests := []struct { - name string - req authkit.UnassignPrincipalRoleRequest - }{ - { - name: "missing principal ID", - req: authkit.UnassignPrincipalRoleRequest{ - RoleID: testRoleID, - }, - }, - { - name: "missing role ID", - req: authkit.UnassignPrincipalRoleRequest{ - PrincipalID: principal.ID, - }, - }, - { - name: "missing principal", - req: authkit.UnassignPrincipalRoleRequest{ - PrincipalID: "missing", - RoleID: testRoleID, - }, - }, - { - name: "missing role", - req: authkit.UnassignPrincipalRoleRequest{ - PrincipalID: principal.ID, - RoleID: "missing", - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - require.Error(t, store.UnassignPrincipalRole(context.Background(), tt.req)) - }) - } - - assignments, err := store.ListPrincipalRoleAssignments(context.Background(), "missing") - require.ErrorIs(t, err, authkit.ErrPrincipalNotFound) - assert.Nil(t, assignments) - }) - - t.Run("resolve principal actions returns distinct sorted actions", func(t *testing.T) { - store := newStore(t) - principal := createPrincipal(t, store) - createRole(t, store, "writers") - createRole(t, store, "readers") - for _, grant := range []authkit.GrantRoleActionRequest{ - {RoleID: "writers", Action: "notes:write"}, - {RoleID: "writers", Action: testAction}, - {RoleID: "readers", Action: testAction}, - } { - require.NoError(t, store.GrantRoleAction(context.Background(), grant)) - } - for _, assignment := range []authkit.AssignPrincipalRoleRequest{ - {PrincipalID: principal.ID, RoleID: "writers"}, - {PrincipalID: principal.ID, RoleID: "readers"}, - } { - require.NoError(t, store.AssignPrincipalRole(context.Background(), assignment)) - } - - actions, err := store.ResolvePrincipalActions(context.Background(), principal.ID) - - require.NoError(t, err) - assert.Equal(t, []string{testAction, "notes:write"}, actions) - }) - - t.Run("resolve principal actions validates request", func(t *testing.T) { - store := newStore(t) - - actions, err := store.ResolvePrincipalActions(context.Background(), "") - require.Error(t, err) - assert.Nil(t, actions) - - actions, err = store.ResolvePrincipalActions(context.Background(), "missing") - require.Error(t, err) - assert.Nil(t, actions) - }) - - t.Run("provisioning rules", func(t *testing.T) { - store := newStore(t) - createRole(t, store, testRoleID) - trustProvider(t, store, providerFixture()) - req := provisioningRuleRequest() - - rule, err := store.CreateProvisioningRule(context.Background(), req) - require.NoError(t, err) - assert.Equal(t, authkit.ProvisioningRule(req), rule) - - req.AssignRoleIDs[0] = "changed" - rule.AssignRoleIDs[0] = "changed-from-returned" - - found, err := store.FindProvisioningRule(context.Background(), testProvisioningRuleID) - require.NoError(t, err) - assert.Equal(t, `hasAny(claims.groups, ["/engineering"])`, found.Condition) - assert.Equal(t, []string{testRoleID}, found.AssignRoleIDs) - - listed, err := store.ListProvisioningRules(context.Background()) - require.NoError(t, err) - assert.Equal(t, []authkit.ProvisioningRule{found}, listed) - - found.Condition = "false" - listed[0].AssignRoleIDs[0] = "changed-from-list" - foundAgain, err := store.FindProvisioningRule(context.Background(), testProvisioningRuleID) - require.NoError(t, err) - assert.Equal(t, `hasAny(claims.groups, ["/engineering"])`, foundAgain.Condition) - assert.Equal(t, []string{testRoleID}, foundAgain.AssignRoleIDs) - }) - - t.Run("provisioning rules can be updated and deleted", func(t *testing.T) { - store := newStore(t) - createRole(t, store, testRoleID) - createRole(t, store, "notes-writer") - trustProvider(t, store, providerFixture()) - _, err := store.CreateProvisioningRule(context.Background(), provisioningRuleRequest()) - require.NoError(t, err) - - updated := authkit.UpdateProvisioningRuleRequest{ - ID: testProvisioningRuleID, - DisplayName: "Platform writers", - Provider: providerFixture().Issuer, - Condition: `hasAny(claims.realm_access.roles, ["writer"])`, - AssignRoleIDs: []string{"notes-writer"}, - Enabled: false, - } - rule, err := store.UpdateProvisioningRule(context.Background(), updated) - require.NoError(t, err) - assert.Equal(t, authkit.ProvisioningRule(updated), rule) - - require.NoError(t, store.DeleteProvisioningRule(context.Background(), testProvisioningRuleID)) - _, err = store.FindProvisioningRule(context.Background(), testProvisioningRuleID) - require.ErrorIs(t, err, authkit.ErrProvisioningRuleNotFound) - require.ErrorIs( - t, - store.DeleteProvisioningRule(context.Background(), testProvisioningRuleID), - authkit.ErrProvisioningRuleNotFound, - ) - - _, err = store.UpdateProvisioningRule(context.Background(), authkit.UpdateProvisioningRuleRequest{ - ID: testProvisioningRuleID, - Provider: "https://untrusted.example", - Condition: `claims.missing == "missing"`, - AssignRoleIDs: []string{"missing"}, - Enabled: true, - }) - require.ErrorIs(t, err, authkit.ErrProvisioningRuleNotFound) - }) - - t.Run("provisioning rules validate configuration", func(t *testing.T) { - store := newStore(t) - createRole(t, store, testRoleID) - trustProvider(t, store, providerFixture()) - - tests := []struct { - name string - req authkit.CreateProvisioningRuleRequest - }{ - { - name: "missing ID", - req: func() authkit.CreateProvisioningRuleRequest { - req := provisioningRuleRequest() - req.ID = "" - - return req - }(), - }, - { - name: "untrusted provider", - req: func() authkit.CreateProvisioningRuleRequest { - req := provisioningRuleRequest() - req.Provider = "https://untrusted.example" - - return req - }(), - }, - { - name: "missing condition", - req: func() authkit.CreateProvisioningRuleRequest { - req := provisioningRuleRequest() - req.Condition = "" - - return req - }(), - }, - { - name: "syntax error", - req: func() authkit.CreateProvisioningRuleRequest { - req := provisioningRuleRequest() - req.Condition = "claims.groups ==" - - return req - }(), - }, - { - name: "non-bool condition", - req: func() authkit.CreateProvisioningRuleRequest { - req := provisioningRuleRequest() - req.Condition = "identity.subject" - - return req - }(), - }, - { - name: "missing role", - req: func() authkit.CreateProvisioningRuleRequest { - req := provisioningRuleRequest() - req.AssignRoleIDs = []string{"missing"} - - return req - }(), - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - rule, err := store.CreateProvisioningRule(context.Background(), tt.req) - - require.Error(t, err) - assert.Empty(t, rule) - }) - } - }) - - t.Run("principal attributes are copied", func(t *testing.T) { - store := newStore(t) - attrs := map[string]any{ - "role": "operator", - } - - principal, err := store.CreatePrincipal(context.Background(), authkit.CreatePrincipalRequest{ - Kind: authkit.PrincipalKindUser, - DisplayName: testDisplayName, - Attributes: attrs, - }) - require.NoError(t, err) - - attrs["role"] = "changed before resolve" - principal.Attributes["role"] = "changed from returned principal" - - _, err = store.LinkIdentity(context.Background(), authkit.LinkIdentityRequest{ - Provider: testProvider, - Subject: testSubject, - PrincipalID: principal.ID, - }) - require.NoError(t, err) - - resolved, err := store.ResolveIdentity(context.Background(), authkit.Identity{ - Provider: testProvider, - Subject: testSubject, - }) - require.NoError(t, err) - require.NotNil(t, resolved) - assert.Equal(t, "operator", resolved.Attributes["role"]) - - resolved.Attributes["role"] = "changed from resolved principal" - resolvedAgain, err := store.ResolveIdentity(context.Background(), authkit.Identity{ - Provider: testProvider, - Subject: testSubject, - }) - require.NoError(t, err) - require.NotNil(t, resolvedAgain) - assert.Equal(t, "operator", resolvedAgain.Attributes["role"]) - }) - - t.Run("link identity", func(t *testing.T) { - store := newStore(t) - first := createPrincipal(t, store) - second := createPrincipal(t, store) - - link, err := store.LinkIdentity(context.Background(), authkit.LinkIdentityRequest{ - Provider: testProvider, - Subject: testSubject, - PrincipalID: first.ID, - }) - require.NoError(t, err) - assert.Equal(t, authkit.ExternalIdentity{ - Provider: testProvider, - Subject: testSubject, - PrincipalID: first.ID, - }, link) - - relinked, err := store.LinkIdentity(context.Background(), authkit.LinkIdentityRequest{ - Provider: testProvider, - Subject: testSubject, - PrincipalID: first.ID, - }) - require.NoError(t, err) - assert.Equal(t, link, relinked) - - conflicted, err := store.LinkIdentity(context.Background(), authkit.LinkIdentityRequest{ - Provider: testProvider, - Subject: testSubject, - PrincipalID: second.ID, - }) - require.Error(t, err) - assert.Empty(t, conflicted) - }) - - t.Run("link identity validates request", func(t *testing.T) { - store := newStore(t) - principal := createPrincipal(t, store) - - tests := []struct { - name string - req authkit.LinkIdentityRequest - }{ - { - name: "missing provider", - req: authkit.LinkIdentityRequest{ - Subject: testSubject, - PrincipalID: principal.ID, - }, - }, - { - name: "missing subject", - req: authkit.LinkIdentityRequest{ - Provider: testProvider, - PrincipalID: principal.ID, - }, - }, - { - name: "missing principal ID", - req: authkit.LinkIdentityRequest{ - Provider: testProvider, - Subject: testSubject, - }, - }, - { - name: "missing principal", - req: authkit.LinkIdentityRequest{ - Provider: testProvider, - Subject: testSubject, - PrincipalID: "missing", - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - link, err := store.LinkIdentity(context.Background(), tt.req) - - require.Error(t, err) - assert.Empty(t, link) - }) - } - }) - - t.Run("resolve identity", func(t *testing.T) { - store := newStore(t) - principal := createPrincipal(t, store) - - _, err := store.LinkIdentity(context.Background(), authkit.LinkIdentityRequest{ - Provider: testProvider, - Subject: testSubject, - PrincipalID: principal.ID, - }) - require.NoError(t, err) - - resolved, err := store.ResolveIdentity(context.Background(), authkit.Identity{ - Provider: testProvider, - Subject: testSubject, - CredentialID: "id-123", - Claims: map[string]any{ - "ignored": true, - }, - }) - - require.NoError(t, err) - require.NotNil(t, resolved) - assert.Equal(t, principal, *resolved) - }) - - t.Run("resolve identity returns unresolved identity", func(t *testing.T) { - store := newStore(t) - - tests := []struct { - name string - identity authkit.Identity - }{ - { - name: "missing provider", - identity: authkit.Identity{ - Subject: testSubject, - }, - }, - { - name: "missing subject", - identity: authkit.Identity{ - Provider: testProvider, - }, - }, - { - name: "unlinked identity", - identity: authkit.Identity{ - Provider: testProvider, - Subject: testSubject, - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - resolved, err := store.ResolveIdentity(context.Background(), tt.identity) - - require.ErrorIs(t, err, authkit.ErrUnresolvedIdentity) - assert.Nil(t, resolved) - }) - } - }) - - t.Run("passkey registration creates user credential and link", func(t *testing.T) { - store := newStore(t) - principal := createPrincipal(t, store) - registration := passkeyRegistration(principal.ID, "credential-1") - - result, err := store.CreateRegistration(context.Background(), registration) - require.NoError(t, err) - assert.Equal(t, registration.User, result.User) - assert.Equal(t, registration.Credential, result.Credential) - assert.Equal(t, authkit.ExternalIdentity{ - Provider: registration.Identity.Provider, - Subject: registration.Identity.Subject, - PrincipalID: principal.ID, - }, result.Link) - - foundByPrincipal, err := store.FindUserByPrincipal( - context.Background(), - registration.User.RPID, - principal.ID, - ) - require.NoError(t, err) - assert.Equal(t, registration.User, foundByPrincipal) - - foundByHandle, err := store.FindUserByHandle( - context.Background(), - registration.User.RPID, - registration.User.Handle, - ) - require.NoError(t, err) - assert.Equal(t, registration.User, foundByHandle) - - credentials, err := store.ListCredentials( - context.Background(), - registration.User.RPID, - registration.User.Handle, - ) - require.NoError(t, err) - require.Len(t, credentials, 1) - assert.Equal(t, registration.Credential, credentials[0]) - - resolved, err := store.ResolveIdentity(context.Background(), registration.Identity) - require.NoError(t, err) - require.NotNil(t, resolved) - assert.Equal(t, principal.ID, resolved.ID) - }) - - t.Run("passkey missing user behavior", func(t *testing.T) { - store := newStore(t) - - foundByPrincipal, err := store.FindUserByPrincipal(context.Background(), "example.test", "missing") - require.ErrorIs(t, err, passkey.ErrUserNotFound) - assert.Empty(t, foundByPrincipal) - - foundByHandle, err := store.FindUserByHandle(context.Background(), "example.test", []byte("missing")) - require.ErrorIs(t, err, passkey.ErrUserNotFound) - assert.Empty(t, foundByHandle) - - credentials, err := store.ListCredentials(context.Background(), "example.test", []byte("missing")) - require.NoError(t, err) - assert.Empty(t, credentials) - }) - - t.Run("passkey registration allows additional credential for same user", func(t *testing.T) { - store := newStore(t) - principal := createPrincipal(t, store) - first := passkeyRegistration(principal.ID, "credential-1") - second := passkeyRegistration(principal.ID, "credential-2") - second.Credential.CredentialID = []byte("credential-2") - second.Credential.WebAuthn.ID = []byte("credential-2") - second.Identity.CredentialID = passkeyCredentialSubject([]byte("credential-2")) - - _, err := store.CreateRegistration(context.Background(), first) - require.NoError(t, err) - result, err := store.CreateRegistration(context.Background(), second) - require.NoError(t, err) - assert.Equal(t, first.Identity.Provider, result.Link.Provider) - assert.Equal(t, first.Identity.Subject, result.Link.Subject) - assert.Equal(t, principal.ID, result.Link.PrincipalID) - - credentials, err := store.ListCredentials( - context.Background(), - first.User.RPID, - first.User.Handle, - ) - require.NoError(t, err) - require.Len(t, credentials, passkeyCredentialCount) - assert.Equal(t, []byte("credential-1"), credentials[0].CredentialID) - assert.Equal(t, []byte("credential-2"), credentials[1].CredentialID) - }) - - t.Run("passkey registration rejects duplicate credential", func(t *testing.T) { - store := newStore(t) - principal := createPrincipal(t, store) - registration := passkeyRegistration(principal.ID, "credential-1") - - _, err := store.CreateRegistration(context.Background(), registration) - require.NoError(t, err) - result, err := store.CreateRegistration(context.Background(), registration) - - require.ErrorIs(t, err, passkey.ErrCredentialExists) - assert.Empty(t, result) - }) - - t.Run("passkey registration rejects conflicting users", func(t *testing.T) { - tests := []struct { - name string - mutate func(passkey.Registration, string) passkey.Registration - }{ - { - name: "same principal different handle", - mutate: func(registration passkey.Registration, _ string) passkey.Registration { - registration.User.Handle = []byte("other-handle") - registration.Credential.UserHandle = []byte("other-handle") - registration.Identity.Subject = passkeyUserSubject(registration.User.Handle) - return registration - }, - }, - { - name: "same handle different principal", - mutate: func(registration passkey.Registration, otherPrincipalID string) passkey.Registration { - registration.User.PrincipalID = otherPrincipalID - registration.Credential.PrincipalID = otherPrincipalID - return registration - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - store := newStore(t) - principal := createPrincipal(t, store) - other := createPrincipal(t, store) - registration := passkeyRegistration(principal.ID, "credential-1") - _, err := store.CreateRegistration(context.Background(), registration) - require.NoError(t, err) - - conflict := tt.mutate(passkeyRegistration(principal.ID, "credential-2"), other.ID) - result, err := store.CreateRegistration(context.Background(), conflict) - - require.ErrorIs(t, err, passkey.ErrUserExists) - assert.Empty(t, result) - }) - } - }) - - t.Run("passkey registration rolls back on identity link conflict", func(t *testing.T) { - store := newStore(t) - principal := createPrincipal(t, store) - other := createPrincipal(t, store) - registration := passkeyRegistration(principal.ID, "credential-1") - _, err := store.LinkIdentity(context.Background(), authkit.LinkIdentityRequest{ - Provider: registration.Identity.Provider, - Subject: registration.Identity.Subject, - PrincipalID: other.ID, - }) - require.NoError(t, err) - - result, err := store.CreateRegistration(context.Background(), registration) - require.Error(t, err) - require.NotErrorIs(t, err, passkey.ErrUserExists) - require.NotErrorIs(t, err, passkey.ErrCredentialExists) - assert.Empty(t, result) - - found, err := store.FindUserByPrincipal(context.Background(), registration.User.RPID, principal.ID) - require.ErrorIs(t, err, passkey.ErrUserNotFound) - assert.Empty(t, found) - credentials, err := store.ListCredentials( - context.Background(), - registration.User.RPID, - registration.User.Handle, - ) - require.NoError(t, err) - assert.Empty(t, credentials) - }) - - t.Run("passkey credential update persists login metadata", func(t *testing.T) { - store := newStore(t) - principal := createPrincipal(t, store) - registration := passkeyRegistration(principal.ID, "credential-1") - _, err := store.CreateRegistration(context.Background(), registration) - require.NoError(t, err) - - updated := registration.Credential - updated.WebAuthn.PublicKey = []byte("updated-public-key") - updated.WebAuthn.Authenticator.SignCount = passkeyUpdatedSignCount - require.NoError(t, store.UpdateCredentialAfterLogin(context.Background(), updated)) - - credentials, err := store.ListCredentials( - context.Background(), - registration.User.RPID, - registration.User.Handle, - ) - require.NoError(t, err) - require.Len(t, credentials, 1) - assert.Equal(t, []byte("updated-public-key"), credentials[0].WebAuthn.PublicKey) - assert.Equal(t, uint32(passkeyUpdatedSignCount), credentials[0].WebAuthn.Authenticator.SignCount) - }) - - t.Run("passkey values are copied", func(t *testing.T) { - store := newStore(t) - principal := createPrincipal(t, store) - registration := passkeyRegistration(principal.ID, "credential-1") - wantUserHandle := append([]byte(nil), registration.User.Handle...) - wantPublicKey := append([]byte(nil), registration.Credential.WebAuthn.PublicKey...) - - result, err := store.CreateRegistration(context.Background(), registration) - require.NoError(t, err) - registration.User.Handle[0] = 'x' - registration.Credential.WebAuthn.PublicKey[0] = 'x' - result.User.Handle[0] = 'y' - result.Credential.WebAuthn.PublicKey[0] = 'y' - - found, err := store.FindUserByPrincipal(context.Background(), "example.test", principal.ID) - require.NoError(t, err) - assert.Equal(t, wantUserHandle, found.Handle) - found.Handle[0] = 'z' - - foundAgain, err := store.FindUserByPrincipal(context.Background(), "example.test", principal.ID) - require.NoError(t, err) - assert.Equal(t, wantUserHandle, foundAgain.Handle) - - credentials, err := store.ListCredentials(context.Background(), "example.test", wantUserHandle) - require.NoError(t, err) - require.Len(t, credentials, 1) - assert.Equal(t, wantPublicKey, credentials[0].WebAuthn.PublicKey) - credentials[0].WebAuthn.PublicKey[0] = 'z' - - credentialsAgain, err := store.ListCredentials(context.Background(), "example.test", wantUserHandle) - require.NoError(t, err) - require.Len(t, credentialsAgain, 1) - assert.Equal(t, wantPublicKey, credentialsAgain[0].WebAuthn.PublicKey) - }) - - t.Run("provision identity creates principal and link", func(t *testing.T) { - store := newStore(t) - req := provisionRequest() - wantAttributes := map[string]any{ - "email": "ada@example.test", - } - - result, err := store.ProvisionIdentity(context.Background(), req) - require.NoError(t, err) - assert.True(t, result.Created) - assert.NotEmpty(t, result.Principal.ID) - assert.Equal(t, authkit.PrincipalKindUser, result.Principal.Kind) - assert.Equal(t, testDisplayName, result.Principal.DisplayName) - assert.Equal(t, wantAttributes, result.Principal.Attributes) - assert.Equal(t, authkit.ExternalIdentity{ - Provider: testProvider, - Subject: testSubject, - PrincipalID: result.Principal.ID, - }, result.Link) - - req.Principal.Attributes["email"] = "changed before resolve" - result.Principal.Attributes["email"] = "changed from returned principal" - - resolved, err := store.ResolveIdentity(context.Background(), req.Identity) - require.NoError(t, err) - require.NotNil(t, resolved) - assert.Equal(t, wantAttributes, resolved.Attributes) - assert.Equal(t, result.Principal.ID, resolved.ID) - }) - - t.Run("provision identity assigns initial roles", func(t *testing.T) { - store := newStore(t) - createRole(t, store, testRoleID) - require.NoError(t, store.GrantRoleAction(context.Background(), authkit.GrantRoleActionRequest{ - RoleID: testRoleID, - Action: testAction, - })) - req := provisionRequest() - req.InitialRoleIDs = []string{testRoleID} - - result, err := store.ProvisionIdentity(context.Background(), req) - require.NoError(t, err) - require.True(t, result.Created) - - actions, err := store.ResolvePrincipalActions(context.Background(), result.Principal.ID) - require.NoError(t, err) - assert.Equal(t, []string{testAction}, actions) - }) - - t.Run("provision identity fails when initial role is missing", func(t *testing.T) { - store := newStore(t) - req := provisionRequest() - req.InitialRoleIDs = []string{"missing"} - - result, err := store.ProvisionIdentity(context.Background(), req) - require.Error(t, err) - assert.Empty(t, result) - - resolved, err := store.ResolveIdentity(context.Background(), req.Identity) - require.ErrorIs(t, err, authkit.ErrUnresolvedIdentity) - assert.Nil(t, resolved) - }) - - t.Run("provision identity does not assign roles to existing links", func(t *testing.T) { - store := newStore(t) - first, err := store.ProvisionIdentity(context.Background(), provisionRequest()) - require.NoError(t, err) - require.True(t, first.Created) - createRole(t, store, testRoleID) - require.NoError(t, store.GrantRoleAction(context.Background(), authkit.GrantRoleActionRequest{ - RoleID: testRoleID, - Action: testAction, - })) - req := provisionRequest() - req.InitialRoleIDs = []string{testRoleID} - - second, err := store.ProvisionIdentity(context.Background(), req) - require.NoError(t, err) - assert.False(t, second.Created) - - actions, err := store.ResolvePrincipalActions(context.Background(), first.Principal.ID) - require.NoError(t, err) - assert.Nil(t, actions) - }) - - t.Run("provision identity returns existing link without updating principal", func(t *testing.T) { - store := newStore(t) - first, err := store.ProvisionIdentity(context.Background(), provisionRequest()) - require.NoError(t, err) - require.True(t, first.Created) - - secondReq := provisionRequest() - secondReq.Principal.DisplayName = "Changed Name" - secondReq.Principal.Attributes = map[string]any{ - "email": "changed@example.test", - } - second, err := store.ProvisionIdentity(context.Background(), secondReq) - - require.NoError(t, err) - assert.False(t, second.Created) - assert.Equal(t, first.Link, second.Link) - assert.Equal(t, first.Principal, second.Principal) - assert.Equal(t, testDisplayName, second.Principal.DisplayName) - assert.Equal(t, "ada@example.test", second.Principal.Attributes["email"]) - }) - - t.Run("provision identity validates request", func(t *testing.T) { - store := newStore(t) - - tests := []struct { - name string - req authkit.ProvisionIdentityRequest - assertErr func(t *testing.T, err error) - }{ - { - name: "missing provider", - req: authkit.ProvisionIdentityRequest{ - Identity: authkit.Identity{Subject: testSubject}, - Principal: authkit.CreatePrincipalRequest{ - Kind: authkit.PrincipalKindUser, - }, - }, - assertErr: func(t *testing.T, err error) { - require.ErrorIs(t, err, authkit.ErrUnresolvedIdentity) - }, - }, - { - name: "missing subject", - req: authkit.ProvisionIdentityRequest{ - Identity: authkit.Identity{Provider: testProvider}, - Principal: authkit.CreatePrincipalRequest{ - Kind: authkit.PrincipalKindUser, - }, - }, - assertErr: func(t *testing.T, err error) { - require.ErrorIs(t, err, authkit.ErrUnresolvedIdentity) - }, - }, - { - name: "invalid principal kind", - req: authkit.ProvisionIdentityRequest{ - Identity: authkit.Identity{ - Provider: testProvider, - Subject: testSubject, - }, - Principal: authkit.CreatePrincipalRequest{ - Kind: authkit.PrincipalKind("team"), - }, - }, - assertErr: func(t *testing.T, err error) { - require.Error(t, err) - require.NotErrorIs(t, err, authkit.ErrUnresolvedIdentity) - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result, err := store.ProvisionIdentity(context.Background(), tt.req) - - tt.assertErr(t, err) - assert.Empty(t, result) - }) - } - }) - - t.Run("provision identity is idempotent under concurrency", func(t *testing.T) { - store := newStore(t) - ctx := context.Background() - start := make(chan struct{}) - results := make(chan authkit.ProvisionIdentityResult, concurrentProvisionAttempts) - errs := make(chan error, concurrentProvisionAttempts) - var wg sync.WaitGroup - - for range cap(results) { - wg.Go(func() { - <-start - result, err := store.ProvisionIdentity(ctx, provisionRequest()) - if err != nil { - errs <- err - - return - } - results <- result - }) - } - - close(start) - wg.Wait() - close(results) - close(errs) - - for err := range errs { - require.NoError(t, err) - } - - created := 0 - principalID := "" - for result := range results { - if result.Created { - created++ - } - if principalID == "" { - principalID = result.Principal.ID - } - assert.Equal(t, principalID, result.Principal.ID) - assert.Equal(t, principalID, result.Link.PrincipalID) - } - assert.Equal(t, 1, created) - - resolved, err := store.ResolveIdentity(ctx, provisionRequest().Identity) - require.NoError(t, err) - require.NotNil(t, resolved) - assert.Equal(t, principalID, resolved.ID) - }) - - t.Run("trusted providers", func(t *testing.T) { - store := newStore(t) - provider := providerFixture() - want := providerFixture() - - trusted, err := store.TrustProvider(context.Background(), provider) - require.NoError(t, err) - assert.Equal(t, want, trusted) - - provider.Audiences[0] = "changed before find" - provider.ForwardedClaims[0][0] = "changed-before-find" - trusted.Audiences[0] = "changed from returned provider" - trusted.SupportedSigningAlgorithms[0] = "ES256" - trusted.ForwardedClaims[0][0] = "changed-from-returned-provider" - - found, err := store.FindProvider(context.Background(), want.Issuer) - require.NoError(t, err) - assert.Equal(t, want, found) - - found.Audiences[0] = "changed from found provider" - found.SupportedSigningAlgorithms[0] = "ES256" - found.ForwardedClaims[0][0] = "changed-from-found-provider" - foundAgain, err := store.FindProvider(context.Background(), want.Issuer) - require.NoError(t, err) - assert.Equal(t, want, foundAgain) - }) - - t.Run("trusted providers can be updated", func(t *testing.T) { - store := newStore(t) - provider := providerFixture() - _, err := store.TrustProvider(context.Background(), provider) - require.NoError(t, err) - - updated := oidc.Provider{ - Issuer: provider.Issuer, - Audiences: []string{"updated-api"}, - JWKSURL: "https://issuer.example/updated-jwks.json", - SupportedSigningAlgorithms: []string{"RS512"}, - ForwardedClaims: []authkit.ClaimPath{{"email"}}, - } - trusted, err := store.TrustProvider(context.Background(), updated) - require.NoError(t, err) - assert.Equal(t, updated, trusted) - - found, err := store.FindProvider(context.Background(), provider.Issuer) - require.NoError(t, err) - assert.Equal(t, updated, found) - }) - - t.Run("trusted providers missing behavior", func(t *testing.T) { - store := newStore(t) - - found, err := store.FindProvider(context.Background(), "https://issuer.example") - - require.ErrorIs(t, err, oidc.ErrProviderNotFound) - assert.Empty(t, found) - }) - - t.Run("trusted providers reject invalid configuration", func(t *testing.T) { - store := newStore(t) - invalid := providerFixture() - invalid.Audiences = nil - - trusted, err := store.TrustProvider(context.Background(), invalid) - require.Error(t, err) - assert.Empty(t, trusted) - - found, err := store.FindProvider(context.Background(), invalid.Issuer) - require.ErrorIs(t, err, oidc.ErrProviderNotFound) - assert.Empty(t, found) - }) - - t.Run("returns context error", func(t *testing.T) { - store := newStore(t) - principal := createPrincipal(t, store) - _, err := store.LinkIdentity(context.Background(), authkit.LinkIdentityRequest{ - Provider: testProvider, - Subject: testSubject, - PrincipalID: principal.ID, - }) - require.NoError(t, err) - token := tokenFixture(fixedStoreTime(), principal.ID) - require.NoError(t, store.CreateToken(context.Background(), token)) - registration := passkeyRegistration(principal.ID, "credential-1") - _, err = store.CreateRegistration(context.Background(), registration) - require.NoError(t, err) - - ctx, cancel := context.WithCancel(context.Background()) - cancel() - - tests := []struct { - name string - run func() error - }{ - { - name: "create principal", - run: func() error { - _, runErr := store.CreatePrincipal(ctx, authkit.CreatePrincipalRequest{ - Kind: authkit.PrincipalKindUser, - }) - - return runErr - }, - }, - { - name: "find principal", - run: func() error { - _, runErr := store.FindPrincipal(ctx, principal.ID) - - return runErr - }, - }, - { - name: "list principals", - run: func() error { - _, runErr := store.ListPrincipals(ctx) - - return runErr - }, - }, - { - name: "unassign principal role", - run: func() error { - return store.UnassignPrincipalRole(ctx, authkit.UnassignPrincipalRoleRequest{ - PrincipalID: principal.ID, - RoleID: testRoleID, - }) - }, - }, - { - name: "list principal role assignments", - run: func() error { - _, runErr := store.ListPrincipalRoleAssignments(ctx, principal.ID) - - return runErr - }, - }, - { - name: "link identity", - run: func() error { - _, runErr := store.LinkIdentity(ctx, authkit.LinkIdentityRequest{ - Provider: "api-token", - Subject: "token-123", - PrincipalID: principal.ID, - }) - - return runErr - }, - }, - { - name: "resolve identity", - run: func() error { - _, runErr := store.ResolveIdentity(ctx, authkit.Identity{ - Provider: testProvider, - Subject: testSubject, - }) - - return runErr - }, - }, - { - name: "provision identity", - run: func() error { - _, runErr := store.ProvisionIdentity(ctx, provisionRequest()) - - return runErr - }, - }, - { - name: "create token", - run: func() error { - return store.CreateToken(ctx, tokenFixture(fixedStoreTime(), principal.ID)) - }, - }, - { - name: "find token", - run: func() error { - _, runErr := store.FindToken(ctx, token.ID) - - return runErr - }, - }, - { - name: "list principal token metadata", - run: func() error { - _, runErr := store.ListPrincipalTokenMetadata(ctx, principal.ID) - - return runErr - }, - }, - { - name: "update token last used", - run: func() error { - return store.UpdateTokenLastUsed(ctx, token.ID, fixedStoreTime()) - }, - }, - { - name: "revoke token", - run: func() error { - return store.RevokeToken(ctx, token.ID, fixedStoreTime()) - }, - }, - { - name: "trust provider", - run: func() error { - _, runErr := store.TrustProvider(ctx, providerFixture()) - - return runErr - }, - }, - { - name: "create provisioning rule", - run: func() error { - _, runErr := store.CreateProvisioningRule(ctx, provisioningRuleRequest()) - - return runErr - }, - }, - { - name: "update provisioning rule", - run: func() error { - _, runErr := store.UpdateProvisioningRule(ctx, authkit.UpdateProvisioningRuleRequest{ - ID: testProvisioningRuleID, - }) - - return runErr - }, - }, - { - name: "delete provisioning rule", - run: func() error { - return store.DeleteProvisioningRule(ctx, testProvisioningRuleID) - }, - }, - { - name: "find provisioning rule", - run: func() error { - _, runErr := store.FindProvisioningRule(ctx, testProvisioningRuleID) - - return runErr - }, - }, - { - name: "list provisioning rules", - run: func() error { - _, runErr := store.ListProvisioningRules(ctx) - - return runErr - }, - }, - { - name: "find provider", - run: func() error { - _, runErr := store.FindProvider(ctx, "https://issuer.example") - - return runErr - }, - }, - { - name: "find passkey user by principal", - run: func() error { - _, runErr := store.FindUserByPrincipal(ctx, registration.User.RPID, principal.ID) - - return runErr - }, - }, - { - name: "find passkey user by handle", - run: func() error { - _, runErr := store.FindUserByHandle(ctx, registration.User.RPID, registration.User.Handle) - - return runErr - }, - }, - { - name: "list passkey credentials", - run: func() error { - _, runErr := store.ListCredentials(ctx, registration.User.RPID, registration.User.Handle) - - return runErr - }, - }, - { - name: "create passkey registration", - run: func() error { - _, runErr := store.CreateRegistration(ctx, passkeyRegistration(principal.ID, "credential-2")) - - return runErr - }, - }, - { - name: "update passkey credential after login", - run: func() error { - return store.UpdateCredentialAfterLogin(ctx, registration.Credential) - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - require.ErrorIs(t, tt.run(), context.Canceled) - }) - } - }) - - t.Run("token storage", func(t *testing.T) { - store := newStore(t) - principal := createPrincipal(t, store) - now := fixedStoreTime() - usedAt := now.Add(time.Hour) - wantUsedAt := usedAt - token := tokenFixture(now, principal.ID) - token.LastUsedAt = &usedAt - - require.NoError(t, store.CreateToken(context.Background(), token)) - *token.LastUsedAt = now.Add(time.Minute) - - found, err := store.FindToken(context.Background(), token.ID) - require.NoError(t, err) - require.NotNil(t, found.LastUsedAt) - assert.Equal(t, wantUsedAt, *found.LastUsedAt) - assert.Equal(t, token.SecretHash, found.SecretHash) - - *found.LastUsedAt = now.Add(time.Minute) - foundAgain, err := store.FindToken(context.Background(), token.ID) - require.NoError(t, err) - require.NotNil(t, foundAgain.LastUsedAt) - assert.Equal(t, wantUsedAt, *foundAgain.LastUsedAt) - }) - - t.Run("token last used and revocation", func(t *testing.T) { - store := newStore(t) - principal := createPrincipal(t, store) - now := fixedStoreTime() - token := createToken(t, store, now, principal.ID) - usedAt := now.Add(time.Hour) - revokedAt := now.Add(time.Hour + time.Minute) - - require.NoError(t, store.UpdateTokenLastUsed(context.Background(), token.ID, usedAt)) - require.NoError(t, store.RevokeToken(context.Background(), token.ID, revokedAt)) - - found, err := store.FindToken(context.Background(), token.ID) - require.NoError(t, err) - require.NotNil(t, found.LastUsedAt) - require.NotNil(t, found.RevokedAt) - assert.Equal(t, usedAt, *found.LastUsedAt) - assert.Equal(t, revokedAt, *found.RevokedAt) - - *found.LastUsedAt = now - *found.RevokedAt = now - foundAgain, err := store.FindToken(context.Background(), token.ID) - require.NoError(t, err) - require.NotNil(t, foundAgain.LastUsedAt) - require.NotNil(t, foundAgain.RevokedAt) - assert.Equal(t, usedAt, *foundAgain.LastUsedAt) - assert.Equal(t, revokedAt, *foundAgain.RevokedAt) - }) - - t.Run("list principal token metadata", func(t *testing.T) { - store := newStore(t) - first := createPrincipal(t, store) - second := createPrincipal(t, store) - now := fixedStoreTime() - firstToken := createToken(t, store, now, first.ID) - secondToken := tokenFixture(now, first.ID) - secondToken.ID = "token_2" - secondToken.Name = "second token" - require.NoError(t, store.CreateToken(context.Background(), secondToken)) - otherToken := tokenFixture(now, second.ID) - otherToken.ID = "token_3" - require.NoError(t, store.CreateToken(context.Background(), otherToken)) - usedAt := now.Add(time.Hour) - revokedAt := now.Add(secondTokenRevokeOffset) - require.NoError(t, store.UpdateTokenLastUsed(context.Background(), secondToken.ID, usedAt)) - require.NoError(t, store.RevokeToken(context.Background(), secondToken.ID, revokedAt)) - - tokens, err := store.ListPrincipalTokenMetadata(context.Background(), first.ID) - require.NoError(t, err) - require.Len(t, tokens, listedTokenMetadataCount) - assert.Equal(t, apikey.TokenMetadata{ - ID: firstToken.ID, - PrincipalID: first.ID, - Name: firstToken.Name, - ExpiresAt: firstToken.ExpiresAt, - }, tokens[0]) - assert.Equal(t, secondToken.ID, tokens[1].ID) - assert.Equal(t, first.ID, tokens[1].PrincipalID) - assert.Equal(t, secondToken.Name, tokens[1].Name) - assert.Equal(t, secondToken.ExpiresAt, tokens[1].ExpiresAt) - require.NotNil(t, tokens[1].LastUsedAt) - require.NotNil(t, tokens[1].RevokedAt) - assert.Equal(t, usedAt, *tokens[1].LastUsedAt) - assert.Equal(t, revokedAt, *tokens[1].RevokedAt) - - *tokens[1].LastUsedAt = now - *tokens[1].RevokedAt = now - listedAgain, err := store.ListPrincipalTokenMetadata(context.Background(), first.ID) - require.NoError(t, err) - require.Len(t, listedAgain, listedTokenMetadataCount) - require.NotNil(t, listedAgain[1].LastUsedAt) - require.NotNil(t, listedAgain[1].RevokedAt) - assert.Equal(t, usedAt, *listedAgain[1].LastUsedAt) - assert.Equal(t, revokedAt, *listedAgain[1].RevokedAt) - }) - - 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, - store.UpdateTokenLastUsed(context.Background(), "missing", now), - apikey.ErrTokenNotFound, - ) - require.ErrorIs(t, store.RevokeToken(context.Background(), "missing", now), apikey.ErrTokenNotFound) - - tokens, err := store.ListPrincipalTokenMetadata(context.Background(), "missing") - require.ErrorIs(t, err, authkit.ErrPrincipalNotFound) - assert.Nil(t, tokens) - }) - - t.Run("api token service integration", func(t *testing.T) { - now := fixedStoreTime() - store := newStore(t) - 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, err := service.IssueToken(context.Background(), apikey.IssueRequest{ - PrincipalID: principal.ID, - Name: "deploy token", - ExpiresAt: now.Add(time.Hour), - }) - require.NoError(t, err) - - verified, err := service.VerifyAPIToken(context.Background(), issued.Plaintext) - require.NoError(t, err) - assert.Equal(t, apikey.VerifiedToken{ - ID: issued.ID, - PrincipalID: principal.ID, - ExpiresAt: issued.ExpiresAt, - }, verified) - }) -} - -func createPrincipal(t *testing.T, store Store) authkit.Principal { - t.Helper() - - principal, err := store.CreatePrincipal(context.Background(), authkit.CreatePrincipalRequest{ - Kind: authkit.PrincipalKindUser, - DisplayName: testDisplayName, - Attributes: map[string]any{ - "role": "operator", - }, - }) - require.NoError(t, err) - - return principal -} - -func createRole(t *testing.T, store Store, roleID string) authkit.Role { - t.Helper() - - role, err := store.CreateRole(context.Background(), authkit.CreateRoleRequest{ - ID: roleID, - DisplayName: "Notes reader", - Description: "Can read notes.", - }) - require.NoError(t, err) - - return role -} - -func trustProvider(t *testing.T, store Store, provider oidc.Provider) oidc.Provider { - t.Helper() - - trusted, err := store.TrustProvider(context.Background(), provider) - require.NoError(t, err) - - return trusted -} - -func roleRequest() authkit.CreateRoleRequest { - return authkit.CreateRoleRequest{ - ID: testRoleID, - DisplayName: "Notes reader", - Description: "Can read notes.", - } -} - -func providerFixture() oidc.Provider { - return oidc.Provider{ - Issuer: "https://issuer.example", - Audiences: []string{"notes-api"}, - JWKSURL: "https://issuer.example/.well-known/jwks.json", - SupportedSigningAlgorithms: []string{"RS256"}, - ForwardedClaims: []authkit.ClaimPath{ - {"groups"}, - {"realm_access", "roles"}, - }, - } -} - -func provisioningRuleRequest() authkit.CreateProvisioningRuleRequest { - return authkit.CreateProvisioningRuleRequest{ - ID: testProvisioningRuleID, - DisplayName: "Engineering readers", - Provider: providerFixture().Issuer, - Condition: `hasAny(claims.groups, ["/engineering"])`, - AssignRoleIDs: []string{testRoleID}, - Enabled: true, - } -} - -func provisionRequest() authkit.ProvisionIdentityRequest { - return authkit.ProvisionIdentityRequest{ - Identity: authkit.Identity{ - Provider: testProvider, - Subject: testSubject, - }, - Principal: authkit.CreatePrincipalRequest{ - Kind: authkit.PrincipalKindUser, - DisplayName: testDisplayName, - Attributes: map[string]any{ - "email": "ada@example.test", - }, - }, - } -} - -func createToken(t *testing.T, store Store, now time.Time, principalID string) apikey.StoredToken { - t.Helper() - - token := tokenFixture(now, principalID) - require.NoError(t, store.CreateToken(context.Background(), token)) - - return token -} - -func tokenFixture(now time.Time, principalID string) apikey.StoredToken { - return apikey.StoredToken{ - ID: "token_1", - PrincipalID: principalID, - Name: "deploy", - SecretHash: sha256.Sum256([]byte("secret")), - ExpiresAt: now.Add(time.Hour), - } -} - -func passkeyRegistration(principalID string, credentialID string) passkey.Registration { - userHandle := []byte("passkey-user-handle-1") - credentialIDBytes := []byte(credentialID) - user := passkey.User{ - RPID: "example.test", - PrincipalID: principalID, - Handle: userHandle, - Name: "ada@example.test", - DisplayName: testDisplayName, - } - - return passkey.Registration{ - User: user, - Credential: passkey.Credential{ - RPID: user.RPID, - PrincipalID: user.PrincipalID, - UserHandle: append([]byte(nil), userHandle...), - CredentialID: credentialIDBytes, - WebAuthn: webauthn.Credential{ - ID: append([]byte(nil), credentialIDBytes...), - PublicKey: []byte("public-key-" + credentialID), - Authenticator: webauthn.Authenticator{ - AAGUID: []byte("authenticator-aaguid"), - SignCount: 1, - }, - }, - }, - Identity: authkit.Identity{ - Provider: "passkey:" + user.RPID, - Subject: passkeyUserSubject(userHandle), - CredentialID: passkeyCredentialSubject(credentialIDBytes), - }, - } -} - -func passkeyUserSubject(handle []byte) string { - return base64.RawURLEncoding.EncodeToString(handle) -} - -func passkeyCredentialSubject(credentialID []byte) string { - return base64.RawURLEncoding.EncodeToString(credentialID) -} - -func fixedStoreTime() time.Time { - return time.Date(2026, time.May, 7, 18, 0, 0, 0, time.UTC) + runPrincipalSuite(t, newStore) + runRoleSuite(t, newStore) + runProvisioningRuleSuite(t, newStore) + runIdentitySuite(t, newStore) + runPasskeySuite(t, newStore) + runOIDCSuite(t, newStore) + runTokenSuite(t, newStore) + runContextCancellationSuite(t, newStore) } diff --git a/internal/storetest/tokens.go b/internal/storetest/tokens.go new file mode 100644 index 0000000..5132763 --- /dev/null +++ b/internal/storetest/tokens.go @@ -0,0 +1,172 @@ +package storetest + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/meigma/authkit" + "github.com/meigma/authkit/apikey" +) + +// runTokenSuite exercises API token storage including create, find, last-used +// timestamps, revocation, metadata listing, missing-token errors, and the +// apikey service integration over the same store. +// +//nolint:funlen // Domain sub-suite groups related t.Run blocks so the full token contract reads top-to-bottom in one file. +func runTokenSuite(t *testing.T, newStore func(t *testing.T) Store) { + t.Helper() + + t.Run("token storage", func(t *testing.T) { + store := newStore(t) + principal := createPrincipal(t, store) + now := fixedStoreTime() + usedAt := now.Add(time.Hour) + wantUsedAt := usedAt + token := tokenFixture(now, principal.ID) + token.LastUsedAt = &usedAt + + require.NoError(t, store.CreateToken(context.Background(), token)) + *token.LastUsedAt = now.Add(time.Minute) + + found, err := store.FindToken(context.Background(), token.ID) + require.NoError(t, err) + require.NotNil(t, found.LastUsedAt) + assert.Equal(t, wantUsedAt, *found.LastUsedAt) + assert.Equal(t, token.SecretHash, found.SecretHash) + + *found.LastUsedAt = now.Add(time.Minute) + foundAgain, err := store.FindToken(context.Background(), token.ID) + require.NoError(t, err) + require.NotNil(t, foundAgain.LastUsedAt) + assert.Equal(t, wantUsedAt, *foundAgain.LastUsedAt) + }) + + t.Run("token last used and revocation", func(t *testing.T) { + store := newStore(t) + principal := createPrincipal(t, store) + now := fixedStoreTime() + token := createToken(t, store, now, principal.ID) + usedAt := now.Add(time.Hour) + revokedAt := now.Add(time.Hour + time.Minute) + + require.NoError(t, store.UpdateTokenLastUsed(context.Background(), token.ID, usedAt)) + require.NoError(t, store.RevokeToken(context.Background(), token.ID, revokedAt)) + + found, err := store.FindToken(context.Background(), token.ID) + require.NoError(t, err) + require.NotNil(t, found.LastUsedAt) + require.NotNil(t, found.RevokedAt) + assert.Equal(t, usedAt, *found.LastUsedAt) + assert.Equal(t, revokedAt, *found.RevokedAt) + + *found.LastUsedAt = now + *found.RevokedAt = now + foundAgain, err := store.FindToken(context.Background(), token.ID) + require.NoError(t, err) + require.NotNil(t, foundAgain.LastUsedAt) + require.NotNil(t, foundAgain.RevokedAt) + assert.Equal(t, usedAt, *foundAgain.LastUsedAt) + assert.Equal(t, revokedAt, *foundAgain.RevokedAt) + }) + + t.Run("list principal token metadata", func(t *testing.T) { + store := newStore(t) + first := createPrincipal(t, store) + second := createPrincipal(t, store) + now := fixedStoreTime() + firstToken := createToken(t, store, now, first.ID) + secondToken := tokenFixture(now, first.ID) + secondToken.ID = "token_2" + secondToken.Name = "second token" + require.NoError(t, store.CreateToken(context.Background(), secondToken)) + otherToken := tokenFixture(now, second.ID) + otherToken.ID = "token_3" + require.NoError(t, store.CreateToken(context.Background(), otherToken)) + usedAt := now.Add(time.Hour) + revokedAt := now.Add(secondTokenRevokeOffset) + require.NoError(t, store.UpdateTokenLastUsed(context.Background(), secondToken.ID, usedAt)) + require.NoError(t, store.RevokeToken(context.Background(), secondToken.ID, revokedAt)) + + tokens, err := store.ListPrincipalTokenMetadata(context.Background(), first.ID) + require.NoError(t, err) + require.Len(t, tokens, listedTokenMetadataCount) + assert.Equal(t, apikey.TokenMetadata{ + ID: firstToken.ID, + PrincipalID: first.ID, + Name: firstToken.Name, + ExpiresAt: firstToken.ExpiresAt, + }, tokens[0]) + assert.Equal(t, secondToken.ID, tokens[1].ID) + assert.Equal(t, first.ID, tokens[1].PrincipalID) + assert.Equal(t, secondToken.Name, tokens[1].Name) + assert.Equal(t, secondToken.ExpiresAt, tokens[1].ExpiresAt) + require.NotNil(t, tokens[1].LastUsedAt) + require.NotNil(t, tokens[1].RevokedAt) + assert.Equal(t, usedAt, *tokens[1].LastUsedAt) + assert.Equal(t, revokedAt, *tokens[1].RevokedAt) + + *tokens[1].LastUsedAt = now + *tokens[1].RevokedAt = now + listedAgain, err := store.ListPrincipalTokenMetadata(context.Background(), first.ID) + require.NoError(t, err) + require.Len(t, listedAgain, listedTokenMetadataCount) + require.NotNil(t, listedAgain[1].LastUsedAt) + require.NotNil(t, listedAgain[1].RevokedAt) + assert.Equal(t, usedAt, *listedAgain[1].LastUsedAt) + assert.Equal(t, revokedAt, *listedAgain[1].RevokedAt) + }) + + 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, + store.UpdateTokenLastUsed(context.Background(), "missing", now), + apikey.ErrTokenNotFound, + ) + require.ErrorIs(t, store.RevokeToken(context.Background(), "missing", now), apikey.ErrTokenNotFound) + + tokens, err := store.ListPrincipalTokenMetadata(context.Background(), "missing") + require.ErrorIs(t, err, authkit.ErrPrincipalNotFound) + assert.Nil(t, tokens) + }) + + t.Run("api token service integration", func(t *testing.T) { + now := fixedStoreTime() + store := newStore(t) + 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, err := service.IssueToken(context.Background(), apikey.IssueRequest{ + PrincipalID: principal.ID, + Name: "deploy token", + ExpiresAt: now.Add(time.Hour), + }) + require.NoError(t, err) + + verified, err := service.VerifyAPIToken(context.Background(), issued.Plaintext) + require.NoError(t, err) + assert.Equal(t, apikey.VerifiedToken{ + ID: issued.ID, + PrincipalID: principal.ID, + ExpiresAt: issued.ExpiresAt, + }, verified) + }) +}