`magicLink()` issues a hashed, single-use token (default 10 minutes). Request sends the link
via Channel (`auth-magic-link`); verify exchanges it for a hybrid session and creates the user
on first success.

<Callout title="The one rule">
  Enable `gate.auth`, then `.plug(magicLink())`. Tokens are hashed at rest — never log the raw link.
  Delivery goes through `fx.send`; use `exposeDevToken` only for local DX without SMTP.
</Callout>

## Quick start

<Steps>

<Step>
### Plug it

```typescript title="src/app.ts"
import { oke } from "okengine";
import { magicLink } from "okengine/plugins";

export const app = oke({
  name: "shop",
  env: "dev",
  gate: { auth: {} },
}).plug(magicLink({ baseUrl: "http://127.0.0.1:6530" }));
```

</Step>

<Step>
### Request a link

```typescript
const { data } = await api.auth.requestMagicLink({ email: "ali@example.com" });
// data.ok === true; Channel delivers auth-magic-link
// data.devToken only when exposeDevToken
```

`POST /auth/magic-link/request`. Under `oke test` the `console` driver captures mail; with
`oke dev`, Mailpit receives the real SMTP message.

</Step>

<Step>
### Verify

```typescript
const { data } = await api.auth.verifyMagicLink({ token });
// session tokens + userId
```

`POST /auth/magic-link/verify`. Bad or reused tokens → `AuthFailed` /
`invalid_credentials`.

</Step>

</Steps>

<Callout type="info" title="Pre-account hijack defense">
  Completing verify proves inbox ownership. An **unverified** email+password account parked on that
  address is reclaimed (sessions revoked, hashes cleared, `emailVerified` set). Verified owners
  re-auth only — no credential purge.
</Callout>

## Options

| Option           | Type                | Default                    | Meaning                                   |
| ---------------- | ------------------- | -------------------------- | ----------------------------------------- |
| `secret`         | `string`            | active\*                   | HMAC secret (\*from `gate.auth`)          |
| `sessions`       | `SessionStore`      | active\*                   | Session store                             |
| `ttlMs`          | `number`            | 10m                        | Challenge lifetime                        |
| `baseUrl`        | `string`            | `OKE_APP_URL` or `:6530`   | Origin used to build the magic link       |
| `from`           | `string`            | `OKE <no-reply@oke.local>` | Template From address                     |
| `exposeDevToken` | `boolean`           | `false`                    | Include raw token in the request response |
| `identities`     | `IdentityStore`     | new                        | Email → user map                          |
| `verifications`  | `VerificationStore` | new                        | Challenge store                           |

## Surfaces

| Flow                    | Path                            | Gate                     |
| ----------------------- | ------------------------------- | ------------------------ |
| `auth.requestMagicLink` | `POST /auth/magic-link/request` | `gate.public` + otp rate |
| `auth.verifyMagicLink`  | `POST /auth/magic-link/verify`  | `gate.public` + otp rate |

**Consequence:** the plugin contributes the `auth-magic-link` Channel template and EN/AR
catalog bodies (`{{link}}`, `{{token}}`). Override copy by merging your own catalog at boot.

## Delivery drivers

| `drivers.channel.email` | Delivery                                              |
| ----------------------- | ----------------------------------------------------- |
| `console`               | Dev inbox (local/test default)                        |
| `smtp`                  | Any SMTP host — Mailpit under `oke dev` (dev default) |
| `resend` / `sndr`       | Hosted email APIs                                     |
| `taqnyat-mail`          | Taqnyat Mail API (additive option)                    |

### Taqnyat Mail

```typescript title="oke.config.ts"
export default {
  drivers: {
    channel: {
      email: { dev: "smtp", test: "console", prod: "taqnyat-mail" },
    },
  },
};
```

| Env                  | Meaning                                          |
| -------------------- | ------------------------------------------------ |
| `TAQNYAT_MAIL_TOKEN` | Taqnyat bearer token enabled for Email           |
| `TAQNYAT_CAMPAIGN`   | Campaign name required by Taqnyat `mailSend.php` |

The plugin needs no change — delivery stays Channel-mediated via `fx.send`. SMTP/Mailpit
remains the default docker path; `taqnyat-mail` is strictly additive.

## Live tests (opt-in)

The Taqnyat live suite sends real email and burns real quota, so it is double-gated: it runs
only when `OKE_EMAIL_LIVE=1` **and** the real credentials (`TAQNYAT_MAIL_TOKEN`,
`TAQNYAT_CAMPAIGN`, plus `OKE_TEST_TAQNYAT_MAIL`) are all present.

Credentials alone never send; without the flag the suite skips visibly — never a silent pass.

```bash
OKE_EMAIL_LIVE=1 TAQNYAT_MAIL_TOKEN=… TAQNYAT_CAMPAIGN=auth \
  OKE_TEST_TAQNYAT_MAIL=you@example.com bun test src/plugins
```

## Troubleshooting

<Accordions>
<Accordion title="verify returns invalid_credentials">

Token expired (default 10m), already used, or mistyped. Request a new link.

</Accordion>
<Accordion title="No email arrived">

In `local` / `test` the `console` driver captures mail into the inbox — nothing hits a mailbox.
Run `oke dev` and open Mailpit (`MAILPIT_UI_URL`) to see the rendered message. For
unit tests without SMTP, set `exposeDevToken: true`.

</Accordion>
</Accordions>

## Learn more

- [OTP](/docs/plugins/otp) — numeric code instead of a link
- [Gate](/docs/elements/gate) — `gate.auth`
- [Channel](/docs/elements/channel) — `fx.send`, Mailpit, consents

## Next

<Cards>
  <Card title="OTP" description="SMS, WhatsApp, or email codes." href="/docs/plugins/otp" />
  <Card title="Gate" description="Builtin auth and policies." href="/docs/elements/gate" />
  <Card title="Channel" description="Email delivery and Mailpit." href="/docs/elements/channel" />
</Cards>
