From ef64a50e9afbf17e915383edd2af54bbc2c537c5 Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Wed, 13 May 2026 19:43:57 -0700 Subject: [PATCH] feat(onboarding): add identity onboarding foundation --- docs/ARCH_REFACTOR.md | 174 ++++++++++ docs/docs/explanations/architecture.md | 14 + docs/docs/reference/extension-points.md | 12 + onboarding/doc.go | 7 + onboarding/service.go | 102 ++++++ onboarding/service_test.go | 428 ++++++++++++++++++++++++ onboarding/types.go | 57 ++++ 7 files changed, 794 insertions(+) create mode 100644 docs/ARCH_REFACTOR.md create mode 100644 onboarding/doc.go create mode 100644 onboarding/service.go create mode 100644 onboarding/service_test.go create mode 100644 onboarding/types.go diff --git a/docs/ARCH_REFACTOR.md b/docs/ARCH_REFACTOR.md new file mode 100644 index 0000000..791e9dc --- /dev/null +++ b/docs/ARCH_REFACTOR.md @@ -0,0 +1,174 @@ +# Temporary Architecture Refactor Notes + +This is a temporary working document. It is not intended to become permanent +reference documentation in this form. Its job is to capture the proposed +architecture direction before the next implementation slices refine the details. + +## Proposal + +authkit should move toward a model where external authentication methods are +explicit ingress paths into authkit-owned principal credentials. + +The motivation is expansion. authkit started with API-style bearer +authentication, but the library is moving toward broader API and web-service +use. Passkeys, browser OIDC login, OIDC bearer exchange, manually issued API +tokens, and future credential methods do not share the same proof ceremony. +They can, however, share the same destination: a verified identity becomes an +authkit principal relationship and an authkit-owned credential used for normal +API access. + +Today, the core request path is: + +```text +Authenticator -> Identity -> PrincipalResolver -> Principal -> Authorizer +``` + +That shape remains valuable. The change is where external protocols participate. +Instead of every protected API request carrying an external credential, authkit +should treat external credentials as proof material for an explicit exchange or +onboarding flow. Successful exchange produces an authkit-owned credential that +is already coupled to an internal principal. + +The future shape is: + +```text +external proof -> authkit.Identity -> onboarding/exchange -> authkit credential -> Principal +``` + +Normal API requests then use the authkit credential: + +```text +authkit credential -> PrincipalResolver -> Principal -> Authorizer +``` + +## Why This Matters + +This keeps runtime authentication side-effect-free. An ordinary protected route +should not create principals, attach new external identities, or evaluate +onboarding policy while handling business traffic. + +It also gives different credential methods the same destination. API tokens, +OIDC login, OIDC bearer exchange, passkeys, and future mechanisms can all prove +different things in different ways, but they should converge on an authkit-owned +credential linked to a principal. + +The purpose is not to force every credential method into one generic proof API. +OIDC bearer validation, passkey assertion verification, and other future +mechanisms have different inputs and different security ceremonies. The shared +boundary starts after proof succeeds and an `authkit.Identity` exists. + +## Existing Concepts To Preserve + +The proposal builds on existing authkit language instead of replacing it: + +- `authkit.Identity` remains the result of verified external proof. +- `authkit.ExternalIdentity` remains the durable relationship between a + provider-scoped identity and a principal. +- `authkit.Principal` remains the internal actor authorization uses. +- `authkit.PrincipalResolver` remains the runtime bridge from credential + identity to principal. +- `authkit.IdentityLinker` remains the low-level operation for attaching an + external identity to an existing principal. +- `authkit.IdentityProvisioner` remains the atomic create-and-link operation. +- `onboarding.Service` is the explicit helper for principal attachment and + provisioning flows, not a runtime authenticator. + +The new architectural center is not "OIDC" or "passkeys"; it is explicit +conversion from verified auth material into an authkit-owned credential and +principal relationship. + +## Identity Exchange + +The exchange boundary should be generalized around `authkit.Identity`, not +around HTTP or any one provider protocol. A future exchange service can accept a +verified identity, resolve or provision the corresponding principal, and issue +an authkit-owned credential. + +Conceptually: + +```text +authkit.Identity -> resolve/provision Principal -> issue authkit credential +``` + +Provider packages still own proof: + +```text +OIDC bearer token -> oidc.Authenticator -> authkit.Identity +passkey assertion -> passkey service -> authkit.Identity +future method -> method package -> authkit.Identity +``` + +The consumer still owns the HTTP endpoint shape, response body, rate limiting, +auditing, and any UI/session behavior. authkit provides the service-level tools +so those endpoints converge on the same principal and credential semantics. + +This avoids each provider package inventing its own exchange behavior. Without +a shared exchange layer, OIDC, passkeys, and future mechanisms could drift into +different provisioning rules, token issuance semantics, error expectations, and +principal-link handling. + +## OIDC Auto-Provisioning Reframed + +Current OIDC auto-provisioning happens when an API request arrives with a valid +external bearer token whose identity is not yet linked. The resolver may create +and link a principal after provisioning rules approve it. + +The proposed direction moves that behavior out of ordinary request +authentication. A service could expose a token exchange endpoint instead: + +```text +external OIDC bearer -> verify as authkit.Identity -> optional provisioning -> authkit bearer +``` + +After exchange, later API requests use the authkit bearer. Provisioning remains +optional, policy-driven, and explicit, but it no longer happens as a side effect +of normal API access. + +In code terms, the consumer-facing endpoint would verify external proof with +the OIDC package, then pass the resulting `authkit.Identity` into the shared +exchange service. The endpoint remains application-owned, but the exchange +semantics are authkit-owned. + +## Passkeys Fit The Same Shape + +Passkeys are not naturally bearer authenticators. They are an interactive proof +ceremony. In the proposed model, a passkey assertion is another exchange path: + +```text +passkey proof -> authkit.Identity -> onboarding/exchange -> authkit bearer or session credential +``` + +The browser UI, route shape, CSRF handling, cookies, and recovery flows stay +application-owned. authkit provides the backend tools to verify proof, bind the +result to a principal, and issue an authkit-owned credential. + +## Authkit-Owned Credentials + +This proposal implies authkit needs a first-party credential story distinct from +external credentials. The existing `apikey` package is close in spirit, but it +is currently shaped around manually issued opaque API tokens. Exchange-issued +credentials may need different lifetime, metadata, revocation, and transport +decisions. + +Those details should be worked out during implementation. The key architectural +point is that protected API requests should eventually depend on authkit-owned +credentials rather than directly depending on every possible external protocol. + +## Deferred Details + +This document intentionally does not settle every implementation detail. Future +slices still need to decide: + +- whether the authkit-owned credential is one token type or multiple related + credential/session types +- how short-lived bearer tokens, long-lived API tokens, and browser sessions + relate to each other +- how exchange endpoints should be packaged without becoming a built-in hosted + login product +- how existing OIDC bearer auto-provisioning should migrate, if at all +- how refresh, revocation, expiry, audit metadata, and token introspection should + work + +Those are implementation questions. The architectural direction is that many +external proof mechanisms can enter authkit, but normal authorization should +center on authkit-owned credentials linked to principals. diff --git a/docs/docs/explanations/architecture.md b/docs/docs/explanations/architecture.md index 2a24226..cf68262 100644 --- a/docs/docs/explanations/architecture.md +++ b/docs/docs/explanations/architecture.md @@ -79,6 +79,7 @@ Adapters sit at the edges: - `apikey` issues and verifies opaque API tokens. - `oidc` verifies signed JWT bearer tokens from trusted issuers. +- `onboarding` coordinates explicit identity attachment and principal provisioning. - `provisioning` can create principals for caller-approved unresolved identities. - `httpauth` adapts a pipeline to `net/http`. - `httpfacts` provides optional helpers for deriving facts from HTTP requests. @@ -110,6 +111,19 @@ subject = JWT sub A valid credential with no linked principal authenticates as a credential but does not become an application principal. +## Explicit Onboarding + +Credential method packages own proof and method-specific storage. After a +credential method verifies auth material and returns an `authkit.Identity`, +applications can use `onboarding.Service` to attach that identity to an existing +principal or provision a new principal for it. + +Onboarding is an explicit application flow. Authenticators and the runtime +pipeline do not create principals or attach identities while handling normal +authenticated requests. This keeps browser login, admin enrollment, recovery, +and trust checks in application-owned code while still reusing the same generic +identity link and principal provisioning ports. + ## Auto-Provisioning Auto-provisioning is an opt-in resolver behavior. A `provisioning.Resolver` diff --git a/docs/docs/reference/extension-points.md b/docs/docs/reference/extension-points.md index ba6abd5..91aa1f8 100644 --- a/docs/docs/reference/extension-points.md +++ b/docs/docs/reference/extension-points.md @@ -91,6 +91,18 @@ Links external identities to internal principals. The `management` package composes these ports with API-token issuing and revocation for setup workflows. +## Explicit Onboarding + +`onboarding.NewService` composes `authkit.PrincipalFinder`, +`authkit.IdentityLinker`, and `authkit.IdentityProvisioner` for application-owned +onboarding flows. + +Use `AttachIdentity` after an application has already verified that an existing +principal should receive a newly verified identity. Use `ProvisionPrincipal` +when application policy has approved creating a principal for a verified +identity. Credential method packages still own method-specific proof and +storage; onboarding only coordinates the generic identity relationship. + ## Auto-Provisioning `provisioning.NewResolver` wraps an existing `PrincipalResolver` with an diff --git a/onboarding/doc.go b/onboarding/doc.go new file mode 100644 index 0000000..76ce9cc --- /dev/null +++ b/onboarding/doc.go @@ -0,0 +1,7 @@ +// Package onboarding coordinates explicit identity attachment and principal provisioning. +// +// Credential method packages authenticate or verify method-specific material and +// return authkit.Identity values. Package onboarding helps applications bind +// those verified identities to principals without adding side effects to normal +// request authentication. +package onboarding diff --git a/onboarding/service.go b/onboarding/service.go new file mode 100644 index 0000000..d4cbd03 --- /dev/null +++ b/onboarding/service.go @@ -0,0 +1,102 @@ +package onboarding + +import ( + "context" + "errors" + "fmt" + + "github.com/meigma/authkit" +) + +// Service composes explicit identity attachment and principal provisioning flows. +type Service struct { + principalFinder authkit.PrincipalFinder + identityLinker authkit.IdentityLinker + identityProvisioner authkit.IdentityProvisioner +} + +// NewService constructs an onboarding service from opts. +func NewService(opts Options) *Service { + return &Service{ + principalFinder: opts.PrincipalFinder, + identityLinker: opts.IdentityLinker, + identityProvisioner: opts.IdentityProvisioner, + } +} + +// AttachIdentity links a verified identity to an existing principal. +func (s *Service) AttachIdentity( + ctx context.Context, + req AttachIdentityRequest, +) (AttachIdentityResult, error) { + if s.principalFinder == nil { + return AttachIdentityResult{}, errors.New("onboarding: principal finder is required") + } + if s.identityLinker == nil { + return AttachIdentityResult{}, errors.New("onboarding: identity linker is required") + } + if err := validateIdentity(req.Identity); err != nil { + return AttachIdentityResult{}, err + } + if req.PrincipalID == "" { + return AttachIdentityResult{}, errors.New("onboarding: principal ID is required") + } + + principal, err := s.principalFinder.FindPrincipal(ctx, req.PrincipalID) + if err != nil { + return AttachIdentityResult{}, fmt.Errorf("onboarding: find principal: %w", err) + } + + link, err := s.identityLinker.LinkIdentity(ctx, authkit.LinkIdentityRequest{ + Provider: req.Identity.Provider, + Subject: req.Identity.Subject, + PrincipalID: req.PrincipalID, + }) + if err != nil { + return AttachIdentityResult{}, fmt.Errorf("onboarding: link identity: %w", err) + } + + return AttachIdentityResult{ + Principal: principal, + Link: link, + }, nil +} + +// ProvisionPrincipal creates or resolves a principal for a verified identity. +func (s *Service) ProvisionPrincipal( + ctx context.Context, + req ProvisionPrincipalRequest, +) (ProvisionPrincipalResult, error) { + if s.identityProvisioner == nil { + return ProvisionPrincipalResult{}, errors.New("onboarding: identity provisioner is required") + } + if err := validateIdentity(req.Identity); err != nil { + return ProvisionPrincipalResult{}, err + } + + result, err := s.identityProvisioner.ProvisionIdentity(ctx, authkit.ProvisionIdentityRequest{ + Identity: req.Identity, + Principal: req.Principal, + InitialRoleIDs: req.InitialRoleIDs, + }) + if err != nil { + return ProvisionPrincipalResult{}, fmt.Errorf("onboarding: provision principal: %w", err) + } + + return ProvisionPrincipalResult{ + Principal: result.Principal, + Link: result.Link, + Created: result.Created, + }, nil +} + +func validateIdentity(identity authkit.Identity) error { + if identity.Provider == "" { + return errors.New("onboarding: identity provider is required") + } + if identity.Subject == "" { + return errors.New("onboarding: identity subject is required") + } + + return nil +} diff --git a/onboarding/service_test.go b/onboarding/service_test.go new file mode 100644 index 0000000..69da072 --- /dev/null +++ b/onboarding/service_test.go @@ -0,0 +1,428 @@ +package onboarding_test + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/meigma/authkit" + "github.com/meigma/authkit/onboarding" +) + +const ( + testPrincipalID = "principal_1" + testProvider = "passkey" + testSubject = "user-handle-1" + testPrincipalName = "Test User" + testRoleID = "reader" +) + +var errLinkConflict = errors.New("identity already linked to another principal") + +func TestNewServiceAllowsSparseOptions(t *testing.T) { + tests := []struct { + name string + opts onboarding.Options + }{ + { + name: "no collaborators", + }, + { + name: "attach collaborators", + opts: onboarding.Options{ + PrincipalFinder: newFakePrincipalFinder(), + IdentityLinker: newFakeIdentityLinker(), + }, + }, + { + name: "provision collaborator", + opts: onboarding.Options{ + IdentityProvisioner: newFakeIdentityProvisioner(), + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + service := onboarding.NewService(tt.opts) + + assert.NotNil(t, service) + }) + } +} + +func TestServiceMethodsRequirePorts(t *testing.T) { + tests := []struct { + name string + opts onboarding.Options + run func(*onboarding.Service) error + want string + }{ + { + name: "attach identity missing principal finder", + run: func(service *onboarding.Service) error { + _, err := service.AttachIdentity(context.Background(), attachRequest()) + + return err + }, + want: "onboarding: principal finder is required", + }, + { + name: "attach identity missing identity linker", + opts: onboarding.Options{ + PrincipalFinder: newFakePrincipalFinder(), + }, + run: func(service *onboarding.Service) error { + _, err := service.AttachIdentity(context.Background(), attachRequest()) + + return err + }, + want: "onboarding: identity linker is required", + }, + { + name: "provision principal missing identity provisioner", + run: func(service *onboarding.Service) error { + _, err := service.ProvisionPrincipal(context.Background(), provisionRequest()) + + return err + }, + want: "onboarding: identity provisioner is required", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + service := onboarding.NewService(tt.opts) + + err := tt.run(service) + + require.Error(t, err) + assert.EqualError(t, err, tt.want) + }) + } +} + +func TestAttachIdentityValidatesRequest(t *testing.T) { + service := onboarding.NewService(onboarding.Options{ + PrincipalFinder: newFakePrincipalFinder(), + IdentityLinker: newFakeIdentityLinker(), + }) + + tests := []struct { + name string + req onboarding.AttachIdentityRequest + want string + }{ + { + name: "missing identity provider", + req: onboarding.AttachIdentityRequest{ + Identity: authkit.Identity{ + Subject: testSubject, + }, + PrincipalID: testPrincipalID, + }, + want: "onboarding: identity provider is required", + }, + { + name: "missing identity subject", + req: onboarding.AttachIdentityRequest{ + Identity: authkit.Identity{ + Provider: testProvider, + }, + PrincipalID: testPrincipalID, + }, + want: "onboarding: identity subject is required", + }, + { + name: "missing principal ID", + req: onboarding.AttachIdentityRequest{ + Identity: verifiedIdentity(), + }, + want: "onboarding: principal ID is required", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := service.AttachIdentity(context.Background(), tt.req) + + require.EqualError(t, err, tt.want) + assert.Empty(t, result) + }) + } +} + +func TestAttachIdentityFindsPrincipalThenLinksIdentity(t *testing.T) { + finder := newFakePrincipalFinder() + linker := newFakeIdentityLinker() + service := onboarding.NewService(onboarding.Options{ + PrincipalFinder: finder, + IdentityLinker: linker, + }) + req := attachRequest() + + result, err := service.AttachIdentity(context.Background(), req) + + require.NoError(t, err) + assert.Equal(t, finder.principal, result.Principal) + assert.Equal(t, linker.link, result.Link) + assert.Equal(t, []string{testPrincipalID}, finder.findIDs) + assert.Equal(t, []authkit.LinkIdentityRequest{ + { + Provider: testProvider, + Subject: testSubject, + PrincipalID: testPrincipalID, + }, + }, linker.requests) +} + +func TestAttachIdentityPropagatesPrincipalNotFound(t *testing.T) { + finder := newFakePrincipalFinder() + finder.err = authkit.ErrPrincipalNotFound + linker := newFakeIdentityLinker() + service := onboarding.NewService(onboarding.Options{ + PrincipalFinder: finder, + IdentityLinker: linker, + }) + + result, err := service.AttachIdentity(context.Background(), attachRequest()) + + require.ErrorIs(t, err, authkit.ErrPrincipalNotFound) + assert.Empty(t, result) + assert.Empty(t, linker.requests) +} + +func TestAttachIdentityPropagatesLinkConflict(t *testing.T) { + linker := newFakeIdentityLinker() + linker.err = errLinkConflict + service := onboarding.NewService(onboarding.Options{ + PrincipalFinder: newFakePrincipalFinder(), + IdentityLinker: linker, + }) + + result, err := service.AttachIdentity(context.Background(), attachRequest()) + + require.ErrorIs(t, err, errLinkConflict) + assert.Empty(t, result) +} + +func TestProvisionPrincipalValidatesIdentity(t *testing.T) { + service := onboarding.NewService(onboarding.Options{ + IdentityProvisioner: newFakeIdentityProvisioner(), + }) + + tests := []struct { + name string + req onboarding.ProvisionPrincipalRequest + want string + }{ + { + name: "missing identity provider", + req: onboarding.ProvisionPrincipalRequest{ + Identity: authkit.Identity{ + Subject: testSubject, + }, + }, + want: "onboarding: identity provider is required", + }, + { + name: "missing identity subject", + req: onboarding.ProvisionPrincipalRequest{ + Identity: authkit.Identity{ + Provider: testProvider, + }, + }, + want: "onboarding: identity subject is required", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := service.ProvisionPrincipal(context.Background(), tt.req) + + require.EqualError(t, err, tt.want) + assert.Empty(t, result) + }) + } +} + +func TestProvisionPrincipalDelegatesToIdentityProvisioner(t *testing.T) { + provisioner := newFakeIdentityProvisioner() + service := onboarding.NewService(onboarding.Options{ + IdentityProvisioner: provisioner, + }) + req := provisionRequest() + + result, err := service.ProvisionPrincipal(context.Background(), req) + + require.NoError(t, err) + assert.Equal(t, provisioner.result.Principal, result.Principal) + assert.Equal(t, provisioner.result.Link, result.Link) + assert.True(t, result.Created) + assert.Equal(t, []authkit.ProvisionIdentityRequest{ + { + Identity: req.Identity, + Principal: req.Principal, + InitialRoleIDs: req.InitialRoleIDs, + }, + }, provisioner.requests) +} + +func TestServicePropagatesContextCancellation(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + t.Run("attach identity", func(t *testing.T) { + service := onboarding.NewService(onboarding.Options{ + PrincipalFinder: newFakePrincipalFinder(), + IdentityLinker: newFakeIdentityLinker(), + }) + + _, err := service.AttachIdentity(ctx, attachRequest()) + + require.ErrorIs(t, err, context.Canceled) + }) + + t.Run("provision principal", func(t *testing.T) { + service := onboarding.NewService(onboarding.Options{ + IdentityProvisioner: newFakeIdentityProvisioner(), + }) + + _, err := service.ProvisionPrincipal(ctx, provisionRequest()) + + require.ErrorIs(t, err, context.Canceled) + }) +} + +func verifiedIdentity() authkit.Identity { + return authkit.Identity{ + Provider: testProvider, + Subject: testSubject, + CredentialID: "credential_1", + } +} + +func testPrincipal() authkit.Principal { + return authkit.Principal{ + ID: testPrincipalID, + Kind: authkit.PrincipalKindUser, + DisplayName: testPrincipalName, + } +} + +func testLink() authkit.ExternalIdentity { + return authkit.ExternalIdentity{ + Provider: testProvider, + Subject: testSubject, + PrincipalID: testPrincipalID, + } +} + +func attachRequest() onboarding.AttachIdentityRequest { + return onboarding.AttachIdentityRequest{ + Identity: verifiedIdentity(), + PrincipalID: testPrincipalID, + } +} + +func provisionRequest() onboarding.ProvisionPrincipalRequest { + return onboarding.ProvisionPrincipalRequest{ + Identity: verifiedIdentity(), + Principal: authkit.CreatePrincipalRequest{ + Kind: authkit.PrincipalKindUser, + DisplayName: testPrincipalName, + }, + InitialRoleIDs: []string{testRoleID}, + } +} + +type fakePrincipalFinder struct { + findIDs []string + principal authkit.Principal + err error +} + +func newFakePrincipalFinder() *fakePrincipalFinder { + return &fakePrincipalFinder{ + principal: testPrincipal(), + } +} + +func (f *fakePrincipalFinder) FindPrincipal( + ctx context.Context, + id string, +) (authkit.Principal, error) { + if err := ctx.Err(); err != nil { + return authkit.Principal{}, err + } + + f.findIDs = append(f.findIDs, id) + if f.err != nil { + return authkit.Principal{}, f.err + } + + return f.principal, nil +} + +type fakeIdentityLinker struct { + requests []authkit.LinkIdentityRequest + link authkit.ExternalIdentity + err error +} + +func newFakeIdentityLinker() *fakeIdentityLinker { + return &fakeIdentityLinker{ + link: testLink(), + } +} + +func (f *fakeIdentityLinker) LinkIdentity( + ctx context.Context, + req authkit.LinkIdentityRequest, +) (authkit.ExternalIdentity, error) { + if err := ctx.Err(); err != nil { + return authkit.ExternalIdentity{}, err + } + + f.requests = append(f.requests, req) + if f.err != nil { + return authkit.ExternalIdentity{}, f.err + } + + return f.link, nil +} + +type fakeIdentityProvisioner struct { + requests []authkit.ProvisionIdentityRequest + result authkit.ProvisionIdentityResult + err error +} + +func newFakeIdentityProvisioner() *fakeIdentityProvisioner { + return &fakeIdentityProvisioner{ + result: authkit.ProvisionIdentityResult{ + Principal: testPrincipal(), + Link: testLink(), + Created: true, + }, + } +} + +func (f *fakeIdentityProvisioner) ProvisionIdentity( + ctx context.Context, + req authkit.ProvisionIdentityRequest, +) (authkit.ProvisionIdentityResult, error) { + if err := ctx.Err(); err != nil { + return authkit.ProvisionIdentityResult{}, err + } + + f.requests = append(f.requests, req) + if f.err != nil { + return authkit.ProvisionIdentityResult{}, f.err + } + + return f.result, nil +} diff --git a/onboarding/types.go b/onboarding/types.go new file mode 100644 index 0000000..824de45 --- /dev/null +++ b/onboarding/types.go @@ -0,0 +1,57 @@ +package onboarding + +import "github.com/meigma/authkit" + +// Options configures a Service. +type Options struct { + // PrincipalFinder finds existing principals before identity attachment. + PrincipalFinder authkit.PrincipalFinder + + // IdentityLinker links verified identities to existing principals. + IdentityLinker authkit.IdentityLinker + + // IdentityProvisioner creates and links principals for verified identities. + IdentityProvisioner authkit.IdentityProvisioner +} + +// AttachIdentityRequest describes a request to attach a verified identity to an existing principal. +type AttachIdentityRequest struct { + // Identity is the verified external identity to attach. + Identity authkit.Identity + + // PrincipalID identifies the existing principal receiving the identity link. + PrincipalID string +} + +// AttachIdentityResult describes the outcome of attaching an identity to a principal. +type AttachIdentityResult struct { + // Principal is the existing principal that received the identity link. + Principal authkit.Principal + + // Link is the external identity link created or confirmed for Principal. + Link authkit.ExternalIdentity +} + +// ProvisionPrincipalRequest describes a request to provision a principal for a verified identity. +type ProvisionPrincipalRequest struct { + // Identity is the verified external identity to provision. + Identity authkit.Identity + + // Principal describes the internal principal to create when Identity is not linked. + Principal authkit.CreatePrincipalRequest + + // InitialRoleIDs are local roles assigned only when a new principal is created. + InitialRoleIDs []string +} + +// ProvisionPrincipalResult describes the outcome of provisioning a principal for an identity. +type ProvisionPrincipalResult struct { + // Principal is the internal principal linked to the identity. + Principal authkit.Principal + + // Link is the external identity link for Principal. + Link authkit.ExternalIdentity + + // Created reports whether this call created a new principal and identity link. + Created bool +}