# Magic links

A magic link signs a person in by email, with no password. They type
their address, and Jadpo sends one email holding a link and a six-digit
code. Either signs them in, once:

- the link opens a page of your frontend, which posts the link's token to
  Jadpo and gets a browser session;
- the code is typed into your own sign-in page, which posts it the same
  way, or your own mobile or command-line client trades it for an API
  token.

Jadpo generates the endpoints, never the pages: your frontend owns every
page.

Magic links need three things besides `magic_link:` itself:

- `browser_session:` with its `origin:`, since the link starts a browser
  session at that origin;
- an email connection in `application { }`'s `email:`, which sends the
  email, unless `magic_link:` names its own `send:` action
  ([Your own email](magic-links.md#your-own-email));
- `application { display_name: }`, your app's name, which the email
  uses.

`link:` is the one setting you must write: the path of the frontend page
the email's link opens, such as `"/sign-in/confirm"`.

A whole app with magic links: the settings, the email connection and the
app's name, the member, and the action that signs a new member up the
first time they sign in.

```jadpo
config {
    browser_origin: Url
    mail_sender: Email
    smtp_host: Hostname
}
```

```jadpo
use accounts/entities { Member }

application {
    display_name: "Notes"
    connections: {
        mail: {
            driver: Driver.smtp
            host: config.smtp_host
        }
    }
    email: {
        connection: mail
        from: config.mail_sender
    }
}

principal {
    user: {
        subject: Member.email
    }
}
```

```jadpo
use accounts

authentication {
    browser_session: {
        cookie: "notes_session"
        origin: config.browser_origin
    }
    api_token: {}
    sign_in: {
        magic_link: {
            link: "/sign-in/confirm"
            same_browser: true
        }
    }
    on_first_sign_in: accounts.create_member
}
```

```jadpo
entity Member {
    id: Uuid
    email: Email {
        unique: true
    }

    policy: {
        create: [Access.system]
        read: [id]
    }
}
```

`on_first_sign_in:` names `create_member`, which runs for an address no
member has ([A person the app has not seen](a-person-the-app-has-not-seen.md)).
It runs as the system, the identity Jadpo uses for work no person asked
for (`Access.system` grants it), in its own transaction, and reaches no
tenant's records:

```jadpo
use accounts/entities { Member }
use accounts/failures { SignUpClosed }

/// Signs a new member up the first time they sign in with a proved address.
action create_member(first: FirstSignIn) fails SignUpClosed -> Member.id {
    if (not first.address_verified) {
        reject SignUpClosed {}
    }
    match first.address {
        none -> {
            reject SignUpClosed {}
        }
        some(address) -> {
            var member: Member = attempt Member.create {
                set: {
                    email: address
                }
                conflict: {
                    Member.email: SignUpClosed
                }
            }
            return member.id
        }
    }
}
```

The API record lists the routes Jadpo answers, each with a fixed body and
answer. The route that trades a code for a token comes with `api_token:`:

```jadpo
// Generated by Jadpo from the routes of API main; update it with `jadpo api update`, never by hand.
record api 1

api main
    authentication api_token
    authentication browser_session

POST /auth/magic-link
    generated magic link
    authentication none
    input MagicLinkAsk (body)
    status 202

POST /auth/magic-link/confirm
    generated magic link
    authentication none
    input MagicLinkConfirmation (body)
    output MagicLinkConfirmed
    status 200

POST /auth/magic-link/redeem
    generated magic link
    authentication none
    input MagicLinkCode (body)
    output MagicLinkToken
    status 200

GET /openapi.json
    generated openapi
    status 200

output MagicLinkConfirmed
    return_to: Text

output MagicLinkToken
    token: ApiToken
```

## The routes

Jadpo generates three routes under `path:`, `/auth/magic-link` unless you
change it. Anyone may call them:

```text
POST /auth/magic-link            ask for a link and a code
POST /auth/magic-link/confirm    a link's token or a code: starts a browser session
POST /auth/magic-link/redeem     a code for an API token (needs api_token:)
```

Each route's response shape is fixed
([Generated routes](generated-routes.md)). A failure comes back in
the usual failure body, and your frontend shows its wording, which the
per-locale failure export carries ([Configuration and
time](10-configuration-and-time.md)).

## Asking for a link

```text
POST /auth/magic-link
{ "address": "ada@example.com", "return_to": "/notes", "locale": "fr_fr" }

202 Accepted                      (no body; the same for every address)
```

- `address` is required. `return_to` and `locale` are optional.
- `return_to` is where the person goes once signed in. It must be a path
  on your app's own site: it starts with one `/`, and anything else, such
  as `//evil.example` or `https://...`, is a 422 `invalid_input`. Without
  it the person goes to `/`.
- The email's language is `locale` when it is a supported locale, else the
  best supported match for the request's `Accept-Language`, else your
  default locale.
- The answer is `202` with no body whether or not an account has the
  address, so the route never tells anyone which addresses have an
  account. The email is sent after the request commits when an account
  has the address, or when you declare `on_first_sign_in:`. Otherwise
  nothing is sent.
- Sends to one address are limited, counted the same whether or not an
  account has it: one every `resend_after:`, and `codes_per_hour:` an
  hour. Over either limit the answer is the built-in `rate_limited` (429,
  with `Retry-After`), the same for unknown addresses.
- Each send makes the earlier link and code for that address stop
  working. Only the latest is ever valid.

## Confirming a link or a code

The link is `<origin><link>?token=<64 hexadecimal digits>`: the
`origin:` of `browser_session:` and the path `link:` names, for example
`https://notes.example/sign-in/confirm?token=3f9a...c1`. Your page reads
the token and posts it. Loading the page uses up nothing, so a mail
scanner that opens the link signs no one in. Only the `POST` does:

```text
POST /auth/magic-link/confirm
{ "token": "3f9a...c1" }

200 OK
{ "return_to": "/notes" }          (the request's return_to)
Set-Cookie: notes_session=...; notes_session_csrf=...
```

Your own sign-in page posts the code instead, and gets the same answer:

```text
POST /auth/magic-link/confirm
{ "address": "ada@example.com", "code": "042317" }
```

Your own client trades the code for an API token:

```text
POST /auth/magic-link/redeem
{ "address": "ada@example.com", "code": "042317" }

200 OK
{ "token": "jadpo_token_9c1e..." }
```

- A confirm `POST` needs an `Origin` header naming your app's own site,
  as any write with the session cookie does.
- After signing in, your frontend is given only the `return_to` path
  stored when the person asked, never an address the link carries.
- A person signed in by link or code has proved they control the address,
  so it reaches `on_first_sign_in:` as verified.

## Settings

Every setting but `link:` has a default. Tightening one is always
allowed. Loosening one past its limit will need an acceptance: a recorded,
reviewed sign-off on a change that weakens security
([Acceptances](acceptances.md)), which shows in
the approval report.

| Setting | Default | Needs an acceptance above |
|---|---|---|
| `address:` | the `email` field of the principal's user entity | |
| `expires_after:` | `15m`, how long a link and a code last | `1h` |
| `code_attempts:` | `5` wrong guesses end a code | `10` |
| `codes_per_hour:` | `10` sends per address | |
| `failed_codes_per_day:` | `20` wrong guesses per address | `50` |
| `resend_after:` | `1m` between sends to one address | |
| `same_browser:` | `false` | |
| `link:` | none: required, the frontend path the email's link opens, such as `"/sign-in/confirm"` | |
| `path:` | `"/auth/magic-link"`, where the generated routes go | |
| `generate_routes:` | `true`; `false` generates none | |
| `email:` | Jadpo's own email | |
| `send:` | none | |

`address:` names a field of the principal's user entity that is built on
`Email` and is `unique: true`. Leave it out and the entity's `email` field
is used. With no such field, `address:` is required.

## Wrong codes

`code_attempts:` wrong guesses end that code. Every wrong guess gets the
same answer, `sign_in_code_invalid`, never a count of guesses left. So
does a code that was used, ended, expired or replaced, and an address with
no code.

When an address reaches `failed_codes_per_day:` wrong guesses in a day
(UTC), codes stop working for it until the next day
(`sign_in_codes_disabled`), while links still work. Nothing locks an
account, so an attacker can't lock a person out.

## Only in the browser that asked: `same_browser:`

`same_browser: true` makes a link work only in the browser that asked for
it. `POST /auth/magic-link` sets a `<cookie>_sign_in` cookie (`HttpOnly`,
`Secure`, `SameSite=Lax`, lasting `expires_after:`), and the confirm
refuses a link from any other browser (`sign_in_link_other_browser`). The
code still works anywhere.

## What is kept

Link tokens are 32 random bytes. Codes are six random digits, kept as
text so a leading `0` stays. Jadpo keeps both only as verifiers, with a
digest of the address rather than the address itself, in the
`jadpo_sign_in_requests` system table. That table is in the store of the
principal's user entity, beside `jadpo_sessions`
([System tables](system-tables.md)). Each link and code works
once, and using either ends the other.

## Writing the routes yourself

Use it when your sign-in needs a step of its own around the generated
behaviour. `path:` only moves the generated routes. `generate_routes:
false` generates none, and your app writes its own routes over the
operations below. They keep every protection above: the limits, only the
latest being valid, the wrong-code rules, `same_browser:` and
`on_first_sign_in:`.

| Operation | Does |
|---|---|
| `magic_links.request(address: Email, locale: Locale, return_to: Text?)` | Sends the link and code after commit, as `POST /auth/magic-link` does; fails with `rate_limited`, and with `sign_in_return_invalid` (`invalid_value`, 422) for a `return_to` that is not a path on the app's site |
| `magic_links.redeem(token: SignInToken)` | Uses a link's token; gives a `MagicLinkRedeemed` (`user_id`, `return_to: Text`) or fails with a `sign_in_` failure |
| `magic_links.redeem_code(address: Email, code: SignInCode)` | The same for a code |

Your action then calls `sessions.start(redeemed.user_id)` or
`sessions.issue_token(redeemed.user_id)`:

```jadpo
// domains/accounts/actions/confirm_sign_in.jadpo
action confirm_sign_in(input: ConfirmSignIn) -> SignedInView {
    var redeemed: MagicLinkRedeemed = magic_links.redeem(input.token)
    sessions.start(redeemed.user_id)
    ...
}
```

- Only actions call these operations, as with `sessions.start`.
- `same_browser:`'s cookie is set on the response of the route whose
  action calls `magic_links.request`.
- The `sign_in_` failures are built in and raised by Jadpo. Like every
  sign-in failure, they pass through an action without being listed in
  its `fails` ([Failures](failures.md)).

## Your own email

Jadpo sends its own magic-link email, worded for you, through your
`email:` connection. Two settings change that
([Built-in emails](built-in-services.md)):

- `email:` names an email type of your own, built from the fields of the
  built-in `MagicLinkEmail`. Jadpo still makes the link and the code and
  sends it.
- `send:` names an action of yours that sends the email itself, given a
  `MagicLinkRequest`.

You can write one of them, not both.

Not built yet: a setting loosened past its limit is
`toolchain.not_built_yet` until acceptances are built. Keep each setting
within its limit for now.
