// Sign in with Aurora Account: a complete example in Node.js, 20 or later. // // A small web server that signs a person in as https://aurora-ws.com/developers // describes: the authorization code flow with PKCE, a private_key_jwt client assertion // for the token request, the ID token checked against Aurora Account's key set, and // userinfo. It keeps the sign-ins under way in memory: an example to read and run, not // a production server. README.md says how to run it. MIT licence: LICENSE. import { createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto"; import { readFileSync } from "node:fs"; import { createServer as createHttpServer } from "node:http"; import { createServer as createHttpsServer } from "node:https"; import { pathToFileURL } from "node:url"; import { SignJWT, createRemoteJWKSet, importPKCS8, jwtVerify } from "jose"; // Name your own service here: Cloudflare in front of Aurora Account turns away some // libraries' default User-Agent (section 10). const USER_AGENT = "AuroraSignInExample/1.0 (Node.js; +https://aurora-ws.com/developers)"; const SCOPE = "openid email profile"; const PENDING_FOR = 10 * 60 * 1000; // ms a sign-in may take, from /login to /callback const JWT_BEARER = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"; // [START assertion] // The client assertion that proves a token request is yours (section 5): signed with // your private key, valid for a minute, and accepted once. export async function makeAssertion(clientId, issuer, privateKey) { const now = Math.floor(Date.now() / 1000); const claims = { iss: clientId, // you sub: clientId, // you, again aud: issuer, // the issuer itself, as one string iat: now, exp: now + 60, // a minute is plenty jti: randomUUID(), // new every time: an assertion works once }; return new SignJWT(claims) .setProtectedHeader({ alg: "RS256", typ: "JWT" }) .sign(privateKey); } // [END assertion] // [START id-token] // The ID token's claims, once it is shown to be Aurora Account's, for you, and for this // sign-in (section 6). Throws otherwise. export async function verifyIdToken(idToken, keys, issuer, clientId, nonce) { // The signature by the key set, RS256 only; then iss, aud and exp. The tolerance // forgives a clock a little off Aurora Account's, as Aurora Account forgives yours. const { payload: claims } = await jwtVerify(idToken, keys, { algorithms: ["RS256"], issuer, audience: clientId, requiredClaims: ["exp", "sub", "nonce"], clockTolerance: 30, }); // aud must be your client_id as a string, not a list that holds it. if (typeof claims.aud !== "string") throw new Error("The ID token's aud is not a string."); // And the nonce this sign-in sent. if (!same(claims.nonce, nonce)) throw new Error("The ID token's nonce is not the one sent."); return claims; } // [END id-token] function setting(name) { if (!process.env[name]) throw new Error(`Set ${name}: README.md says how.`); return process.env[name]; } function same(a, b) { const [x, y] = [Buffer.from(String(a)), Buffer.from(String(b))]; return x.length === y.length && timingSafeEqual(x, y); } // One cookie from the Cookie header, read by hand: on localhost every dev server's // cookies arrive here, and none of them may get in the way. function cookieOf(req, name) { for (const part of (req.headers.cookie ?? "").split(";")) { const at = part.indexOf("="); if (at !== -1 && part.slice(0, at).trim() === name) return part.slice(at + 1).trim(); } return ""; } const escape = (text) => String(text).replace(/[&<>"']/g, (c) => `${c.charCodeAt(0)};`); const shown = (value) => `
${escape(JSON.stringify(value, null, 2))}`;
const page = (title, body) =>
`` +
`` +
`${escape(message)}
`; res .status(400) .clearCookie("aurora_state", cookie) .send(page("Not signed in", body)); }; app.get("/", (req, res) => { res.send( page("Example", ``), ); }); app.get("/login", (req, res) => { const now = Date.now(); for (const [state, { started }] of pending) if (now - started > PENDING_FOR) pending.delete(state); // [START authorize] // Three fresh random values for this sign-in, kept on the server. const [state, nonce, verifier] = [1, 2, 3].map(() => randomBytes(32).toString("base64url"), ); pending.set(state, { verifier, nonce, started: now }); // PKCE: the challenge is the verifier's SHA-256, base64url without padding. const codeChallenge = createHash("sha256").update(verifier).digest("base64url"); const url = new URL(meta.authorization_endpoint); url.search = new URLSearchParams({ response_type: "code", client_id: clientId, redirect_uri: redirectUri, scope: SCOPE, state, nonce, code_challenge: codeChallenge, code_challenge_method: "S256", }).toString(); // The state is tied to this browser by a cookie, checked on the way back. res .cookie("aurora_state", state, { ...cookie, maxAge: PENDING_FOR }) .redirect(url.href); // [END authorize] }); app.get("/callback", async (req, res) => { // [START callback] // The state must be the one this browser was given, and still pending. const query = new URL(req.originalUrl, "http://localhost").searchParams; const state = query.get("state") ?? ""; const started = same(cookieOf(req, "aurora_state"), state) ? pending.get(state) : undefined; if (started) pending.delete(state); // used once if (!started || Date.now() - started.started > PENDING_FOR) { return fail(res, "This sign-in did not start here, or expired."); } // iss comes back on every answer, errors too (RFC 9207). if (query.get("iss") !== issuer) return fail(res, "The answer does not name the issuer."); // access_denied: the person pressed Cancel (section 9). if (query.has("error")) return fail(res, `Aurora Account answered ${query.get("error")}.`); // [END callback] try { // [START token] // The code, the PKCE verifier and a new assertion, form-encoded. Each assertion // is accepted once, so every request makes its own. const token = await fetch(meta.token_endpoint, { method: "POST", headers, signal: AbortSignal.timeout(10_000), body: new URLSearchParams({ grant_type: "authorization_code", code: query.get("code") ?? "", redirect_uri: redirectUri, code_verifier: started.verifier, client_id: clientId, client_assertion_type: JWT_BEARER, client_assertion: await makeAssertion(clientId, issuer, privateKey), }), }); if (!token.ok) return fail(res, `The token endpoint answered ${await token.text()}`); const tokens = await token.json(); // [END token] // The ID token, checked before anything in it is believed (section 6). const claims = await verifyIdToken( tokens.id_token, keys, issuer, clientId, started.nonce, ); // [START userinfo] // The access token goes in the Authorization header, never in the URL. const info = await fetch(meta.userinfo_endpoint, { headers: { ...headers, Authorization: `Bearer ${tokens.access_token}` }, signal: AbortSignal.timeout(10_000), }); if (!info.ok) return fail(res, `Userinfo answered ${await info.text()}`); const userinfo = await info.json(); // The same person as the ID token's. if (userinfo.sub !== claims.sub) return fail(res, "Userinfo names another person."); // [END userinfo] // Here your service starts its own session, keyed on sub, never on the email // (section 1). const body = `Aurora Account says you are ${escape(claims.sub)}.