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
87 changes: 87 additions & 0 deletions store/memory/clone.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
package memory

import (
"maps"

"github.com/meigma/authkit"
"github.com/meigma/authkit/oidc"
)

// cloneAttributes returns an independent copy of attrs. A nil or empty map
// returns nil so an empty principal carries no allocated attribute map.
func cloneAttributes(attrs map[string]any) map[string]any {
if len(attrs) == 0 {
return nil
}

cloned := make(map[string]any, len(attrs))
maps.Copy(cloned, attrs)

return cloned
}

// clonePrincipal returns a copy of principal with an independent Attributes
// map.
func clonePrincipal(principal authkit.Principal) authkit.Principal {
principal.Attributes = cloneAttributes(principal.Attributes)

return principal
}

// cloneProvider returns a copy of provider with independent Audiences,
// SupportedSigningAlgorithms, and ForwardedClaims slices.
func cloneProvider(provider oidc.Provider) oidc.Provider {
provider.Audiences = cloneStrings(provider.Audiences)
provider.SupportedSigningAlgorithms = cloneStrings(provider.SupportedSigningAlgorithms)
provider.ForwardedClaims = cloneClaimPaths(provider.ForwardedClaims)

return provider
}

// cloneProvisioningRule returns a copy of rule with an independent
// AssignRoleIDs slice.
func cloneProvisioningRule(rule authkit.ProvisioningRule) authkit.ProvisioningRule {
rule.AssignRoleIDs = cloneStrings(rule.AssignRoleIDs)

return rule
}

// cloneStrings returns an independent copy of values. A nil or empty input
// returns nil.
func cloneStrings(values []string) []string {
if len(values) == 0 {
return nil
}

cloned := make([]string, len(values))
copy(cloned, values)

return cloned
}

// cloneClaimPaths returns an independent copy of paths, with every inner
// ClaimPath copied as well.
func cloneClaimPaths(paths []authkit.ClaimPath) []authkit.ClaimPath {
if len(paths) == 0 {
return nil
}

cloned := make([]authkit.ClaimPath, len(paths))
for i, path := range paths {
cloned[i] = cloneClaimPath(path)
}

return cloned
}

// cloneClaimPath returns an independent copy of path.
func cloneClaimPath(path authkit.ClaimPath) authkit.ClaimPath {
if len(path) == 0 {
return nil
}

cloned := make(authkit.ClaimPath, len(path))
copy(cloned, path)

return cloned
}
33 changes: 33 additions & 0 deletions store/memory/helpers_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package memory

import (
"context"
"testing"

"github.com/stretchr/testify/require"

"github.com/meigma/authkit"
)

const (
testProvider = "oidc"
testSubject = "user-123"
testDisplayName = "Ada Lovelace"
)

// createPrincipal creates a user principal with the standard test display
// name and attributes, failing the test on error.
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
}
185 changes: 185 additions & 0 deletions store/memory/identity.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
package memory

import (
"context"
"errors"
"fmt"
"strconv"

"github.com/meigma/authkit"
)

// LinkIdentity links an external identity to an existing principal. Linking
// the same identity to the same principal twice is idempotent; linking the
// same identity to a different principal returns an error so a credential
// rebind never silently swaps principals.
func (s *Store) LinkIdentity(ctx context.Context, req authkit.LinkIdentityRequest) (authkit.ExternalIdentity, error) {
if err := ctx.Err(); err != nil {
return authkit.ExternalIdentity{}, err
}

if req.Provider == "" {
return authkit.ExternalIdentity{}, errors.New("memory: provider is required")
}
if req.Subject == "" {
return authkit.ExternalIdentity{}, errors.New("memory: subject is required")
}
if req.PrincipalID == "" {
return authkit.ExternalIdentity{}, errors.New("memory: principal ID is required")
}

s.mu.Lock()
defer s.mu.Unlock()

if _, ok := s.principals[req.PrincipalID]; !ok {
return authkit.ExternalIdentity{}, fmt.Errorf("memory: principal %q does not exist", req.PrincipalID)
}

key := identityKey{
provider: req.Provider,
subject: req.Subject,
}
if link, ok := s.links[key]; ok {
if link.PrincipalID == req.PrincipalID {
return link, nil
}

// Cross-principal rebind is rejected to keep external identities
// pinned to one principal; an admin must unlink first.
return authkit.ExternalIdentity{}, fmt.Errorf(
"memory: identity %q/%q is already linked to principal %q",
req.Provider,
req.Subject,
link.PrincipalID,
)
}

link := authkit.ExternalIdentity(req)
s.links[key] = link

return link, nil
}

// ResolveIdentity returns the principal linked to identity, or wraps
// `authkit.ErrUnresolvedIdentity` when the identity is missing required
// fields, has no link, or points at a missing principal.
func (s *Store) ResolveIdentity(ctx context.Context, identity authkit.Identity) (*authkit.Principal, error) {
if err := ctx.Err(); err != nil {
return nil, err
}

if identity.Provider == "" || identity.Subject == "" {
return nil, fmt.Errorf("%w: provider and subject are required", authkit.ErrUnresolvedIdentity)
}

s.mu.RLock()
defer s.mu.RUnlock()

link, ok := s.links[identityKey{
provider: identity.Provider,
subject: identity.Subject,
}]
if !ok {
return nil, fmt.Errorf(
"%w: identity %q/%q is not linked",
authkit.ErrUnresolvedIdentity,
identity.Provider,
identity.Subject,
)
}

principal, ok := s.principals[link.PrincipalID]
if !ok {
return nil, fmt.Errorf(
"%w: linked principal %q does not exist",
authkit.ErrUnresolvedIdentity,
link.PrincipalID,
)
}

resolved := clonePrincipal(principal)

return &resolved, nil
}

// ProvisionIdentity creates a principal and links the supplied external
// identity to it in one atomic step, or returns the existing link when the
// identity is already provisioned. Initial roles, when supplied, are assigned
// to the newly created principal; existing principals are never mutated by a
// subsequent ProvisionIdentity call.
//
// The write lock is held for the entire operation so concurrent provisioning
// of the same identity converges on exactly one principal even when many
// callers race.
func (s *Store) ProvisionIdentity(
ctx context.Context,
req authkit.ProvisionIdentityRequest,
) (authkit.ProvisionIdentityResult, error) {
if err := ctx.Err(); err != nil {
return authkit.ProvisionIdentityResult{}, err
}
if req.Identity.Provider == "" || req.Identity.Subject == "" {
return authkit.ProvisionIdentityResult{}, fmt.Errorf(
"%w: provider and subject are required",
authkit.ErrUnresolvedIdentity,
)
}
if req.Principal.Kind != authkit.PrincipalKindUser && req.Principal.Kind != authkit.PrincipalKindService {
return authkit.ProvisionIdentityResult{}, fmt.Errorf(
"memory: unsupported principal kind %q",
req.Principal.Kind,
)
}

s.mu.Lock()
defer s.mu.Unlock()

key := identityKey{
provider: req.Identity.Provider,
subject: req.Identity.Subject,
}
// Existing-link short-circuit: never mutate the principal or its roles for
// an already-provisioned identity.
if link, ok := s.links[key]; ok {
principal, ok := s.principals[link.PrincipalID]
if !ok {
return authkit.ProvisionIdentityResult{}, fmt.Errorf(
"%w: linked principal %q does not exist",
authkit.ErrUnresolvedIdentity,
link.PrincipalID,
)
}

return authkit.ProvisionIdentityResult{
Principal: clonePrincipal(principal),
Link: link,
Created: false,
}, nil
}
if err := s.validateInitialRolesLocked(req.InitialRoleIDs); err != nil {
return authkit.ProvisionIdentityResult{}, err
}

principal := authkit.Principal{
ID: principalIDPrefix + strconv.Itoa(s.nextPrincipalNumber),
Kind: req.Principal.Kind,
DisplayName: req.Principal.DisplayName,
Attributes: cloneAttributes(req.Principal.Attributes),
}
s.nextPrincipalNumber++
s.principals[principal.ID] = principal

link := authkit.ExternalIdentity{
Provider: req.Identity.Provider,
Subject: req.Identity.Subject,
PrincipalID: principal.ID,
}
s.links[key] = link
assignInitialRoles(s.principalRoles, principal.ID, req.InitialRoleIDs)

return authkit.ProvisionIdentityResult{
Principal: clonePrincipal(principal),
Link: link,
Created: true,
}, nil
}
Loading