Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .mockery.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -127,3 +127,10 @@ packages:
pkgname: authkitmocks
dir: mocks/authkit
filename: identity_linker.go
IdentityProvisioner:
config:
template: testify
structname: IdentityProvisioner
pkgname: authkitmocks
dir: mocks/authkit
filename: identity_provisioner.go
105 changes: 105 additions & 0 deletions mocks/authkit/identity_provisioner.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

79 changes: 79 additions & 0 deletions onboarding/attach.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
package onboarding

import (
"context"
"errors"
"fmt"

"github.com/meigma/authkit"
)

// Attacher links a verified external identity to an existing internal principal.
type Attacher struct {
finder authkit.PrincipalFinder
linker authkit.IdentityLinker
}

// NewAttacher constructs an Attacher from the ports it requires.
func NewAttacher(finder authkit.PrincipalFinder, linker authkit.IdentityLinker) *Attacher {
return &Attacher{
finder: finder,
linker: linker,
}
}

// AttachIdentity finds the named principal then links the verified identity to it.
//
// Both steps must succeed: a missing principal short-circuits before any link
// is recorded, so a failed attach never leaves a dangling external identity.
func (a *Attacher) AttachIdentity(
ctx context.Context,
req AttachIdentityRequest,
) (AttachIdentityResult, error) {
// Validate external input before touching any port so a malformed identity
// never reaches the store and never produces a partial side effect.
if err := validateIdentity(req.Identity); err != nil {
return AttachIdentityResult{}, err
}
if req.PrincipalID == "" {
return AttachIdentityResult{}, errors.New("onboarding: principal ID is required")
}

principal, err := a.finder.FindPrincipal(ctx, req.PrincipalID)
if err != nil {
return AttachIdentityResult{}, fmt.Errorf("onboarding: find principal: %w", err)
}

link, err := a.linker.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
}

// 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 recorded for Principal.
Link authkit.ExternalIdentity
}
128 changes: 128 additions & 0 deletions onboarding/attach_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
package onboarding_test

import (
"context"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"

"github.com/meigma/authkit"
authkitmocks "github.com/meigma/authkit/mocks/authkit"
"github.com/meigma/authkit/onboarding"
)

func TestAttachIdentityValidatesRequest(t *testing.T) {
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) {
// Mocks pass through unused — a stray port call would panic the test.
finder := authkitmocks.NewPrincipalFinder(t)
linker := authkitmocks.NewIdentityLinker(t)
attacher := onboarding.NewAttacher(finder, linker)

result, err := attacher.AttachIdentity(context.Background(), tt.req)

require.EqualError(t, err, tt.want)
assert.Empty(t, result)
})
}
}

func TestAttachIdentityFindsPrincipalThenLinksIdentity(t *testing.T) {
finder := authkitmocks.NewPrincipalFinder(t)
finder.EXPECT().FindPrincipal(mock.Anything, testPrincipalID).Return(testPrincipal(), nil)
linker := authkitmocks.NewIdentityLinker(t)
linker.EXPECT().LinkIdentity(mock.Anything, authkit.LinkIdentityRequest{
Provider: testProvider,
Subject: testSubject,
PrincipalID: testPrincipalID,
}).Return(testLink(), nil)
attacher := onboarding.NewAttacher(finder, linker)

result, err := attacher.AttachIdentity(context.Background(), attachRequest())

require.NoError(t, err)
assert.Equal(t, testPrincipal(), result.Principal)
assert.Equal(t, testLink(), result.Link)
}

func TestAttachIdentityPropagatesPrincipalNotFound(t *testing.T) {
finder := authkitmocks.NewPrincipalFinder(t)
finder.EXPECT().
FindPrincipal(mock.Anything, testPrincipalID).
Return(authkit.Principal{}, authkit.ErrPrincipalNotFound)
// Linker stays unconfigured: a stray LinkIdentity call panics the test.
linker := authkitmocks.NewIdentityLinker(t)
attacher := onboarding.NewAttacher(finder, linker)

result, err := attacher.AttachIdentity(context.Background(), attachRequest())

require.ErrorIs(t, err, authkit.ErrPrincipalNotFound)
assert.Empty(t, result)
}

func TestAttachIdentityPropagatesLinkConflict(t *testing.T) {
finder := authkitmocks.NewPrincipalFinder(t)
finder.EXPECT().FindPrincipal(mock.Anything, testPrincipalID).Return(testPrincipal(), nil)
linker := authkitmocks.NewIdentityLinker(t)
linker.EXPECT().
LinkIdentity(mock.Anything, mock.Anything).
Return(authkit.ExternalIdentity{}, errLinkConflict)
attacher := onboarding.NewAttacher(finder, linker)

result, err := attacher.AttachIdentity(context.Background(), attachRequest())

require.ErrorIs(t, err, errLinkConflict)
assert.Empty(t, result)
}

func TestAttacherPropagatesContextCancellation(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()

finder := authkitmocks.NewPrincipalFinder(t)
finder.EXPECT().
FindPrincipal(mock.Anything, testPrincipalID).
Return(authkit.Principal{}, context.Canceled)
linker := authkitmocks.NewIdentityLinker(t)
attacher := onboarding.NewAttacher(finder, linker)

_, err := attacher.AttachIdentity(ctx, attachRequest())

require.ErrorIs(t, err, context.Canceled)
}
18 changes: 13 additions & 5 deletions onboarding/doc.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,15 @@
// Package onboarding coordinates explicit identity attachment and principal provisioning.
// Package onboarding binds verified external identities to internal principals.
//
// 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.
// Credential proof packages authenticate or verify provider-specific material
// and return authkit.Identity values. Onboarding then offers two alternative
// workflows for turning those identities into principals:
//
// - Attacher links a verified identity to an existing principal. Use it
// when a signed-in user adds another credential to their account.
// - Provisioner creates a new principal for a verified identity. Use it
// as the explicit entry point when an app admits a new user on first proof.
//
// Both flows validate the identity before any side effects. They are
// alternatives, not collaborators, so each type takes only the ports it
// needs.
package onboarding
Loading