Providers
WIPPassword, magic-link, six OAuth providers, WebAuthn passkeys, API keys, and SAML. Every provider is an instance of AuthProvider.IProvider.
What is a provider?
A provider is a typed sign-in method. Every provider implements the same two-step interface:
interface AuthProvider.IProvider<Begin, Complete, Profile> {
id: string
begin(input: Begin, ctx: ProviderContext): Promise<Intent[]>
complete(input: Complete, ctx: ProviderContext): Promise<Intent[]>
}
begin- start the flow. May redirect (OAuth), send a message (magic-link), or be a no-op (password, api-key).complete- verify the result. ReturnsIntent[]- usually astartSessionintent followed by aredirect.
You register providers on AuthEngine:
import { password } from '@gentleduck/auth/providers/password'
import { authGoogle } from '@gentleduck/auth/providers/oauth/google'
export const auth = new AuthEngine({
// ...
providers: [
password({ ... }),
authGoogle({ ... }),
],
})
// or after construction:
auth.providers.use(magicLink({ ... }))
The shipped providers
| Provider | Import | When to pick |
|---|---|---|
| Password | @gentleduck/auth/providers/password | Default. Email + password. |
| Magic link | @gentleduck/auth/providers/magic-link | Passwordless. Email, SMS, or WebPush. |
| Google OAuth | @gentleduck/auth/providers/oauth/google | Consumer SaaS. |
| GitHub OAuth | @gentleduck/auth/providers/oauth/github | Developer tooling. |
| LinkedIn OAuth | @gentleduck/auth/providers/oauth/linkedin | B2B / professional. |
| Microsoft OAuth | @gentleduck/auth/providers/oauth/microsoft | Enterprise. Entra ID. |
| Discord OAuth | @gentleduck/auth/providers/oauth/discord | Communities. |
| Apple OAuth | @gentleduck/auth/providers/oauth/apple | iOS apps. |
| Passkey | @gentleduck/auth/providers/passkey | Strongest factor. WebAuthn. |
| API key | @gentleduck/auth/providers/api-key | Long-lived bearer tokens for integrations. |
| SAML | @gentleduck/auth/providers/saml | Enterprise SSO. IdP-initiated. |
The provider context
Every begin and complete call receives a ProviderContext:
interface ProviderContext {
auth: AuthEngine
req: { headers: Headers; method: string; url: string }
tenantId?: string
events: AuthEvents.IBus
limiter: AuthLimiter.ILimiter
i18n?: I18n.IResolver
trace?: { spanId: string; traceId: string }
}
Use ctx.auth.identities to look up identities, ctx.auth.sessions to
mint sessions, and ctx.events.emit(...) to broadcast custom audit events.
Common features across providers
Rate limiting
Every provider takes a limiterKeyPrefix:
password({
limiterKeyPrefix: 'signin:password:', // default
// ...
})
Combined with the configured limiter, this bucket trips
AUTH/RATE_LIMITED after the threshold and emits a lockout event.
onSignIn hook
For OAuth, SAML, and other federated providers, onSignIn is the
extension point that lets you provision identities just-in-time:
authGoogle({
// ...
onSignIn: async ({ profile, tenantId }) => {
let identity = await auth.identities.findByProviderSub({
providerId: 'google',
sub: profile.sub,
})
if (!identity) {
identity = await auth.identities.create({
email: profile.email,
emailVerified: profile.email_verified,
profile: { displayName: profile.name, avatarUrl: profile.picture },
})
await auth.identities.linkProvider(identity.id, {
providerId: 'google',
sub: profile.sub,
})
}
return { identityId: identity.id }
},
})
Always returns { identityId: string } - the runtime then mints the
session and emits the intents.
Profile mapping
profileToIdentityProfile lets you control how the IdP profile maps to
your typed Profile generic:
authGoogle({
// ...
profileToIdentityProfile: (profile) => ({
displayName: profile.name,
avatarUrl: profile.picture,
locale: profile.locale,
}),
})
Writing a custom provider
Implement AuthProvider.IProvider:
import type { AuthProvider } from '@gentleduck/auth/core'
interface IBeginInput { email: string }
interface ICompleteInput { token: string }
export function myProvider<Profile>(opts: {
findIdentityByEmail: (email: string) => Promise<{ id: string } | null>
}): AuthProvider.IProvider<IBeginInput, ICompleteInput, Profile> {
return {
id: 'my-provider',
async begin({ email }, ctx) {
const identity = await opts.findIdentityByEmail(email)
if (!identity) return [{ kind: 'json', status: 404, body: { error: 'not found' } }]
// mint and dispatch a token...
return [{ kind: 'json', status: 200, body: { sent: true } }]
},
async complete({ token }, ctx) {
// verify the token...
const session = await ctx.auth.sessions.create({ identityId, aal: 'aal1', amr: ['custom'] })
return [{ kind: 'startSession', session }]
},
}
}
Register the same way:
auth.providers.use(myProvider({ findIdentityByEmail: ... }))