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 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) |
Written and kept by Praecise account. Read from https://accounts.praecise.com/guides/tutorial.md.