Praecise account/ Guides

Tutorial: sign in with the Praecise account

A product that signs people in through Praecise, step by step, with working code.

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, 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.

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:

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:

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:

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:

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:

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:

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:

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.

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:

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.

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

A machine, an agent, an assistant. See agents.md.

When it does not work

You seeCause
A page saying the redirect address is not registeredredirect_uri differs from the registered one (host, path, a trailing slash)
invalid_request about code_challengePKCE missing; it is compulsory
invalid_client at /tokenwrong secret, or the client is not switched on (its secret is not set at the account service)
invalid_grant at /tokenthe 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 pageyou sent prompt=none
The browser goes round between your product and the account serviceyour public page, or your callback on an error, starts the request again; only a protected address may
The ID token fails verificationcompare 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 yourspost_logout_redirect_uri is not exactly your host plus /
No logout token arrivesyour 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)

Written and kept by Praecise account. Read from https://accounts.praecise.com/guides/tutorial.md.

This page as Markdown