ADR-003: CredentialProvider Abstraction for Authentication Methods
This is a rendered copy of docs/adr/003-credential-provider-interface.md, read in place from the repository — see therecords index for how to read it. If this page and the source file ever disagree, the source file is correct.
ADR-003: CredentialProvider Abstraction for Authentication Methods
Status: Accepted Date: 2026-03-11 Authors: QAuth Team
Implementation status (2026-06-24): Accepted as design; not yet implemented. Per ADR-007 (MCP-first), the CredentialProvider/federation work is deferred to Phase 4 (long-term).
Correction (2026-07-21, #304): IMPLEMENTED — the note above is superseded. The
CredentialProviderinterface and provider registry landed in PR #227 (Epic #224):libs/server/federation/src/providers/credential-provider.interface.tsandpassword.provider.tsexist, and the auth engine callsprovider.verify()in production. A second provider —WalletProvider(ADR-004) — has since shipped as a skeleton (#232). Federation (T4) is now the active track, not deferred; see the ADR-007 Activation note (decision #296).
Context
Phase 1 authentication logic (password verification, email claim extraction) is implemented directly in the auth service routes. Login looks up a user by email, verifies the password hash, and builds JWT claims from the user record. This works for a single authentication method, but it cannot accommodate multiple methods without expanding the service layer with provider-specific conditionals.
Phase 4 adds wallet federation (OID4VP). Phase 6+ adds external OIDC federation. Each addition would require changes to the authentication core — adding new conditionals, new lookup paths, new claim extraction rules — if the core is not abstracted.
Decision
Define a CredentialProvider interface in libs/server/federation/. Every authentication method implements this interface. The authentication engine calls provider.verify() and receives a VerifiedIdentity. It does not contain provider-specific logic.
export type AssuranceLevel = 'low' | 'substantial' | 'high'; // eIDAS LoA / ISO 29115
export interface VerifiedIdentity { externalSub: string; assuranceLevel: AssuranceLevel; rawClaims: Record<string, unknown>;}
export interface UserAttribute { source: string; attrKey: string; attrValue: string; verified: boolean; expiresAt?: Date;}
export interface CredentialProvider { readonly type: string; verify(input: unknown): Promise<VerifiedIdentity>; extractAttributes(result: VerifiedIdentity): UserAttribute[];}The PasswordProvider is the Phase 1 implementation:
type = 'password'verify()accepts{ email, passwordHash, emailVerified }, returnsVerifiedIdentitywithassuranceLevel: 'low'extractAttributes()returns the email attribute with the appropriateverifiedflag andsource: 'self_reported'
The auth engine flow is:
- Determine
provider_typefrom the request (Phase 1: always'password') - Look up the appropriate
CredentialProviderfrom the registry - Call
provider.verify(input)→VerifiedIdentity - Upsert
user_attributesrows fromprovider.extractAttributes(identity) - Issue JWT with
sub = users.id,emailfrom verified attributes if present
Phase 4 Extension
Adding WalletProvider does not change the auth engine. It is a new CredentialProvider implementation:
type = 'wallet'verify()validates a Verifiable Presentation per OID4VPextractAttributes()extracts claims from Verifiable Credentials intouser_attributesrows withsource: 'wallet'
The engine calls the same interface. The only change is registering the new provider.
Provider Placement
All providers live in libs/server/federation/:
libs/server/federation/ src/ providers/ credential-provider.interface.ts password.provider.ts # Phase 4: wallet.provider.ts # Phase 6+: oidc-federation.provider.ts index.tsAlternatives Considered
Switch/case on provider_type in the service layer
Service code checks if (providerType === 'password') { ... } else if (providerType === 'wallet') { ... }.
Rejected because: each new provider requires modifying the auth engine. Violates the Open/Closed Principle. Makes the service layer a growing accumulation of credential-specific logic rather than a stable flow.
Plugin registry pattern with dynamic discovery
Providers register themselves via a plugin system at startup. The auth engine discovers providers from the registry by type string.
Rejected for MVP because: adds runtime complexity and late binding for no benefit when the set of providers is known at build time. Can be layered on top of the interface pattern in Phase 3 if needed.
Consequences
Positive
- Auth engine is stable across provider additions
- Each provider is independently testable
- Assurance level (
acrclaim) is a property ofVerifiedIdentity, naturally propagated without service-layer conditionals PasswordProvideris a first-class peer of future providers — not a legacy path
Negative
- Phase 1 password auth is split across two layers (service routes + provider) where it was previously consolidated in the route
- Interface adds one level of indirection for the simplest case
Neutral
- Standard strategy pattern — well-understood by TypeScript developers