Authentication
@webda/auth provides login, registration, account linking, password recovery and token issuing for Webda applications.
It replaces the v3 Authentication service (see the v3 to v4 migration guide).
Overview
The Authentication service holds the shared logic. Each login method is a separate provider service. A provider
proves an identity and hands a ResolvedIdentity to Authentication.complete:
EmailPasswordProvider ─┐
GoogleAuthentication ───┤ (OAuthProvider)
your own provider ──────┴─> ResolvedIdentity ──> Authentication.complete()
│
├─ apply provider email policy (allowedEmailDomains, trustEmailVerification)
├─ find the Ident (provider, providerUid)
├─ link / adopt / register the User (linking policy)
├─ session.login() + MFA state
└─ TokenService.issue() ──> { accessToken, refreshToken, expiresIn }
Any service exposing providerName and getPublicInfo() is discovered as a provider; two providers with the same name
are refused at startup. Authentication.complete() (and applyPolicy()) refuse an identity whose provider is not
the name of a registered provider with BAD_REQUEST (400): a made-up name would escape the email policy of the
provider it impersonates.
Installation
pnpm add @webda/auth
Configuration
{
"services": {
"Authentication": {
"type": "Webda/Authentication",
"userModel": "Webda/User", // default
"identModel": "Webda/Ident", // default
"linking": "verified", // "never" | "verified" | "always", default "verified"
"registration": true, // default true: allow automatic registration
"compatibility": { "v3": true } // default { "v3": true }
},
"emailAuth": {
"type": "Webda/EmailPasswordProvider",
"mailer": "Mailer", // default
"verification": "before", // "before" | "after" | "none", default "before"
"password": {
"policy": ".{8,}", // regexp, default ".{8,}"
"verifier": "myVerifier" // optional service name, replaces the regexp
},
"throttle": {
"resendDelay": 14400000, // ms between two emails to the same address (4h)
"failedBeforeDelay": 3, // failed logins before locking
"lockout": 900000 // ms (15 min)
},
// Pages of YOUR front-end (example values, there are no defaults). init() fails naming a missing key:
// failure and recover are always required, verified and register unless verification is "none"
"redirects": {
"verified": "https://app.example.com/email-verified", // GET verify link success
"failure": "https://app.example.com/link-failed", // any GET link failure, receives ?reason=CODE
"register": "https://app.example.com/register", // GET register link, receives ?token=...&email=...
"recover": "https://app.example.com/reset-password", // GET recovery link, receives ?token=...
"confirm": "https://app.example.com/login" // optional: verify link without the matching session (default: failure)
},
// base url of emailed links, default "/auth/email"; prefer an absolute url
"url": "https://api.example.com/auth/email",
"allowedEmailDomains": ["example.com"], // optional
"trustEmailVerification": true // optional, boolean | string[]
}
}
}
verification modes:
before: registration is a two step flow; a register link is emailed and the account is created when the link is usedafter: the account is created immediately, the email is unverified until the user verifies itnone: the account is created immediately and no verification mail is sent
Per-provider email policy
Any provider (it reads its own service parameters) may set:
trustEmailVerification: whether the provider's "email verified" claim is believed.true(default) believes it,falsenever, astring[]only for those domains. A distrusted claim is treated as unverified.allowedEmailDomains: only these domains may log in (case-insensitive, exact domain match, no wildcard,[]refuses everyone). The email must be verified and trusted; otherwise login fails withEMAIL_DOMAIN_NOT_ALLOWED. As a consequence, under an allow-list email/password registration inafterornonemode is refused (the new email is unverified): usebefore.
Store mapping
Users, idents and refresh tokens are regular models; map them to a store like any other model:
{
"services": {
"db": {
"type": "MemoryStore",
"models": ["Webda/User", "Webda/Ident", "Webda/RefreshToken", "Webda/RevokedAccess"]
}
}
}
A model mapped to no store falls back to the application registry store. That is acceptable for a single instance
only: multi-instance deployments must map Webda/RefreshToken (and the user and ident models) to a shared store,
otherwise a refresh token issued by one instance is unknown to the others and revocation does not reach them.
Likewise map Webda/RevokedAccess, the denylist of revoked token families and users (see
@webda/oauth): on a per-instance store a revocation stays local to the
instance that performed it. Other instances notice a shared revocation within TokenService.revocationCacheTtl
seconds (default 10).
Webda/Ident and Webda/RefreshToken, and any application subclass of them (for example a custom identModel), are
never exposed by the DomainService (REST, GraphQL, model operations): their fields (_user, verifiedAt,
_loginAttempts, ...) would allow an account takeover. Only an explicit listing in the DomainService models parameter
exposes them; the "*" wildcard does not.
There is no LegacyIdent model: v3 ident records stored under the "<uid>_<provider>" key are read through the
Webda/Ident model (see compatibility). Ident keys are
"<providerUid>:<provider>"; emails are normalised (trimmed, lowercased).
Operations
All operations are exposed through the REST API with the path shown (relative to the API root) and as operations by id.
| Operation id | REST | Inputs | Result / notes |
|---|---|---|---|
Auth.Providers | GET auth/providers | - | list of { name, type, startUrl? } |
Auth.Me | GET auth/me | - | public entry of the user, 404 when not logged in |
Auth.Logout | POST auth/logout | - | revokes the refresh family of the session, clears the session; also abandons pending MFA |
Auth.Refresh | POST auth/refresh | refreshToken | { accessToken, refreshToken, expiresIn } |
Auth.Idents | GET auth/idents | - | [{ provider, providerUid, email?, verifiedAt?, lastUsedAt? }]; upgrades v3 idents first |
Auth.Unlink | POST auth/idents/unlink | provider, providerUid | 409 LAST_LOGIN_METHOD if no usable login method would remain (see below) |
Auth.Email.Login | POST auth/email/login | email, password | { status: "ok", user, accessToken, ... } or { status: "mfa_required", methods } |
Auth.Email.Register | POST auth/email/register | email, password, token?, profile? | { status: "verification_sent" } (mode before, no token) or the login result |
Auth.Email.StartVerification | POST auth/email/verification | email | 204. Logged out: never reveals whether the email exists |
Auth.Email.Verify | POST auth/email/verify | token | { status: "verified" }. Needs a session of the user named by the token |
| (GET link) | GET auth/email/verify?token=... | token | 302 redirect, see below |
| (GET link) | GET auth/email/recover?token=... | token | 302 to redirects.recover?token=... (token not consumed), invalid: redirects.failure?reason= |
Auth.Password.StartRecovery | POST auth/password/recovery | email | always 204 (never reveals accounts) |
Auth.Password.Recover | POST auth/password/recover | token, password | sets the password, marks the email verified, ends all sessions (see below); does not log in |
Auth.Password.Change | POST auth/password/change | current, next | requires login; ends the other sessions (see below); emits Authentication.PasswordUpdate |
Auth.<Provider>.Token | POST auth/<provider>/token | token | OAuth providers, e.g. Auth.Google.Token: the login result (see OAuth providers) |
| (GET link) | GET <url>?redirect=... | redirect? | OAuth providers: 302 to the provider, see OAuth providers |
| (GET link) | GET <url>/callback | code, state | OAuth providers: 302 to the login target or redirects.failure?reason= |
User.Mfa.Enroll | POST users/{uuid}/mfa.enroll | method, input? | { secret, uri }: start TOTP enrollment (URI formatted for authenticator QR code) |
User.Mfa.Challenge | POST users/{uuid}/mfa.challenge | method | { phone, expiresIn }: send a code (SMS), also allowed on a pending session; phone masked |
User.Mfa.Confirm | POST users/{uuid}/mfa.confirm | method, code | { recoveryCodes, ...tokens }: confirm factor, issue recovery codes, re-issue the caller's tokens |
User.Mfa.Verify | POST users/{uuid}/mfa.verify | code, method? | verify TOTP code on pending session, issue access/refresh tokens |
User.Mfa.Disable | POST users/{uuid}/mfa.disable | method, code | disable TOTP method (requires current TOTP code or recovery code) |
User.Mfa.Recover | POST users/{uuid}/mfa.recover | code | login with a single-use recovery code on pending session |
User.Mfa.RecoveryCodes | POST users/{uuid}/mfa.recoveryCodes | code | { recoveryCodes }: regenerate recovery codes (requires factor code or recovery code) |
Auth.Unlink only counts the usable remaining login methods: idents of other providers, plus email idents when
the user has a password (without one an email ident allows neither login nor recovery). It also refuses to remove the
last email ident of a user with a password.
The CLI command webda auth migrate is described in the migration guide.
Email verification
Verification requires a logged-in session matching the token's user: a link opened by someone else, or by a logged-out browser, cannot verify anything.
GET auth/email/verify?token=...with the matching session: verifies, redirects toredirects.verified- with a register token: redirects to
redirects.register - verify token without a matching session: no state change, redirects to
redirects.confirm(defaultredirects.failure) with?reason=LOGIN_REQUIRED&token=.... Log in, then callAuth.Email.Verifywith the token Auth.Email.Registerinbeforemode without a token answers{ status: "verification_sent" }and sends the register link like a logged-outAuth.Email.StartVerification: in the background, at most once perthrottle.resendDelayper address (further calls are silently ignored), never for an owned or verified emailAuth.Email.StartVerificationwhile logged in emails a link for the current account; the ident only becomes yours whenAuth.Email.Verifyis called by that same session
Recovery link
The recovery mail links to GET <url>/recover?token=.... A valid token is redirected, unconsumed, to
redirects.recover?token=...: that page asks for the new password and calls Auth.Password.Recover with the token.
An invalid or expired token is redirected to redirects.failure?reason=TOKEN_INVALID (or TOKEN_EXPIRED).
Emailed links carry their token in the query string: serve the confirm, register and recover pages with
Referrer-Policy: no-referrer (or same-origin) so the token does not leak to third-party resources they load.
Login throttling
Failed logins are counted before the password is checked, with atomic increments on the ident's top-level
_loginAttempts and _lastLoginAttemptAt attributes, so parallel guesses are counted on every store. After
failedBeforeDelay attempts the ident is locked for lockout ms (THROTTLED, 429). A success, or a successful
Auth.Password.Recover, resets the counter. A count without _lastLoginAttemptAt (upgraded v3 data) is an expired lock.
Auth.Email.Register refuses any email already owned (ACCOUNT_EXISTS).
OAuth providers
OAuthProvider (exported by @webda/auth) is the base of the OAuth 2.0 / OpenID Connect providers;
@webda/google-auth provides GoogleAuthentication (provider name google). A
provider is a service of its own next to Authentication:
{
"services": {
"Authentication": { "type": "Webda/Authentication" },
"google": {
"type": "Webda/GoogleAuthentication",
"client_id": "${GOOGLE_CLIENT_ID}",
"client_secret": "${GOOGLE_CLIENT_SECRET}",
"url": "/auth/google", // default "/auth/<provider>"
"redirect_uri": "https://api.example.com/auth/google/callback", // default: request url + "/callback"
"redirects": {
"success": "https://app.example.com/", // default "/"
"failure": "https://app.example.com/login" // required, receives ?reason=CODE
},
"authorized_uris": ["https://app.example.com/"], // allowed ?redirect= targets, default []
"scope": ["openid", "email", "profile"], // Google default
"access_type": "online", // Google: "offline" also returns a refresh token
"audiences": ["1234-ios.apps.googleusercontent.com"], // Google: extra client ids for Auth.Google.Token
"hostedDomain": "example.com", // Google: only Workspace accounts of this domain
"allowedEmailDomains": ["example.com"], // optional per-provider email policy
"trustEmailVerification": true
}
}
}
init() refuses to start without client_id, client_secret (unless the provider is a public client),
redirects.failure or an Authentication service, and with a relative authorized_uris entry. Set redirect_uri
explicitly behind a proxy: the default is built from the request host.
Browser flow
GET <url>?redirect=<target>creates a randomstate(256 bits), a PKCE code verifier (the S256 challenge goes to the provider) and an OpenIDnonce, and keeps them with the target and theredirect_uriin a dedicated cookiewebda_oauth_<provider>: encrypted with the CryptoService,HttpOnly,SameSite=Lax,Securewhen the request or theredirect_uriis https, valid 10 minutes, withPathset to the path of theredirect_uri(so it includes any deployment prefix, such as an API Gateway stage or a path-stripping proxy; the callback must be served by the same host as the login). Nothing is written in the session, so the flow works whatever the session cookiesameSite(includingstrict).redirectis optional; when present it must be an absolute http(s) url with the same origin as anauthorized_urisentry and a path equal to or below that entry's path (https://app.example.com/afterallows/afterand/after/x, not/afterwards); a path containing an encoded slash or backslash (%2f,%5c) or longer than 1024 characters is refused. Anything else redirects toredirects.failure?reason=REDIRECT_NOT_ALLOWEDwithout starting a login.GET <url>/callback?code=...&state=...reads and clears that cookie (single use), compares the state in constant time, exchanges the code with the PKCE verifier and theredirect_uriof step 1 (an OpenID provider also checks the ID tokennonce), and hands the identity toAuthentication.complete(). It never answers an error: it redirects to the stored target (orredirects.success), withmfa=requiredadded when the user must still pass MFA, or toredirects.failure?reason=<CODE>. Both routes answerCache-Control: no-store.
| Reason | Cause |
|---|---|
REDIRECT_NOT_ALLOWED | the login redirect does not match authorized_uris |
STATE_MISMATCH | no pending login cookie, or a wrong, missing, expired, tampered state |
PROVIDER_ERROR | the provider answered with error or without code |
TOKEN_INVALID | the code exchange or the ID token verification (audience, nonce...) failed |
ACCOUNT_EXISTS, EMAIL_DOMAIN_NOT_ALLOWED, ... | refused by Authentication.complete() (linking, email policy, ...) |
OAUTH_ERROR | unexpected failure (logged as a reason code, no detail in the url) |
A browser whose session cookie reaches the callback (lax) while logged in links the provider identity to the current
account (or gets IDENT_LINKED_ELSEWHERE): linking is a browser-flow action, protected by the state and PKCE.
Token operation: Auth.<Provider>.Token (POST auth/<provider>/token) is for clients that obtained a token from
the provider themselves (mobile, desktop, Google One Tap). The body is JSON, { "token": "..." } or the v3 form
{ "tokens": { "id_token": "...", "access_token": "...", ... } } (only id_token, access_token, refresh_token,
expiry_date, token_type and scope, as strings or numbers of at most 4096 characters, are kept); any other Content-Type is refused with
UNSUPPORTED_MEDIA_TYPE (415), so a cross-site form or text/plain POST cannot use a victim's cookies. It returns
the login result like Auth.Email.Login; an unverifiable token is TOKEN_INVALID. It never links: when the
request carries a logged-in session, an identity owned by another user is refused with IDENT_LINKED_ELSEWHERE, and a
new or unowned identity starts a fresh session (the cookie user is logged out) instead of being attached to it.
Auth.Google.Token only accepts Google ID tokens (token, or tokens.id_token) whose audience is client_id
or one of audiences (never access tokens, whose audience cannot be checked).
Provider tokens are stored on the ident as ident.tokens, an EncryptedField (see below): only a ciphertext is
written, read them with await ident.tokens.get(). They are not part of the Authentication.Login /
Authentication.Register events; a provider publishes them with its own event.
Google identities: the ident is <sub>:google; the email is verified only when the ID token claim
email_verified is true (so an unverified Google email never claims the email ident nor links an existing account
under the verified policy); the profile keeps name, picture, locale, hd. With hostedDomain, an ID token
without that hd claim is refused with EMAIL_DOMAIN_NOT_ALLOWED. ID tokens are verified by one OAuth2Client per
service, so Google's certificates are fetched once and cached. After a successful login the service emits
GoogleAuth.Tokens with { tokens, context }: the credentials of the code exchange, or the tokens ({ id_token }
for a bare token) sent to Auth.Google.Token, where only the ID token is verified.
useService("google").on("GoogleAuth.Tokens", async ({ tokens, context }) => {
// tokens.access_token, tokens.refresh_token (access_type "offline")
});
Writing a provider: extend OAuthProvider and implement providerName,
getAuthorizationUrl({ state, redirectUri, scope, codeChallenge, codeChallengeMethod, nonce }),
handleCallback({ code, redirectUri, codeVerifier, nonce }) (an OpenID provider must check the ID token nonce) and
handleToken({ token, tokens }), both returning a ResolvedIdentity. The base class forces identity.provider to
providerName (a provider cannot assert another provider's identity) and defaults amr to ["oauth"]; override
requiresClientSecret() for public clients and onAuthenticated() to react to a login. Errors that are not
HttpErrors become TOKEN_INVALID for the operation and OAUTH_ERROR for the callback; log errors with
safeErrorReason(err), never their message (libraries put tokens in them).
Encrypted fields
EncryptedField<T> (@webda/core, behavior Webda/Encrypted) stores a value encrypted at rest with the
CryptoService: await field.set(value), await field.get(), field.clear(), field.isSet(). Only __ciphertext
is stored (server-only, never in an API output): the JSON value encrypted with the current symmetric key (random IV)
inside a JWT signed with that key, so tampering is detected. Values encrypted before a key rotation still decrypt
while the old key is in the CryptoService registry. Declare it like any behavior: secret: EncryptedField<MyType>;.
Every instance must share the CryptoService keys (the Registry store): an instance without the key cannot
decrypt. When the Registry is in the same database as the encrypted data, read the keys from a secret file with
the CryptoService keysFile parameter, so a database dump does not contain them (see
CryptoService).
Client certificates
CertificateProvider (Webda/CertificateProvider, provider certificate) authenticates requests presenting a
verified TLS client certificate, terminated by the HttpServer or by a trusted reverse proxy: each request gets a
stateless session with amr: ["mtls"] by default. With mode: "factor", it adds mtls only to an existing session
for the certificate's linked user, for that request. See Client certificates (mTLS).
Tokens
- Access token: a JWT (audience
webda-access) validaccessTtlseconds (default 900). Send it asAuthorization: Bearer <token>; the scheme is case-insensitive. An invalid Bearer token means anonymous: there is no fallback to the session cookie. - Refresh token: opaque, valid
refreshTtlseconds (default 2592000, 30 days), stored as a SHA-256 hash. EachAuth.Refreshrotates it. Presenting an already used token revokes the whole token family (reuse detection), with one grace: withinrefreshGraceseconds of the rotation (default 86400), while the token it was exchanged for was never used, the replay is rotated once more and that unused replacement is retired. A client that lost the answer of its refresh (laptop asleep, connection dropped) keeps its session this way; a retired token presented later (two holders) revokes the family.refreshGrace: 0restores strict detection.TokenService.issue()refuses a session whose MFA is pending, and the MFA state is persisted with the refresh token. Auth.Logoutrevokes the family; access tokens already issued remain valid until they expire.
Sessions and password changes
A login stamps session.authAt (and the access token carries it as the authAt claim). When the session manager loads
a cookie session or a Bearer token carrying authAt, it reads the user (one read per request, through the user
resolver registered by Authentication) and treats the session as anonymous when user.password.changedAt is
later than authAt. A failing read also yields an anonymous session. So:
Auth.Password.Recoverrevokes every refresh token of the user and ends all of its cookie sessions and access tokens.Auth.Password.Changedoes the same for every other session; the calling cookie session is re-stamped and stays logged in. A Bearer caller must log in again (its refresh token is revoked too).
Sessions without authAt (created before this check existed, or by custom code calling session.login()) are not
checked.
Multi-Factor Authentication (MFA / TOTP)
@webda/auth provides second-factor authentication built on top of RFC 6238 Time-based One-Time Passwords (TOTP),
one-time codes sent by SMS (see SMS second factor) and single-use recovery codes, with an
extensible SecondFactorService base class for other factors (e.g. WebAuthn).
The factors and recovery codes live in the Webda/MFA behavior of @webda/core. Each attribute has its own
operations, secrets and recovery codes. purpose: "login" selects the User attribute used for login. The inherited User.mfa attribute has that purpose
by default. Other attributes on the same User, or on other models, provide independent verification.
An attribute can enable several methods (e.g. totp and sms); its verification accepts any enabled method unless
restricted by the method input.
Configuration
Add Webda/TotpService and expose the User model through a Webda/DomainService (or a transport that includes it).
Configure each attribute under models.<Model>.behaviors.<attribute>, independently of the factor provider:
{
"models": {
"Webda/User": {
"behaviors": {
"mfa": { "purpose": "login", "methods": ["totp"], "reauthMaxAge": 300, "recoveryCodes": 10 }
// Declare paymentMfa: MFA on your User subclass before configuring it.
// "paymentMfa": { "purpose": "step-up", "methods": ["totp"], "verificationMaxAge": 60, "recoveryCodes": 0 }
}
}
},
"services": {
"Authentication": { "type": "Webda/Authentication" },
"DomainService": { "type": "Webda/DomainService", "models": ["Webda/User"] },
"totp": {
"type": "Webda/TotpService",
"issuer": "MyApplication", // name shown in authenticator apps (default: package name or "Webda")
"algorithm": "sha1", // "sha1" | "sha256" | "sha512" (default "sha1")
"digits": 6, // 6 | 8 (default 6)
"period": 30, // time step in seconds (default 30)
"window": 1, // accepted drift window in time steps (default 1)
"recoveryCodes": 10, // provider default; the behavior attribute can override it
"throttle": {
"failedBeforeDelay": 5, // failed verification attempts before lockout (the strictest enabled method applies)
"lockout": 900000 // lockout duration in ms (15 min)
}
}
}
}
Storage and Security Model
- Secret encryption at rest: The Base32 TOTP secret is stored inside
user.mfa.totp, anEncryptedField<TotpSecret>(Webda/Encrypted), encrypted usingCryptoServicesymmetric encryption. The plaintext secret is never serialized in JSON outputs or API responses. - Single-use step enforcement:
totpLastSteprecords the accepted time step. Replaying an identical or earlier time-step code is rejected immediately to prevent replay attacks. - Recovery codes: Generated in human-friendly
xxxxx-xxxxxformat. Plaintext codes are displayed to the user only once upon enrollment confirmation (or regeneration). They are stored hashed with SHA-256 inuser.mfa.__recoveryCodes. Verification uses constant-time comparison (crypto.timingSafeEqual) and deletes the code immediately upon use. - Recovery codes cover every factor: they are issued with the first enabled factor. Confirming another factor keeps
them and answers
recoveryCodes: []; disabling the last factor drops them. - Brute-force protection: Failed attempts are atomically counted in
_mfaAttemptsand_lastMfaAttemptAton the model holding the behavior (countAttempt). Every operation that checks a code shares this counter: verify, recover, confirm, disable and recovery-codes, whatever the method. When failures reach the strictestfailedBeforeDelayof the enabled methods, all of them are locked out (THROTTLED, 429) forlockoutms. A successful check clears the counter.
Authentication Flow
- Enrollment:
POST users/{uuid}/mfa.enrollwith{ method: "totp" }requires an active logged-in session (and requires reauthentication if the session is older thanreauthMaxAge).- Returns
{ secret, uri }whereuriis a standardotpauth://totp/...URI ready for QR code rendering.
- Confirmation:
POST users/{uuid}/mfa.confirmwith{ method: "totp", code: "123456" }.- On success, marks TOTP enabled, issues recovery codes (first factor only), revokes every refresh token family of the user, and upgrades the current session to
mfa: "verified". - Returns
{ recoveryCodes: ["...", ...], accessToken, refreshToken, expiresIn }: the tokens replace the caller's revoked ones.
- Login Gate:
- When MFA is enabled, primary logins (e.g.
Auth.Email.Login,Auth.<Provider>.Token, OAuth callbacks) answer:{ status: "mfa_required", methods: ["totp"] } - The session enters a pending state (
session.mfa = "pending"). No access or refresh tokens are issued.
- When MFA is enabled, primary logins (e.g.
- Verification:
POST users/{uuid}/mfa.verifywith{ code: "123456" }.- On success, marks
session.mfa = "verified", stampssession.mfaAt, appends"totp"tosession.amr(["pwd", "totp"]), issues access/refresh tokens, and returns{ status: "ok", accessToken, refreshToken, ... }. - On an already logged session it is a step-up, see MFA freshness / step-up.
- Recovery:
- If the user lost their authenticator,
POST users/{uuid}/mfa.recoverwith{ code: "xxxxx-xxxxx" }. - Consumes the recovery code, sets
session.amr = ["pwd", "recovery"], issues tokens, and completes login.
- If the user lost their authenticator,
- Disabling & Regeneration:
POST users/{uuid}/mfa.disablewith{ method: "totp", code: "..." }disables TOTP (requires valid code or recovery code).POST users/{uuid}/mfa.recoveryCodeswith{ code: "..." }regenerates a fresh set of recovery codes.
SMS second factor
Webda/SmsMfaService sends one-time codes by SMS (method: "sms"). It only implements the factor hooks: throttling,
lock-out, recovery codes, session upgrade and events are the shared MFA behavior and Authentication logic described
above. Messages go through a pluggable SmsSender service (send(to, message), interface exported by @webda/core):
Webda/DebugSmsSender(@webda/core) keeps the messages in memory (sent) and logs them: tests and local development.Webda/SnsSmsSender(@webda/aws) publishes transactional SMS with Amazon SNS, see SnsSmsSender.- Other providers only need a service implementing
SmsSender.
{
"services": {
"SmsSender": { "type": "Webda/SnsSmsSender", "region": "us-east-1" }, // or "Webda/DebugSmsSender"
"sms": {
"type": "Webda/SmsMfaService",
"sender": "SmsSender", // name of the SmsSender service (default "SmsSender")
"digits": 6, // code length, 4 to 10 (default 6)
"ttl": "5m", // code lifetime (default "5m")
"resendDelay": "60s", // minimum delay between two codes of a factor (default "60s")
"message": "{code} is your {app} verification code", // template (default)
"app": "MyApplication", // {app} value (default: package name or "Webda")
"throttle": { "failedBeforeDelay": 5, "lockout": 900000 }
}
}
}
Flow, with the operations of the attribute (User.Mfa.* for login):
- Enroll:
User.Mfa.Enrollwith{ method: "sms", input: { phone: "+1 555 010 0089" } }. The phone is normalised to E.164 (separators removed, the+country code is required, otherwiseINVALID_PHONE), stored pending, and a first code is sent. Answers{ phone: "+1********89", expiresIn: 300 }. Enrolling again before confirming replaces the phone. - Confirm:
User.Mfa.Confirmwith{ method: "sms", code }. This is the phone verification: on success the factor is enabled and, when the owner model declares aphoneVerifiedAtattribute, it is set (no core schema change). - Login: the primary login answers
{ status: "mfa_required", methods: ["sms"] }. The pending session callsUser.Mfa.Challengewith{ method: "sms" }to receive a code, thenUser.Mfa.Verifywith{ method: "sms", code }. A pending session can only challenge a confirmed factor. A logged session can challenge too (step-up, disable, recovery codes regeneration, resending a code during enrollment).
Every new code replaces the previous one. Challenge (and a new Enroll) before resendDelay answers THROTTLED
(429) without sending anything; a failing sender error is returned to the caller and nothing is saved.
Security notes:
- SMS is a weak factor (SIM swap, SS7 interception, phishing relays): do not recommend it as the only second factor,
offer TOTP alongside it (
methods: ["totp", "sms"]) and keep recovery codes. - The phone is never output: it is stored in the encrypted factor data (
EncryptedField,CryptoService) and answered masked. Logs ofDebugSmsSendercontain the phone and the code: do not use it in production. - Codes come from
crypto.randomInt, are stored as SHA-256 hashes inside the encrypted factor data, compared in constant time, expire afterttland are single use (cleared on success). - Wrong codes count in the same
_mfaAttemptslock-out as TOTP;resendDelaylimits the SMS sent per factor.
Extending SecondFactorService
Custom MFA factors (WebAuthn, email codes...) can be implemented by extending SecondFactorService<P>. The service is
the SecondFactorProvider of its method, registered on resolve: the MFA behavior of every model uses it. The owner is
a user or any object holding the behavior.
export class EmailCodeService extends SecondFactorService<EmailCodeParameters> {
readonly method = "email";
async startEnrollment(mfa: MFA, owner: any, input?: any): Promise<any> {
// Store the factor data in mfa.getFactor("email").data, send the first code; the caller saves the owner
}
// Optional: methods delivering codes implement challenge, exposed as <Model>.<Attribute>.Challenge
async challenge(mfa: MFA, owner: any): Promise<any> {
// Send a new code; the caller saves the owner
}
async check(mfa: MFA, code: string): Promise<boolean> {
// Validate the code
}
}
The MFA behavior handles throttling, recovery codes, saving the owner and public actions. Factor services register
providers by method; they do not register Auth.* operations. The Authentication service applies reauthentication,
event emission, session upgrade and token revocation to its configured login attribute. Recovery codes belong to
one attribute and can be regenerated with any of that attribute's enabled factors.
Pending MFA sessions
When the user has MFA enabled, a login is only complete when the provider's amr contains a primary factor (pwd or
oauth, exported as PRIMARY_FACTORS) and one of the user's enabled MFA methods (for example totp). Any other
amr leaves the session pending and answers { status: "mfa_required", methods }.
A session whose MFA is pending is not logged in: ctx.getCurrentUserId() and ctx.getCurrentUser() return
undefined, so permission checks (canAct) treat it as anonymous. Code that needs the pending user reads
ctx.getSession().userId.
TokenService parameters: accessTtl, refreshTtl.
MFA on other models
Any model can hold the behavior to protect itself, e.g. a vault whose sensitive operations need a second factor. It declares the behavior and the attempts counters:
export class Vault extends Model {
mfa: MFA;
_mfaAttempts: number = 0;
_lastMfaAttemptAt?: number;
}
The domain service exposes the behavior actions of every such model, allowed by its canAct(context, "mfa.<action>"):
| Operation | REST | Input | Output |
|---|---|---|---|
Vault.Mfa.Enroll | POST vaults/{uuid}/mfa.enroll | method | the factor enrollment data (TOTP: secret, uri) |
Vault.Mfa.Challenge | POST vaults/{uuid}/mfa.challenge | method | the challenge data (SMS: phone, expiresIn) |
Vault.Mfa.Confirm | POST vaults/{uuid}/mfa.confirm | method, code | { recoveryCodes } (first factor only) |
Vault.Mfa.Verify | POST vaults/{uuid}/mfa.verify | code, method? | { method, proof, expiresIn } |
Vault.Mfa.Disable | POST vaults/{uuid}/mfa.disable | method, code | - |
Vault.Mfa.RecoveryCodes | POST vaults/{uuid}/mfa.recoveryCodes | code | { recoveryCodes } |
Verify checks the code against the given method, or every enabled method then the recovery codes (method: "recovery" for a recovery code only). It answers a step-up proof signed by CryptoService, valid 5 minutes, bound to
the object and to the caller. The operation that needs the second factor checks it:
const method = await verifyMfaProof(input.proof, vault, "mfa"); // the method that verified, undefined when not valid
if (!method) throw new WebdaError.Forbidden("Second factor required");
The attribute argument is required: a proof for mfa cannot authorize an operation requiring paymentMfa.
A fourth argument requires a more recent proof (verifyMfaProof(proof, vault, "mfa", 60), in seconds).
Multiple MFA attributes and session status
For a User with mfa: MFA and paymentMfa: MFA, the operations are User.Mfa.* and User.PaymentMfa.*.
Policies can restrict allowed methods, override recoveryCodes (0 disables them), set the proof lifetime with
verificationMaxAge (seconds), and set login enrollment reauthMaxAge (seconds; 0 disables the limit).
Both attributes can use method: "totp" with separate secrets and recovery codes. Only the attribute configured with
purpose: "login" completes a pending login. More than one login attribute is a configuration error. Pending sessions can access only their own login attribute;
all other behavior actions retain the model's normal read and action permission checks.
Successful Confirm, Verify and Recover actions record their server-derived operation name in the session:
session.mfaVerifications = {
"User.Mfa.Verify": { method: "totp", verifiedAt: 1720000000000, owner: "Webda/User/user-123" },
"User.PaymentMfa.Verify": { method: "totp", verifiedAt: 1720000005000, owner: "Webda/User/user-123" }
};
Sensitive operations must check their required operation entry, owner and age (verifiedAt is milliseconds).
Each entry records the latest owner verified through that operation; it never authorizes a different object.
A failed verification does not create or refresh an entry. Disabling a factor clears this attribute's entries for
that owner. Login and logout clear previous verification entries. Cookies and issued access/refresh tokens preserve
entries, and token refresh preserves their original timestamps. A bearer request's session changes alone do not
replace its token: use the returned scoped proof for object step-up, or issue a new token from the updated session
(the login attribute does it, see MFA freshness / step-up).
For example, a recent payment verification can be required with:
const verified = ctx.getSession().mfaVerifications?.["User.PaymentMfa.Verify"];
if (!verified || verified.owner !== `Webda/User/${user.getUUID()}` || Date.now() - verified.verifiedAt > 60000) {
throw new WebdaError.Forbidden("Recent payment MFA required");
}
MFA freshness / step-up
The session records when its second factor was verified: session.mfaAt (ms), carried by cookies, by the mfaAt
access token claim and by the refresh token. It is a proof time, not a token time: Auth.Refresh never advances it, so
a session verified once stays "verified" for the refresh family lifetime but grows older. It is stamped when:
- a pending login completes with the login attribute
VerifyorRecover; - a provider login already carries one of the user's MFA methods in its
amr; - the login attribute
Confirmenables a factor; - a logged session verifies the login attribute again (step-up).
session.mfaAge() answers the milliseconds since that verification, Infinity when it never was (sessions and tokens
issued before mfaAt existed are therefore never fresh).
A sensitive operation requires a recent second factor with maxMfaAge (a Duration: seconds or "10m"), on
@Operation as on model and behavior @Action (use a string or number literal: the compiler reads it):
@Operation({ id: "Account.Delete", maxMfaAge: "10m", rest: { method: "delete", path: "account" } })
async deleteAccount() {}
It is checked after the permission query, before the operation authorizers, for every transport (REST, GraphQL, gRPC,
MCP) and by canCallOperation() listings, which answer false when refused:
| Session | Refusal |
|---|---|
| anonymous, or second factor pending | UNAUTHORIZED (401) |
logged, no second factor (mfa: none) | MFA_REQUIRED (403): maxMfaAge makes MFA mandatory |
verified more than maxMfaAge ago | MFA_STEP_UP_REQUIRED (401), details: { maxAge } (s) |
On MFA_STEP_UP_REQUIRED the client prompts for a code, calls User.Mfa.Verify (or User.Mfa.Recover) on its logged
session, then retries. The step-up keeps the login (authAt, ident, refresh family), stamps mfaAt, emits
Authentication.MfaVerified with stepUp: true and answers { status: "ok", stepUp: true, user, method, proof }:
- a Bearer session also receives new
accessToken/refreshTokenin its same refresh family, carrying the newmfaAt; the previous tokens keep the old one; - a cookie session is saved with the request, no token is issued;
- a delegated OAuth token (issued to a client) is not re-issued: it keeps its client restrictions.
maxMfaAge reads the session only (no I/O). Since WebdaQL has NOW(), a permission query can express the same
freshness (permission: "mfa = 'verified' AND mfaAt > NOW('-10m')"), but it refuses with a plain 403: maxMfaAge
gives clients the dedicated MFA_STEP_UP_REQUIRED answer and the maxAge to request.
Linking policy
linking decides what happens when an unauthenticated login presents an email that an existing account owns. Only
a verified email ident counts as an owner under never and verified; an unverified one is treated as absent.
| Policy | Provider asserts the email as verified (and trusted) | Provider does not assert verification |
|---|---|---|
never | ACCOUNT_EXISTS (log in, then link) | ACCOUNT_EXISTS |
verified | linked to the owning account | ACCOUNT_EXISTS |
always | linked (admin accepted risk, owner may be unverified) | linked |
Other rules:
- A logged-in user presenting an ident that belongs to another user gets
IDENT_LINKED_ELSEWHERE. - If the provider hands a user (
identity.user) that differs from the ident's owner:ACCOUNT_EXISTS. - An existing ident with no owner is adopted by the resolved, current or newly registered user.
- The additional
emailident is only created when the provider asserts (trusted) verification. - With
registration: falsea new user is refused withREGISTRATION_DISABLED.
Events
Listen on the Authentication service. All payloads include context.
| Event | Payload |
|---|---|
Authentication.Register | user, data, identId, identity |
Authentication.Login | userId, user, identId, ident, provider, identity |
Authentication.LoginFailed | user (undefined for an unknown email) |
Authentication.Logout | - |
Authentication.GetMe | user |
Authentication.PasswordCreate | user, password |
Authentication.PasswordUpdate | user, password |
Authentication.Linked | user, ident |
Authentication.Unlinked | user, ident (projection: uuid, provider, providerUid, email, verifiedAt, lastUsedAt, userId; no provider tokens nor profile) |
Authentication.MfaEnabled | user, userId, method |
Authentication.MfaDisabled | user, userId, method |
Authentication.MfaVerified | user, userId, method, stepUp (true when a logged session verified again) |
Authentication.MfaFailed | user, userId, method |
The identity of Authentication.Register and Authentication.Login never carries the provider tokens (see
OAuth providers). Authentication.Register is emitted before the new user is saved: it can still
change it. Authentication.LoginFailed
carries { context, user } only (user is undefined for an unknown email).
Errors
| Code | HTTP | Meaning |
|---|---|---|
ACCOUNT_EXISTS | 409 | An account owns this email, log in to link |
IDENT_LINKED_ELSEWHERE | 409 | The ident belongs to another user |
IDENT_CONFLICT | 409 | A v3 ident upgrades onto a key another user holds |
LAST_LOGIN_METHOD | 409 | The last login method cannot be removed |
INVALID_CREDENTIALS | 403 | Wrong email or password |
REGISTRATION_DISABLED | 403 | Automatic registration is off |
EMAIL_DOMAIN_NOT_ALLOWED | 403 | The provider policy refuses this email (domain, or not verified) |
TOKEN_INVALID | 403 | Malformed, wrong purpose, revoked or reused token |
TOKEN_EXPIRED | 410 | Expired emailed token |
THROTTLED | 429 | Too many attempts, or a mail was sent too recently |
INVALID_MFA_CODE | 401 | Invalid, replayed, or unrecognised MFA verification or recovery code |
MFA_NOT_ENABLED | 400 | MFA (or specific method) is not enabled for the user |
MFA_ALREADY_ENABLED | 409 | Factor method is already enabled for the user |
MFA_NOT_PENDING | 401 | Session is neither logged in nor pending second factor verification |
MFA_REQUIRED | 403 | The operation has maxMfaAge and the user has no second factor |
MFA_STEP_UP_REQUIRED | 401 | Second factor older than maxMfaAge, details.maxAge in seconds |
INVALID_PHONE | 400 | The SMS enrollment phone is not an E.164 number (+ country code) |
PASSWORD_POLICY | 400 | The password does not satisfy the policy |
INVALID_IDENT | 400 | Malformed ident |
UNSUPPORTED_MEDIA_TYPE | 415 | Auth.<Provider>.Token called without a JSON body |
BAD_REQUEST | 400 | complete() called for a provider name that is not registered |
Importable from @webda/auth (AccountExists, Throttled, InvalidMfaCode, MfaNotEnabled, ...).
Password policy
password.policy is a regular expression (default .{8,}). For anything else, write a service with a
validate(password, user?) method returning (a promise of) a boolean, and reference its name in password.verifier:
@Bean
export class MyVerifier extends Service {
async validate(password: string, user?: User): Promise<boolean> {
return password.length >= 12 && !password.includes(user?.email ?? "\0");
}
}
The policy is process-wide: with several EmailPasswordProvider instances, the last one resolved wins.
Mail templates
The provider sends through the configured Mailer with these templates and replacements { url, token, to }:
EMAIL_REGISTER: registration and verification linksEMAIL_RECOVERY: password recovery link
url is the full link (<url>/verify?token=... or <url>/recover?token=...), token the raw token, to the address.
A relative url parameter is made absolute from the request when one is available; configure an absolute url so
links are correct whatever triggered the mail.
Emailed tokens are purpose-scoped JWTs (audience webda-email): register and verify 24 h, recover 1 h.
Known limitations
- Access tokens stay valid until expiry after logout or refresh-family revocation (default 15 minutes); a password change does end them (see above).
Auth.<Provider>.Tokenaccepts a valid provider token until it expires: a client must protect the tokens it obtains (there is no server nonce on this operation).Auth.Email.RegisteranswersACCOUNT_EXISTSfor a registered email, so it can be used to enumerate accounts.