# Tutorial: sign in with the Praecise account

From nothing to a product that signs people in through Praecise with no
sign-in page of its own, confirms a sensitive action with a passkey, and
signs out everywhere. About thirty minutes. The finished code is one file,
[`examples/sign-in/server.mjs`](../examples/sign-in/server.mjs), which the
account service's own tests run against the service, so every step below is
known to work. The reference for each step is [sign-in.md](sign-in.md).

You need: Node 20 or later, the `jose` package, and a client at the account
service (step 0).

## 0. Be a client

A product is an entry in the account service's client list and a secret. Ask
whoever runs the account service for an entry with:

- an id, for example `my-product`;
- your host, for example `https://my-product.example`;
- the path the code comes back to, for example `/auth/praecise/callback`;
- the path you take logout tokens at, for example
  `/auth/praecise/backchannel-logout`.

The secret is 32 random bytes as base64url, set on both sides and written
nowhere else:

```
node -e "console.log(require('crypto').randomBytes(32).toString('base64url'))"
```

Then run the example as your product:

```
CLIENT_ID=my-product CLIENT_SECRET=<the secret> BASE_URL=https://my-product.example \
  node examples/sign-in/server.mjs
```

## 1. Send the person to sign in

There is no sign-in page in your product. When somebody opens an address
that needs them signed in and has no session of yours, send the browser to
the account service at once, with a PKCE challenge:

```js
const state = random(16);
const verifier = random();
pending.set(state, { verifier, nonce });

const url = new URL("https://accounts.praecise.com/auth");
url.search = new URLSearchParams({
  client_id: clientId,
  response_type: "code",
  redirect_uri: `${baseUrl}/auth/praecise/callback`,
  scope: "openid email profile",
  state,
  nonce,
  code_challenge: sha256(verifier).toString("base64url"),
  code_challenge_method: "S256",
}).toString();
res.writeHead(302, { Location: url.toString() }).end();
```

Keep `state`, `verifier` and `nonce` on your side until the browser returns.
The `redirect_uri` must be the registered one, character for character.

## 2. Take the code back

The browser returns to your callback with `code` and your `state`. Swap the
code for tokens, server to server, and verify the ID token:

```js
const answer = await fetch("https://accounts.praecise.com/token", {
  method: "POST",
  headers: {
    "Content-Type": "application/x-www-form-urlencoded",
    Authorization: `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString("base64")}`,
  },
  body: new URLSearchParams({
    grant_type: "authorization_code",
    code,
    redirect_uri: `${baseUrl}/auth/praecise/callback`,
    code_verifier: started.verifier,
  }),
});
const { id_token } = await answer.json();

const keys = createRemoteJWKSet(new URL("https://accounts.praecise.com/jwks"));
const { payload: claims } = await jwtVerify(id_token, keys, {
  issuer: "https://accounts.praecise.com",
  audience: clientId,
});
if (claims.nonce !== started.nonce) throw new Error("not for this sign-in");
```

Now make your own session, bound to `claims.sub`:

```js
sessions.set(id, { sub: claims.sub, email: claims.email, emailVerified: claims.email_verified === true });
```

`sub` never changes; the address can. If you already hold a record for that
address from before, link it to this `sub` only when `email_verified` is
true; otherwise tell the person to confirm their address at
`https://accounts.praecise.com/account#email` and come back.

## 3. No page of your own in between

Praecise is the one gate. Whoever is signed in there comes straight back from
the request of step 1 with a code and never sees a page; anybody else signs
in, or makes their account, there. So the product's protected address does
only this:

```js
if (url.pathname === "/app") {
  if (!session) return startSignIn(res);   // the ordinary request, at once
  return showTheProduct(res, session);
}
```

and the callback sends a refusal to the public page, which sends nobody
anywhere by itself. That, and not a sign-in page, is what prevents a loop:

```js
if (query.get("error")) return redirect(res, "/");
```

Make your own record of the person quietly the first time their `sub`
arrives. Decide what they may do (role, plan, membership); never whether they
may sign in.

## 4. Sign out through Praecise

Signing out of your product is signing out of Praecise. End your session,
then:

```js
const url = new URL("https://accounts.praecise.com/session/end");
url.search = new URLSearchParams({
  client_id: clientId,
  post_logout_redirect_uri: `${baseUrl}/`,   // exactly this, slash included
}).toString();
```

## 5. End your sessions when told

When the person signs out anywhere else, the account service posts a signed
logout token to your registered path. Verify it and end every session you
hold for that `sub`:

```js
const { payload, protectedHeader } = await jwtVerify(token, keys, { issuer, audience: clientId, maxTokenAge: "2 minutes" });
if (protectedHeader.typ !== "logout+jwt") throw new Error("not a logout token");
if (!payload.events?.["http://schemas.openid.net/event/backchannel-logout"]) throw new Error("not a logout");
if (!payload.sub || payload.nonce !== undefined) throw new Error("no subject, or a nonce");
if (seen.has(payload.jti)) throw new Error("seen before");
seen.add(payload.jti);
for (const [id, s] of sessions) if (s.sub === payload.sub) sessions.delete(id);
res.writeHead(204).end();
```

Answer `204` (or `200`) when done and `400` when the token is refused. You
receive one for a sign-out you started yourself too.

## 6. Confirm something sensitive with a passkey

Before a payment, a key being shown, a member being removed: ask the person
to confirm with their passkey, and tie the answer to the action with the
nonce.

```js
const nonce = sha256(`${action}:${random(8)}`).toString("hex");
// the request of step 1, plus:
//   acr_values=phr  max_age=0  prompt=login  nonce=<that>
```

In the callback, accept it only if it is the same person, with a passkey,
just now:

```js
const fresh = Date.now() / 1000 - claims.auth_time < 120;
if (claims.sub !== session.sub || claims.acr !== "phr" || claims.nonce !== started.nonce || !fresh) refuse();
```

If your API is a separate service, pass it the ID token and let it make the
same checks itself against `/jwks`, with `aud` equal to your client id.

## Guides

**Retiring your own sign-in.** Praecise sign-in set up on live and proven by
a real member first; your passwords, emailed codes and reset pages removed
only after, in one release, with nothing left behind. A member's existing
record is linked to their `sub` at their first Praecise sign-in, when
`email_verified` is true.

**Invitations and "Create account".** A Praecise account needs an invitation,
and Praecise is the only place one is asked for, issued or used. Your product
has no invitation code, no list of invited addresses and no request form.
"Create account" in your product is a link to
`https://accounts.praecise.com/signup?to=<your client id>`: the account is
made there and the person lands back at your product, signed in. Membership
of a team or an organisation is yours to keep; it decides what a person may
do.

**Living on two hosts, or moving to a new one.** One client can have several
hosts: the account service's setting for your client lists them, each gets
the return address and the sign-out address, and the first gets the logout
tokens. A move is: add the new host, release your product there, remove the
old host. Each host keeps its own session cookie; the Praecise session makes
the second one silent.

**Your "Your account" page.** Name, address, password, passkeys and devices,
sign-in history and "sign out everywhere" are at
`https://accounts.praecise.com/account`. Link there; keep only your own
preferences.

**Calling the shared services from your server** (domains, mailboxes): a
service token, minted by a person on their account page and sent as
`Authorization: Bearer`. See [api.md](api.md).

```
curl -s https://accounts.praecise.com/api/me -H "Authorization: Bearer $PRAECISE_TOKEN"
```

**A machine, an agent, an assistant.** See [agents.md](agents.md).

## When it does not work

| You see | Cause |
|---|---|
| A page saying the redirect address is not registered | `redirect_uri` differs from the registered one (host, path, a trailing slash) |
| `invalid_request` about `code_challenge` | PKCE missing; it is compulsory |
| `invalid_client` at `/token` | wrong secret, or the client is not switched on (its secret is not set at the account service) |
| `invalid_grant` at `/token` | the code was used already, is older than ten minutes, or the `code_verifier` or `redirect_uri` differs from step 1 |
| `login_required` on a request that should show the sign-in page | you sent `prompt=none` |
| The browser goes round between your product and the account service | your public page, or your callback on an `error`, starts the request again; only a protected address may |
| The ID token fails verification | compare `iss` exactly, `aud` with your client id, and fetch `/jwks` again if the `kid` is unknown |
| The page after sign-out is the account service's, not yours | `post_logout_redirect_uri` is not exactly your host plus `/` |
| No logout token arrives | your entry names no path for it, your endpoint is not reachable from the account service, or it did not answer 200 or 204 (the account service's log names the product) |
