diff --git a/docs/contributing.md b/docs/contributing.md index f74bd91..ec3b2c0 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -231,7 +231,7 @@ mutation UpdateUser { } mutation DeleteUser { - _delete_user(params: { email: "signup.test134523@yopmail.com" }) { + _delete_user(params: { id: "b1e2c3d4-5678-90ab-cdef-1234567890ab" }) { message } } diff --git a/docs/core/email-verification-contract.md b/docs/core/email-verification-contract.md new file mode 100644 index 0000000..ef0e15a --- /dev/null +++ b/docs/core/email-verification-contract.md @@ -0,0 +1,196 @@ +--- +sidebar_position: 7 +title: Email Verification Contract +--- + +# Email verification contract + +Authorizer resolves a local account from an email address in several flows — +password signup, magic link, and every social/enterprise federated login. This +page states when an address counts as **verified**, why federated logins are +held to that bar, and what you need to configure. + +:::warning Behaviour change in 2.4.0 + +A social login whose provider does not attest the email address is now refused. +See [Upgrading](#upgrading) for what to configure if this affects your +deployment. +::: + +## Why an address must be attested + +OAuth 2.0 carries no identity claims at all. OpenID Connect added `email` — and +alongside it a **separate `email_verified` boolean**, precisely because `email` +on its own proves nothing about who controls the mailbox. + +That distinction matters here because the email address is what selects the +local account. It decides signup-versus-login, and the login branch merges the +incoming federated identity into whatever account already holds the address. If +an attacker can make a provider assert an address they do not own, they land in +that account's session. + +This is the [nOAuth](https://www.descope.com/blog/post/noauth) attack class. +Microsoft Entra is the sharp case: + +- Entra **v2 ID tokens carry no `email_verified` claim at all**. +- Entra's `email` is a *mutable, unverified* directory attribute — any tenant + admin can set it to any string, including someone else's address. +- The multi-tenant endpoints (`common`, `organizations`, `consumers`) sign with + Microsoft's **global** keys, so a token minted in a free attacker-owned tenant + has a valid signature and a valid `aud`. Only the tenant distinguishes it. + +So: register a free Entra tenant, set a user's `email` to `victim@example.com`, +click "Login with Microsoft", and without this contract you are in the victim's +account. + +## The three connection classes + +Authorizer treats connections the same way Auth0 does: + +| Connection class | Is the address attested? | Behaviour | +|---|---|---| +| **Database** (password signup) | No — nobody has vouched for it | `email_verified` is `false` until the user clicks the verification link Authorizer mails them (requires `--enable-email-verification=true`; with verification disabled the address is marked verified at signup) | +| **Social** (Google, Apple, GitHub, …) | Usually yes — the provider vouches | The provider's own signal is imported directly; no separate verification round-trip | +| **Enterprise / Azure AD / OIDC** | **Not guaranteed** — enterprise directories do not promise it | Requires an explicit trust decision from you (see below) | + +## Per-provider signal + +Each provider is read from the signal that provider actually emits. There is no +single claim that works everywhere, and treating one provider's silence as +another's "true" is exactly what creates the vulnerability. + +| Provider | Signal | Notes | +|---|---|---| +| Google | `email_verified` (ID token) | Standard OIDC | +| Apple | `email_verified` (ID token) | Documented as "a string or Boolean value" — both forms accepted | +| Twitch | `email_verified` (ID token) | Standard OIDC | +| LinkedIn | `email_verified` (userinfo) | Both bool and quoted-string forms accepted | +| Microsoft | `xms_edov`, **or** a trusted tenant | No `email_verified` exists on Entra v2 — see below | +| GitHub | verified by construction | Both the public `/user` email and the `/user/emails` fallback are filtered to verified addresses | + +## How a user verifies + +With `--enable-email-verification`, `signup` creates the verification request +and sends the mail before returning "Verification email has been sent. Please +check your inbox". Clicking that link is the normal path and needs nothing else. + +**The link is valid for 30 minutes.** Expiry is enforced when the token is +validated, so an expired link is refused even if the underlying request row is +still there. + +:::info Requesting a new link invalidates the old one + +A link can also stop working *before* its 30 minutes are up. Each request +rotates a nonce, and redemption checks the token's nonce against the stored +row — so **the most recent link is always the only valid one**. + +If a user clicks an older link after requesting a fresh one, it will be +refused. That is intended, and it is the usual explanation for "the link in my +inbox doesn't work" when the user has more than one. +::: + +If it expires or never arrives: + +| Route | Who drives it | Notes | +|---|---|---| +| **`resend_verify_email`** | the user | The primary recovery. Mints a fresh link for the same address, and mints one even when no pending request remains. Gated on the address actually being unverified, so it cannot be used as an open mailer. | +| **Password login** | the user | An unverified account's password login emails an OTP instead; verifying that OTP marks the address verified. | +| **`_update_user { email_verified: true }`** | an admin | The escape hatch when the user genuinely cannot receive mail. | +| Forgot password | the user | Completing a token reset also verifies the address — a side effect of proving mailbox control, not the route to reach for. | + +None of these needs an administrator except the last. Prefer **Resend +Verification Email** over force-verifying: it has the user prove control rather +than asserting it on their behalf. + +The response to a resend is deliberately generic ("if a verification is +pending …") and identical whether or not the address exists, so the endpoint +cannot be used to test which addresses are registered. + +## Hard requirement: verification needs a working email service + +:::danger + +`--enable-email-verification=true` with no SMTP configured is a **fatal startup +error** in 2.4.0. + +Every route in the table above terminates at the same mailbox, so without a mail +path a user is created unverified and can never recover — and an unverified +account also blocks a federated login for that address. + +Set `--smtp-host`, `--smtp-port` and `--smtp-sender-email` — all three — or turn +email verification off. +::: + +## This is not the same as Auth0's post-login email check + +A post-login Action like: + +```js +exports.onExecutePostLogin = async (event, api) => { + if (!event.user.email_verified) { + api.access.deny('Please verify your email address before logging in.'); + } +}; +``` + +is a **login policy**: "should this user, whoever they are, be let in before +confirming their own address?" It is reasonably opt-in, and Authorizer's +equivalent is `--enable-email-verification`. + +What this page describes is **identity resolution**: "which local account does +this federated assertion refer to?" Getting that wrong does not inconvenience +the legitimate user — it hands their account to somebody else. Which is why the +default is secure and the escape hatch is narrowed rather than total. + +## What a refusal looks like + +The callback returns `400` before any local account lookup, so no account is +created and no existing account is touched: + +```json +{ + "error": "email_not_verified", + "error_description": "The identity provider did not confirm that you own this email address." +} +``` + +An `oauth_email_unverified` security metric and an audit event are recorded. + +## Upgrading + +Most deployments need no change — Google, Apple, GitHub, Discord, Facebook, +LinkedIn, Twitch, Twitter and Roblox all supply a signal already. + +**If you use Microsoft login**, pick one: + +- pin `--microsoft-tenant-id` to your tenant (single-tenant deployments — the + common case, and the best option); +- set `--microsoft-allowed-tenants` to the tenants you serve (multi-tenant SaaS); +- enable the `xms_edov` optional claim in your Entra app registration. + +### Compatibility mode + +If you need more time, `--oauth-allow-unverified-provider-email=true` is a +stopgap — existing users keep working and cross-credential takeover stays +blocked. + +It does **not** disable the check. An unattested address still cannot cross into +an account owned by another credential; it only re-permits same-provider +linking, which leaves two Entra tenants able to collide on one address. The +server logs a warning on every boot while it is set. + +It is not a substitute for one of the three fixes above. + +## Operator actions in the dashboard + +The Users table exposes both operator routes per user, shown only when the +relevant identifier is actually unverified: + +- **Mark Email Verified** — asserts the address is good without mailing + anything. Sends only `email_verified`; deliberately not `email`, since that + param drives the change-address flow (which clears verification and mails a + new link). +- **Resend Verification Email** — mails a fresh link so the user proves it + themselves. Preferred when you have no independent reason to trust the + address. +- **Mark Phone Verified** — the phone equivalent. diff --git a/docs/core/graphql-api.md b/docs/core/graphql-api.md index 75596ff..aa9b4a8 100644 --- a/docs/core/graphql-api.md +++ b/docs/core/graphql-api.md @@ -1242,15 +1242,24 @@ mutation { #### `_delete_user` -Mutation to delete user. This mutation is only allowed for super admins. It accepts `params` of type `DeleteUserInput` with following keys +Mutation to delete user. This mutation is only allowed for super admins. It accepts `params` of type `DeleteUserRequest` with following keys > Note: the super admin query can be access via special header with super admin secret (this is set via ENV) or `authorizer-admin` as http only cookie. **Request Params** -| Key | Description | Required | -| ------- | ---------------------------------------------------- | -------- | -| `email` | Email of user that needs to be removed from platform | true | +| Key | Description | Required | +| ---- | ------------------------------------------------- | -------- | +| `id` | ID of user that needs to be removed from platform | true | + +:::warning Breaking in 2.4.0 + +This took `email` and now takes `id` only. Email is not an identifier every +account has — a phone-only signup has none — so an email-keyed delete could not +reach those accounts at all, and there was no second identifier accepted. + +Pass the `id` you already have from `_users` or `_user`. +::: This mutation returns `Response` type with following keys diff --git a/docs/core/grpc.md b/docs/core/grpc.md index 3c5d04b..c31b654 100644 --- a/docs/core/grpc.md +++ b/docs/core/grpc.md @@ -340,7 +340,15 @@ The `AuthorizerAdminService` is served on the same gRPC port and address as `Aut #### `DeleteUser` -*Admin-only.* Delete a user by email and all associated OTP/verification data. Mirrors [`_delete_user`](./graphql-api#_delete_user). +*Admin-only.* Delete a user by id and all associated OTP/verification data. Mirrors [`_delete_user`](./graphql-api#_delete_user). + +:::warning Breaking in 2.4.0 + +`DeleteUserRequest.email` (field 1) is removed and replaced by `id` (field 2). +Field 1 is **reserved**, not reused: both are strings, so reusing the tag would +let an old client's email decode silently as an id on a delete path. Reserving +makes an old client fail loudly instead. +::: #### `VerificationRequests` diff --git a/docs/core/rest-api.md b/docs/core/rest-api.md index 65d410c..768c5c8 100644 --- a/docs/core/rest-api.md +++ b/docs/core/rest-api.md @@ -516,13 +516,19 @@ Update user profile fields (email, roles, name, etc.). #### `POST /v1/admin/delete_user` -Delete a user by email (and all associated OTP/verification data). +Delete a user by id (and all associated OTP/verification data). **Request body** -| Field | Type | Description | Required | -| ------- | -------- | ----------- | -------- | -| `email` | `string` | User email. | yes | +| Field | Type | Description | Required | +| ----- | -------- | ----------- | -------- | +| `id` | `string` | User id. | yes | + +:::warning Breaking in 2.4.0 + +This took `email` and now takes `id` only — a phone-only account has no email, +so it could not be deleted at all. +::: **Response** `{ message: string }` diff --git a/docs/core/server-config.md b/docs/core/server-config.md index 1f36b31..2ffa4bd 100644 --- a/docs/core/server-config.md +++ b/docs/core/server-config.md @@ -136,6 +136,23 @@ Organization / UI: These replace v1 flags such as `DISABLE_BASIC_AUTHENTICATION`, `DISABLE_EMAIL_VERIFICATION`, etc. See the [Auth behavior mapping](../migration/v1-to-v2#auth-behavior) for exact correspondences. +:::danger Breaking in 2.4.0 — `--enable-email-verification` requires SMTP + +Starting the server with `--enable-email-verification=true` and no working mail +path is now a **fatal boot error**. It used to start and fail silently per user. + +Every route back into an unverified account terminates at the same mailbox, so +without SMTP a user is created unverified and can never recover — and an +unverified account also blocks a federated login for the same address. That is +unrecoverable state, so it fails where an operator can see it rather than one +user at a time. + +A "working mail path" means all three of `--smtp-host`, `--smtp-port` (greater +than zero) and `--smtp-sender-email`. Setting only the host still fails. + +Either configure SMTP or set `--enable-email-verification=false`. +::: + ### Multi-factor authentication (MFA) & WebAuthn/passkeys **Breaking change**: `--enable-mfa`, `--enable-totp-login`, `--enable-email-otp`, @@ -296,7 +313,7 @@ Each provider uses its own set of flags: Other supported providers follow the same pattern: - `--facebook-client-id`, `--facebook-client-secret`, `--facebook-scopes` -- `--microsoft-client-id`, `--microsoft-client-secret`, `--microsoft-tenant-id`, `--microsoft-scopes` +- `--microsoft-client-id`, `--microsoft-client-secret`, `--microsoft-tenant-id`, `--microsoft-allowed-tenants`, `--microsoft-scopes` - `--apple-client-id`, `--apple-client-secret`, `--apple-scopes` - `--linkedin-client-id`, `--linkedin-client-secret`, `--linkedin-scopes` - `--discord-client-id`, `--discord-client-secret`, `--discord-scopes` @@ -304,6 +321,44 @@ Other supported providers follow the same pattern: - `--twitch-client-id`, `--twitch-client-secret`, `--twitch-scopes` - `--roblox-client-id`, `--roblox-client-secret`, `--roblox-scopes` +### Provider email attestation (2.4.0) + +:::warning Breaking in 2.4.0 + +A social login whose provider does **not** attest the email address no longer +signs the user into an existing account with that address. + +OAuth proves the user holds an account at the provider. It proves nothing about +the email address that provider hands back — which is why OIDC has a separate +`email_verified` claim. Microsoft Entra is the practical case: on a multi-tenant +alias the `email` claim is **mutable and unattested**, so anyone able to set a +directory attribute in *their own* tenant could assert your address and be +logged into your account. This is the [nOAuth](https://www.descope.com/blog/post/noauth) +class of account takeover. + +Signup with an unattested address still works — it just creates its own account +instead of reaching an existing one. +::: + +Attestation comes from the provider's `email_verified` claim, or Entra's +`xms_edov` optional claim. To make Entra attest: + +- pin `--microsoft-tenant-id` to a single tenant, **or** +- enable the `xms_edov` optional claim on the app registration, **or** +- list the tenants you trust in `--microsoft-allowed-tenants` when + `--microsoft-tenant-id` is a multi-tenant alias (`common`, `organizations`, + `consumers`). Empty means no restriction — and in that mode the tenant is + untrusted, so the email it asserts will not link to an existing account. + +`--oauth-allow-unverified-provider-email=true` is a temporary compatibility +escape hatch. It does **not** disable the check: an unattested address still +cannot cross into an account owned by another credential. It only re-permits +same-provider linking, which leaves two Entra tenants able to collide on one +address. The server logs a warning on every boot while it is set. + +See [Email verification contract](./email-verification-contract) for the +per-provider signal table and the upgrade path. + --- ## 8. Rate limiting diff --git a/sidebars.ts b/sidebars.ts index 4d83e98..bc96fca 100644 --- a/sidebars.ts +++ b/sidebars.ts @@ -30,6 +30,7 @@ const sidebars: SidebarsConfig = { 'core/client-registry', 'core/sso-guide', 'core/email', + 'core/email-verification-contract', 'core/rate-limiting', 'core/metrics-monitoring', ],