Booking engine quickstart
A copy-paste walkthrough for adding “Sign in to Rewards” to a booking engine with the Authorization Code + PKCE flow. The snippets are plain server-side TypeScript (Node) using fetch and Web Crypto — translate them to any stack. This is the concrete version of the Loyalty SSO reference.
- We issue you a
client_idandclient_secret(shown once) and register your exactredirect_uri(s). - The issuer is
https://www.guestmaker.ai/api/oidc. Always read endpoints + the JWKS from discovery rather than hard-coding them — we rotate keys. - The token exchange and id_token verification run on your backend only. The
client_secretnever reaches the browser.
// Load discovery once at startup and cache it.
const ISSUER = "https://www.guestmaker.ai/api/oidc";
const disco = await fetch(`${ISSUER}/.well-known/openid-configuration`).then((r) => r.json());
// disco.authorization_endpoint, disco.token_endpoint, disco.jwks_uri, ...1 · Generate PKCE, state & nonce
Create a fresh code_verifier per attempt and its S256 code_challenge. Generate a random state (CSRF) and nonce (replay). Stash all three in the guest’s session keyed by state.
import { randomBytes, createHash } from "node:crypto";
const base64url = (b: Buffer) =>
b.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
const codeVerifier = base64url(randomBytes(32));
const codeChallenge = base64url(createHash("sha256").update(codeVerifier).digest());
const state = base64url(randomBytes(16));
const nonce = base64url(randomBytes(16));
// session.save({ state, nonce, codeVerifier }) // server-side, keyed by state2 · Redirect to the authorize endpoint
Request only the scopes you need. openid is required; add profile email for identity and loyalty:read transactions:read for member data.
const authUrl = new URL(disco.authorization_endpoint);
authUrl.search = new URLSearchParams({
response_type: "code",
client_id: CLIENT_ID,
redirect_uri: REDIRECT_URI, // must byte-match a registered value
scope: "openid profile email loyalty:read transactions:read",
state,
nonce,
code_challenge: codeChallenge,
code_challenge_method: "S256",
}).toString();
// res.redirect(authUrl.toString()) // send the guest to our branded login3 · Handle the callback & exchange the code
The guest returns to your redirect_uri with code, state and iss. Confirm state matches the session and iss equals our issuer, then exchange the single-use code (~60 s) with client_secret_basic.
// In your /callback handler — q = request query
if (q.state !== session.state) throw new Error("state mismatch");
if (q.iss && q.iss !== ISSUER) throw new Error("issuer mismatch");
const basic = Buffer.from(`${CLIENT_ID}:${CLIENT_SECRET}`).toString("base64");
const tokens = await fetch(disco.token_endpoint, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Authorization: `Basic ${basic}`,
},
body: new URLSearchParams({
grant_type: "authorization_code",
code: q.code,
redirect_uri: REDIRECT_URI,
code_verifier: session.codeVerifier,
}),
}).then((r) => r.json());
// tokens.access_token (RS256 JWT, ~5 min), tokens.id_token4 · Verify the id_token
Never trust an unverified token. Verify the signature against our JWKS pinned to RS256, and check iss, aud = your client_id, nonce and expiry. Use any compliant JOSE library — here, jose.
import { jwtVerify, createRemoteJWKSet } from "jose";
const jwks = createRemoteJWKSet(new URL(disco.jwks_uri)); // caches + refreshes on new kid
const { payload: claims } = await jwtVerify(tokens.id_token, jwks, {
algorithms: ["RS256"],
issuer: ISSUER,
audience: CLIENT_ID,
});
if (claims.nonce !== session.nonce) throw new Error("nonce mismatch");
// Identity + tier/points ride in the claims (per granted scope):
// claims.sub, claims.name, claims.email,
// claims.loyalty_tier_name, claims.points_balance, claims.member_number
// session.login({ memberId: claims.sub, accessToken: tokens.access_token })5 · Read live member data
The id_token is a snapshot at login. For live balance and history, call the member resource endpoints with the access_token as a Bearer token. They are member-scoped — the token already identifies the guest, so you never pass an email or member id.
const auth = { Authorization: `Bearer ${tokens.access_token}` };
const balance = await fetch(`${origin}/api/loyalty/me/balance`, { headers: auth })
.then((r) => r.json());
// { points_balance, tier, pending_balance, credit, member_number, ... }
const history = await fetch(`${origin}/api/loyalty/me/transactions?limit=20`, { headers: auth })
.then((r) => r.json());
// { transactions: [...], total, limit, offset }There is no UserInfo endpoint — profile/email/tier arrive as id_token claims, live data comes from /api/loyalty/me/*. Access tokens last ~5 minutes; re-run the flow when one expires (the guest’s session persists across the group’s properties, so it’s usually a silent redirect).
- Fresh
code_verifierper attempt; PKCES256only (neverplain). - Verify
state,nonce, and theissresponse parameter. - Verify the id_token signature vs JWKS pinned to
RS256; rejectalg: none/ HS256. redirect_uriis exact-match, HTTPS, no wildcards or trailing-slash variants.- Keep
client_secretserver-side; run the token exchange from your backend only.
Full detail, token model and revocation behaviour live in the Loyalty SSO reference.