accountlinking

package
v1.3.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 23, 2026 License: MIT, MIT Imports: 14 Imported by: 0

README

gomodularauth-account-linking

gomodularauth-account-linking provides secure linking and unlinking of authentication accounts while preserving a single canonical Core User identity in GoModularAuth.

Installation

go get github.com/BladiCreator/go-modular-auth/plugins/account-linking

Quick Start


Detailed guide: doc/plugins/account-linking.md.

Documentation

Overview

Package accountlinking provides secure linking and unlinking of authentication accounts while preserving a single canonical Core User identity in GoModularAuth.

Index

Constants

View Source
const (
	// EventAccountLinked is emitted when an authentication provider account is successfully linked to a user.
	EventAccountLinked auth.EventName = "account.linked"

	// EventAccountUnlinked is emitted when an authentication provider account is detached from a user.
	EventAccountUnlinked auth.EventName = "account.unlinked"

	// EventLinkFailed is emitted when an account linking attempt fails (conflict, validation, authorization).
	EventLinkFailed auth.EventName = "account.link.failed"

	// EventUnlinkFailed is emitted when an account unlinking attempt is blocked (e.g. final auth method).
	EventUnlinkFailed auth.EventName = "account.unlink.failed"

	// EventTicketCreated is emitted when a new linking ceremony ticket is issued.
	EventTicketCreated auth.EventName = "account.ticket.created"
)
View Source
const (
	ModelLinkingTickets = "linking_tickets"
)
View Source
const PluginID = "account-linking"

PluginID identifies this plugin in the GoModularAuth runtime.

Variables

View Source
var (
	// ErrAccountNotFound indicates that the requested account does not exist.
	ErrAccountNotFound = errors.New("account-linking: account not found")

	// ErrAccountAlreadyLinked indicates that the provider account is already linked to the current user.
	ErrAccountAlreadyLinked = errors.New("account-linking: account is already linked to this user")

	// ErrAccountConflict indicates that the provider account is already linked to another distinct user.
	// The plugin strictly forbids silent merging of two Users to prevent account takeover.
	ErrAccountConflict = errors.New("account-linking: account is already linked to another user")

	// ErrCannotUnlinkFinalAuthMethod indicates that the requested unlink operation would remove the user's
	// sole remaining authentication method, permanently orphaning the user account.
	ErrCannotUnlinkFinalAuthMethod = errors.New("account-linking: cannot unlink the user's sole remaining authentication method")

	// ErrUnauthorized indicates that the calling Principal is not authorized to link or unlink accounts for the target user.
	ErrUnauthorized = errors.New("account-linking: unauthorized actor for account operation")

	// ErrUserNotFound indicates that the target user does not exist in the Core user store.
	ErrUserNotFound = errors.New("account-linking: user not found")

	// ErrInvalidInput indicates that one or more required input arguments are missing or malformed.
	ErrInvalidInput = errors.New("account-linking: invalid input")

	// ErrTicketNotFound indicates that the linking ticket does not exist.
	ErrTicketNotFound = errors.New("account-linking: linking ticket not found")

	// ErrTicketExpired indicates that the linking ticket has exceeded its validity window.
	ErrTicketExpired = errors.New("account-linking: linking ticket has expired")

	// ErrTicketAlreadyUsed indicates that the linking ticket was already redeemed (single-use violation).
	ErrTicketAlreadyUsed = errors.New("account-linking: linking ticket was already consumed")

	// ErrProviderMismatch indicates that the linking ticket was issued for a specific provider but presented with another.
	ErrProviderMismatch = errors.New("account-linking: provider does not match linking ticket target")

	// ErrStoreRequired indicates that a persistent store is required for ticket-based linking operations.
	ErrStoreRequired = errors.New("account-linking: store is required")
)

Functions

func RegisterSchema

func RegisterSchema(s *schema.Schema) error

RegisterSchema registers the linking tickets model in the compiled schema.

Types

type Account

type Account = account.Account

Account is an alias to Core Account entity.

type AccountLinkedPayload

type AccountLinkedPayload struct {
	UserID       string    `json:"user_id"`
	AccountID    string    `json:"account_id"`
	Provider     string    `json:"provider"`
	ProviderID   string    `json:"provider_id"`
	ActorSubject string    `json:"actor_subject,omitempty"`
	Timestamp    time.Time `json:"timestamp"`
}

AccountLinkedPayload carries structured details for EventAccountLinked.

type AccountLinking

type AccountLinking interface {
	// LinkAccount securely attaches an external provider account to a target User.
	// It participates in a Store transaction, enforces caller authorization, prevents
	// silent merging across distinct users (detecting collisions), and emits audit events.
	LinkAccount(ctx context.Context, in LinkAccountInput) (*account.Account, error)

	// UnlinkAccount detaches an external provider account from a User by provider and providerID.
	// Fails with ErrCannotUnlinkFinalAuthMethod if this is the user's sole authentication method
	// unless AllowOrphan is explicitly enabled.
	UnlinkAccount(ctx context.Context, in UnlinkAccountInput) error

	// UnlinkAccountByID detaches an external provider account from a User by Core Account ID.
	// Enforces ownership and final authentication method protection.
	UnlinkAccountByID(ctx context.Context, in UnlinkAccountByIDInput) error

	// CreateLinkingTicket issues a single-use, time-limited, cryptographically random ticket
	// for two-step linking flows (e.g. initiating linking in session, completing after OAuth/WebAuthn redirect).
	CreateLinkingTicket(ctx context.Context, in CreateTicketInput) (*LinkingTicketResult, error)

	// CompleteLinkingTicket redeems a linking ticket, validating expiration, single-use redemption,
	// and provider restrictions, before linking the account within a Store transaction.
	CompleteLinkingTicket(ctx context.Context, in CompleteTicketInput) (*account.Account, error)

	// GetLinkingTicket inspects a linking ticket by its plain token without consuming it.
	GetLinkingTicket(ctx context.Context, ticketToken string) (*LinkingTicket, error)

	// RevokeLinkingTicket cancels an active linking ticket before expiration.
	RevokeLinkingTicket(ctx context.Context, ticketToken string) error

	// ListLinkedAccounts returns all authentication accounts currently attached to a user.
	ListLinkedAccounts(ctx context.Context, userID string) ([]*account.Account, error)

	// GetAccount retrieves a specific linked account by provider and provider ID.
	GetAccount(ctx context.Context, provider, providerID string) (*account.Account, error)

	// CanUnlinkAccount evaluates whether the specified account can be safely unlinked
	// without leaving the user without any authentication methods.
	CanUnlinkAccount(ctx context.Context, userID, accountID string) (bool, error)
}

AccountLinking defines the strongly typed capability for orchestrating secure account linking and unlinking across any authentication mechanism in GoModularAuth.

Applications and transport adapters retrieve this capability via:

linking, err := auth.Get[plugins.AccountLinking](engine)
// or
linking, err := auth.Get[accountlinking.AccountLinking](engine)

type AccountUnlinkedPayload

type AccountUnlinkedPayload struct {
	UserID       string    `json:"user_id"`
	AccountID    string    `json:"account_id"`
	Provider     string    `json:"provider"`
	ProviderID   string    `json:"provider_id"`
	ActorSubject string    `json:"actor_subject,omitempty"`
	Timestamp    time.Time `json:"timestamp"`
}

AccountUnlinkedPayload carries structured details for EventAccountUnlinked.

type CompleteTicketInput

type CompleteTicketInput struct {
	// TicketToken is the unhashed token issued during CreateLinkingTicket.
	TicketToken string `json:"ticket_token"`

	// Provider identifies the authentication provider being linked.
	Provider string `json:"provider"`

	// ProviderID identifies the user within the provider.
	ProviderID string `json:"provider_id"`

	// Metadata contains optional provider claims.
	Metadata map[string]string `json:"metadata,omitempty"`
}

CompleteTicketInput specifies parameters to redeem a linking ticket and attach an external account.

type Config

type Config struct {
	// DefaultTicketTTL is the default validity duration for linking tickets. Defaults to 15 minutes.
	DefaultTicketTTL time.Duration

	// AllowOrphanByDefault controls whether removing the final authentication method is allowed by default. Defaults to false.
	AllowOrphanByDefault bool

	// Clock provides time abstraction for deterministic testing. Defaults to time.Now().UTC.
	Clock func() time.Time
}

Config holds configuration parameters for the Account Linking plugin.

type CreateTicketInput

type CreateTicketInput struct {
	// UserID identifies the authenticated User initiating the linking flow.
	UserID string `json:"user_id"`

	// TargetProvider restricts the ticket to a specific provider if specified (optional).
	TargetProvider string `json:"target_provider,omitempty"`

	// TTL defines the ticket expiration window. Defaults to plugin configuration if zero.
	TTL time.Duration `json:"ttl,omitempty"`

	// Metadata holds arbitrary state to roundtrip with the ticket (e.g. redirect URI).
	Metadata map[string]string `json:"metadata,omitempty"`
}

CreateTicketInput specifies parameters to initiate a two-step linking ceremony.

type LinkAccountInput

type LinkAccountInput struct {
	// UserID identifies the canonical Core User.
	UserID string `json:"user_id"`

	// Provider identifies the authentication provider (e.g. "google", "github", "passkey", "password").
	Provider string `json:"provider"`

	// ProviderID identifies the user uniquely inside the provider (e.g. subject, sub, email, or credential ID).
	ProviderID string `json:"provider_id"`

	// Metadata contains optional provider-specific claims or attributes.
	Metadata map[string]string `json:"metadata,omitempty"`
}

LinkAccountInput specifies the parameters to link a new authentication provider identity to a User.

type LinkFailedPayload

type LinkFailedPayload struct {
	UserID       string    `json:"user_id"`
	Provider     string    `json:"provider"`
	ProviderID   string    `json:"provider_id"`
	Reason       string    `json:"reason"`
	ActorSubject string    `json:"actor_subject,omitempty"`
	Timestamp    time.Time `json:"timestamp"`
}

LinkFailedPayload carries audit details when a link operation fails.

type LinkingTicket

type LinkingTicket struct {
	ID             string            `json:"id"`
	TokenHash      string            `json:"token_hash"`
	UserID         string            `json:"user_id"`
	TargetProvider string            `json:"target_provider,omitempty"`
	Metadata       map[string]string `json:"metadata,omitempty"`
	ConsumedAt     *time.Time        `json:"consumed_at,omitempty"`
	ExpiresAt      time.Time         `json:"expires_at"`
	CreatedAt      time.Time         `json:"created_at"`
}

LinkingTicket holds ephemeral single-use state for two-step account linking ceremonies.

func (*LinkingTicket) Expired

func (t *LinkingTicket) Expired(now time.Time) bool

Expired reports whether the ticket is expired relative to the given time.

func (*LinkingTicket) IsConsumed

func (t *LinkingTicket) IsConsumed() bool

IsConsumed reports whether the ticket has already been used.

type LinkingTicketResult

type LinkingTicketResult struct {
	// TicketID is the internal identifier.
	TicketID string `json:"ticket_id"`

	// TicketToken is the unhashed bearer token to present when completing the linking ceremony.
	TicketToken string `json:"ticket_token"`

	// UserID is the bound user ID.
	UserID string `json:"user_id"`

	// TargetProvider is the optional restricted provider.
	TargetProvider string `json:"target_provider,omitempty"`

	// ExpiresAt specifies the exact expiration instant.
	ExpiresAt time.Time `json:"expires_at"`
}

LinkingTicketResult holds the issued ticket metadata and plain token.

type Module

type Module struct {
	// contains filtered or unexported fields
}

Module implements auth.PluginModule using plugin-sdk.

func New

func New(opts ...Option) *Module

New creates an installable Account Linking PluginModule with optional configurations.

func (*Module) ID

func (m *Module) ID() string

ID returns the module's unique identifier.

func (*Module) Init

func (m *Module) Init(h *auth.Host) error

Init configures infrastructure and registers the typed Capability into GoModularAuth via plugin-sdk.

func (*Module) RegisterSchema

func (m *Module) RegisterSchema(s *schema.Schema) error

RegisterSchema registers the linking tickets model in the compiled schema.

type Option

type Option func(*Config)

Option configures Config.

func WithAllowOrphanByDefault

func WithAllowOrphanByDefault(allow bool) Option

WithAllowOrphanByDefault sets whether removing the final auth method is permitted globally.

func WithClock

func WithClock(clock func() time.Time) Option

WithClock sets a custom clock function.

func WithDefaultTicketTTL

func WithDefaultTicketTTL(ttl time.Duration) Option

WithDefaultTicketTTL sets the default validity duration for linking tickets.

type Principal

type Principal = auth.Principal

Principal is an alias to Core Principal identity.

type TicketCreatedPayload

type TicketCreatedPayload struct {
	TicketID       string    `json:"ticket_id"`
	UserID         string    `json:"user_id"`
	TargetProvider string    `json:"target_provider,omitempty"`
	ExpiresAt      time.Time `json:"expires_at"`
	ActorSubject   string    `json:"actor_subject,omitempty"`
	Timestamp      time.Time `json:"timestamp"`
}

TicketCreatedPayload carries audit details when a linking ticket is generated.

type UnlinkAccountByIDInput

type UnlinkAccountByIDInput struct {
	// UserID identifies the target canonical Core User.
	UserID string `json:"user_id"`

	// AccountID identifies the specific Core Account record to delete.
	AccountID string `json:"account_id"`

	// AllowOrphan overrides the lockout protection, permitting removal of the user's sole remaining auth method.
	AllowOrphan bool `json:"allow_orphan,omitempty"`
}

UnlinkAccountByIDInput specifies the parameters to unlink an authentication account directly by Core Account ID.

type UnlinkAccountInput

type UnlinkAccountInput struct {
	// UserID identifies the target canonical Core User.
	UserID string `json:"user_id"`

	// Provider identifies the authentication provider to unlink.
	Provider string `json:"provider"`

	// ProviderID identifies the account to unlink within the provider.
	ProviderID string `json:"provider_id"`

	// AllowOrphan overrides the lockout protection, permitting removal of the user's sole remaining auth method.
	AllowOrphan bool `json:"allow_orphan,omitempty"`
}

UnlinkAccountInput specifies the parameters to unlink an authentication account by provider name and identifier.

type UnlinkFailedPayload

type UnlinkFailedPayload struct {
	UserID       string    `json:"user_id"`
	AccountID    string    `json:"account_id,omitempty"`
	Provider     string    `json:"provider,omitempty"`
	ProviderID   string    `json:"provider_id,omitempty"`
	Reason       string    `json:"reason"`
	ActorSubject string    `json:"actor_subject,omitempty"`
	Timestamp    time.Time `json:"timestamp"`
}

UnlinkFailedPayload carries audit details when an unlink operation fails.

type User

type User = user.User

User is an alias to Core User entity.

Directories

Path Synopsis
Package migrations embeds SQL migrations for the Account Linking plugin.
Package migrations embeds SQL migrations for the Account Linking plugin.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL