# Auth extensibility

Extend authentication from a plugin — custom strategies, external sign-in (OAuth/SSO), auth-flow hooks, and first-class multi-step challenges (2FA) — without forking core.

> **Alpha (`0.x`) — `@experimental`.** The auth-extensibility surface (D71/D57) is
> new and ships `@experimental`: it works and is integration-tested, but stays
> experimental until a first-party plugin exercises it in production (D55). Pin
> your versions.

Nextly's auth is pluggable through four surfaces, modeled on what Payload, Strapi,
and WordPress allow — and adding first-class **multi-step (2FA)** that those do
piecemeal:

| Surface             | What it does                                                                                             | Who provides it                                       |
| ------------------- | -------------------------------------------------------------------------------------------------------- | ----------------------------------------------------- |
| **Strategies**      | Decide *who a user is* from a credential the request carries (password, an API token, a magic-link code) | App opt-in (`defineConfig({ auth: { strategies } })`) |
| **Auth-flow hooks** | Modify / abort / **challenge** the login, register, logout, session flow                                 | Plugin `contributes.auth.hooks`                       |
| **Challenges**      | A pending second step (2FA) that pauses login until resolved                                             | Plugin `contributes.auth.challenges`                  |
| **Auth-page UI**    | Provider buttons, challenge views, login-form slots                                                      | Plugin `contributes.auth.ui`                          |

The built-in email/password login is itself just the `password` strategy, always
present and run last; it cannot be disabled. A sign-in that starts with a
redirect — OAuth, SSO — is not a strategy: see
[signing in with an external provider](#worked-example-signing-in-with-an-external-provider).

## Strategies are opt-in (the trust boundary)

A strategy authenticates users, so it's the highest-trust extension point. A plugin
may *ship* a strategy, but it only takes effect when the **app** enables it:

```ts
// nextly.config.ts
import { defineConfig } from "nextly/config";
import { googleOneTap } from "@acme/nextly-plugin-google";

export default defineConfig({
  // One Tap posts a Google ID token with the request, so it is a strategy.
  auth: { strategies: [googleOneTap({ clientId: process.env.GOOGLE_CLIENT_ID! })] },
});
```

Hooks, challenges, and UI follow normal contribution rules (active as soon as the
plugin is installed). Strategies don't — that's deliberate (secure-by-default).

## Worked example: signing in with an external provider

A provider whose sign-in starts with a redirect does not fit the strategy chain,
which runs against a request that already carries a credential. Such a plugin
mounts its own routes and finishes the login with `ctx.auth.completeLogin`.

```ts
import { definePlugin } from "@nextlyhq/plugin-sdk";

export const googleAuth = definePlugin({
  name: "@acme/google-auth",
  version: "1.0.0",
  nextly: ">=0.0.2-alpha.63",
  // Finishing a login signs in whichever account the plugin names, so it is
  // declared where an operator reviews the plugin.
  capabilities: { auth: { login: true } },
  contributes: {
    routes: [
      {
        method: "GET",
        path: "/google/authorize",
        public: true,
        handler: (_req, ctx) => redirectToGoogle(ctx),
      },
      {
        method: "GET",
        path: "/google/callback",
        public: true,
        async handler(request, ctx) {
          // Proving who this is stays the plugin's job; see the duties below.
          // This checks the `state` cookie, sends the PKCE verifier, and
          // validates the ID token's `iss`, `aud` and `nonce`.
          const profile = await exchangeCodeForProfile(request, ctx);

          // Keyed on the provider's stable subject, never on an email alone,
          // and never on anything the request supplies.
          const user = await findLocalUserFor(profile, ctx);
          if (!user) {
            // A relative redirect: `Response.redirect` needs an absolute URL
            // and throws on this one.
            return new Response(null, {
              status: 302,
              headers: { Location: "/admin/login?error=signin-failed" },
            });
          }

          // Core finishes the login: the account-state gate, the hooks, the
          // audit row, the cookies, and a second factor if one is configured.
          return ctx.auth.completeLogin(user.id, {
            request,
            // Begins with the plugin's slug, so the audit trail says which
            // plugin signed this person in.
            strategy: "acme-google-auth:google",
            next: "/admin",
          });
        },
      },
    ],
    auth: {
      ui: {
        providers: [
          {
            strategy: "acme-google-auth:google",
            label: "Continue with Google",
            // Where the button goes. A provider with neither an href nor a
            // component, or with an href that is not a same-origin path, is
            // dropped from the login page and reported at boot.
            // Routes are served under the raw package name.
            href: "/admin/api/plugins/@acme/google-auth/google/authorize",
          },
        ],
      },
    },
  },
});

declare function redirectToGoogle(ctx: unknown): Response;
declare function exchangeCodeForProfile(
  request: Request,
  ctx: unknown
): Promise<{ sub: string; email: string; emailVerified: boolean }>;
declare function findLocalUserFor(
  profile: { sub: string; email: string; emailVerified: boolean },
  ctx: unknown
): Promise<{ id: string } | null>;
```

**What stays the plugin's job.** Core cannot see the provider exchange, so it
cannot check any of it. Each of these is what stops a login CSRF or an account
takeover ([RFC 9700](https://www.rfc-editor.org/rfc/rfc9700)):

* Bind the `state` parameter to an HttpOnly, single-use cookie set by the
  authorize route, and refuse a callback whose `state` does not match it.
* Use PKCE with `S256`.
* Validate the ID token: its signature, `iss`, `aud`, expiry, and the `nonce`
  you sent.
* Key the linked account on the provider's `sub`, which is stable, rather than
  on an email address, which can change hands.
* Match an existing account by email only when the provider says the address
  is verified (`email_verified`).
* Never take a user id from the request: pass `completeLogin` only an id your
  own lookup produced.
* Clear the transaction cookie on the response, on success and on failure.

**What `completeLogin` does for you**, in this order: loads the user by id, so a
caller cannot fabricate profile fields; checks that the account may hold a
session at all; runs the `beforeLogin` hooks, so a maintenance mode or an IP
allowlist applies to an external login too; runs the `afterAuthenticate` hooks,
which is where a second factor interrupts; and only then mints the session.

The account check comes before the hooks because a hook may act, sending a code
or writing a record, and must never do so for an account that cannot sign in.
Such an account reaches no hook; its attempt is still recorded in the
`login-failed` audit row.

The password-attempt lockout does NOT apply here, and a plugin cannot record its
login as the `password` strategy to switch it on: the strategy must start with
the plugin's own slug. An external login is not a password attempt, and
otherwise anyone who knows an address could lock its owner out of their
provider with five wrong guesses.

It answers every login outcome with a redirect, so every plugin fails the same
safe way: a refusal lands on `/admin/login?error=signin-failed` with an audit
row that names no account, and a second factor, or an administrator-set
password that must be replaced first, lands on `/admin/login?resume=1` with
the pending token in an HttpOnly cookie rather than in the URL. A hook that
blocks an external login throws a typed 4xx `NextlyError` (for example
`NextlyError.forbidden`) to be answered that way.
What is not a login outcome is thrown to the calling route instead: an untyped
or 5xx error, an `afterAuthenticate` hook returning another account, and a
plugin calling `completeLogin` without `capabilities.auth.login` or with a
strategy name that does not begin with its own slug.

Strategies remain app-level: they live in `defineConfig({ auth: { strategies } })`
rather than in a plugin. A plugin that finishes logins with `completeLogin`
does add a way in, which is why it must declare `capabilities.auth.login` —
`nextly plugins info` shows it, and without it `completeLogin` refuses — and
why its strategy names must begin with its own slug (`acme-google-auth:`), so
every audit row names the plugin that signed someone in.

## Worked example: TOTP two-factor (a challenge)

2FA is an `afterAuthenticate` hook that returns a **challenge** instead of letting
the session issue, plus a **challenge definition** that resolves the code. Core
handles the hard part — a single-purpose, short-lived **pending token**, the
attempt cap, and re-issuing the session only after the challenge resolves.

```ts
import { definePlugin } from "@nextlyhq/plugin-sdk";

export const twoFactor = definePlugin({
  name: "@acme/nextly-plugin-2fa",
  version: "0.1.0",
  nextly: ">=0.0.2-alpha.63",
  contributes: {
    auth: {
      // 1. After the password (or any strategy) authenticates, require a second
      //    step IF the user has 2FA enabled. Returning `{ challenge }` pauses login.
      hooks: {
        afterAuthenticate: async (user, ctx) => {
          const enabled = await isTotpEnabled(ctx, user.id);
          if (!enabled) return user; // continue → session issues
          return { challenge: { id: "totp", userId: user.id } };
        },
      },
      // 2. Resolve the challenge: validate the TOTP code the client submits.
      challenges: [
        {
          id: "totp",
          resolve: async ({ userId, response }, ctx) => {
            const ok = await verifyTotp(ctx, userId, String(response.code ?? ""));
            return ok ? { ok: true } : { ok: false, reason: "bad-code" };
          },
        },
      ],
      // 3. Render the step UI when the login response is a `totp` challenge.
      ui: { challengeViews: { totp: "@acme/nextly-plugin-2fa/admin#TotpPrompt" } },
    },
  },
});
```

### The challenge flow on the wire

1. `POST /auth/login` with valid credentials → the `afterAuthenticate` hook returns
   a challenge, so the response is **not** a session but:
   ```json
   { "status": "challenge", "challengeType": "totp", "pendingToken": "…", "uiHint": null }
   ```
   `uiHint` is the challenge's `uiHint`, or `null` when it sets none. A login
   finished by `ctx.auth.completeLogin` instead redirects to
   `/admin/login?resume=1` with the pending token in an HttpOnly cookie, as it
   does for an administrator-set password that must be replaced first.
2. The login page renders the `challengeViews["totp"]` component with
   `ChallengeViewProps` from `@nextlyhq/plugin-sdk/admin`. The view collects the
   code and calls `resolve({ code })`; the **host** posts it:
   ```
   POST /auth/challenge/resolve   { "pendingToken": "…", "response": { "code": "123456" } }
   ```
   with the pending token from the body, or from the cookie when the login
   resumed. `pendingToken` in the view's props is deprecated: a resumed login
   has none to give it. A view that still posts its own answer with it keeps
   working, and only for such a view does `onResolved` decide where the login
   lands.
3. Core verifies the pending token (single-purpose, TTL'd, attempt-capped), runs your
   challenge's `resolve`, and on success issues the real session — identical to a
   direct login — and the host navigates to the login's destination. `resolve`
   answers `{ ok, error?, continues?, next? }`: `continues` means another step
   follows (a forced password change), so the view must not call `onResolved`.
   Calling it after the host has navigated changes nothing. A wrong code
   re-issues a pending token until the cap is hit; the last refusal ends the
   flow and the page says so.

The pending token authorizes **nothing** except resolving the challenge — it can never
establish a session (the access guard rejects it). One challenge pauses a
login at a time: the first an `afterAuthenticate` hook returns is the one asked,
and once it is answered only core's forced password change can follow.

## Auth-flow hooks reference

All hooks are optional on `contributes.auth.hooks`. Any of them can **abort** by
throwing — a `NextlyError` keeps its own code, anything else becomes a generic
error. `beforeRegister`, `afterAuthenticate`, `determineUser` and
`customizeClaims` can also **modify** by returning a value; the others observe,
and what they return is ignored. `afterAuthenticate` can **challenge**.

| Hook                            | When                                                                       | Use                                                                                                                                                                                                                                                                                                                                        |
| ------------------------------- | -------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `beforeLogin(input)`            | Before any strategy; in `completeLogin`, after the account check           | Pre-checks; throw to block (maintenance mode, IP allowlist)                                                                                                                                                                                                                                                                                |
| `afterAuthenticate(user)`       | After a strategy identifies the user                                       | **Insert 2FA / step-up**; decorate the user (the id must stay the same, or the login fails)                                                                                                                                                                                                                                                |
| `afterLogin(user)`              | After the session is minted, before the success audit row and the response | Side effects (last-login, notify); a throw fails the login                                                                                                                                                                                                                                                                                 |
| `beforeRegister(data)`          | Before a new user is created                                               | Normalize / augment the payload                                                                                                                                                                                                                                                                                                            |
| `afterRegister(user)`           | After a successful register                                                | Welcome email, provisioning, CRM sync                                                                                                                                                                                                                                                                                                      |
| `beforeLogout` / `afterLogout`  | Around logout                                                              | Cleanup, revoke external sessions. `beforeLogout` receives no user                                                                                                                                                                                                                                                                         |
| `determineUser(request)`        | `GET /auth/session` only                                                   | Custom credential (API key, alt token) → return a user or `null`. The returned user must still pass the account gate: an unknown, deactivated, or (where sign-in requires it) unverified account is not answered as a session                                                                                                              |
| `customizeClaims(claims, user)` | Building the JWT (login **and** refresh)                                   | Add JWT claims only. Every claim core built — identity, `roleIds` and the token claims — is restored as core built it. A claim named after a configured user field can be neither added nor changed, and one whose value could not be read is left out rather than set to null. The reserved `nbf`, `aud`, `iss` and `typ` cannot be added |

Hooks observe-or-mutate; they do **not** replace strategies. Use a strategy to
authenticate, a hook to gate/decorate.

## What stays core (and why)

Session/token issuance, cookies, CSRF, rate-limiting, and account lockout stay in core
— they're security-critical and identical for every auth method. You extend *who you
are* and *the flow*, not the session machinery. This matches where Payload, Strapi, and
WordPress draw the line.

## Security notes

* **Strategies are app-opt-in; a plugin's own login is not.** A strategy
  authenticates no one until the app lists it, but any installed plugin that
  declares `capabilities.auth.login` can finish a login with
  `ctx.auth.completeLogin` for whichever account it names. Check that
  declaration with `nextly plugins info` before installing.
* An enabled strategy/hook runs with **full trust** (D34, no sandbox in v1) — vet auth
  plugins like any dependency.
* Multi-step uses a **single-purpose pending token** (short TTL, attempt-capped) that
  can't be used as a session.
* The whole surface is **`@experimental`** until a first-party plugin graduates it (D55).

See also: [Permissions](https://nextlyhq.com/docs/plugins/permissions.md) · [Data access](https://nextlyhq.com/docs/plugins/services.md) · [API reference](https://nextlyhq.com/docs/plugins/api-reference.md).
