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 stays signed in with us, so it’s a silent redirect: see below).
Keeping the guest signed in
A guest who has signed in once stays signed in with us for 30 days, until they log out. The session is shared by every booking engine of the hotel group, OIDC or club login, and by the member portal. Send the guest to the authorize endpoint whenever you need to know who they are: if they are signed in they come straight back with a code and never see the form. Two standard parameters and a logout cover the rest.
// Silent check on page load: never shows a page.
authUrl.searchParams.set("prompt", "none");
// → back with ?code= when signed in (exchange it as in step 3)
// → back with ?error=login_required when not
// Always show the form (switch account, shared computer):
authUrl.searchParams.set("prompt", "login");
// Ask again if the sign-in is older than you accept (seconds):
authUrl.searchParams.set("max_age", "3600");
// Sign the guest out of GuestMaker too, from your own logout:
const logout = new URL(disco.end_session_endpoint);
logout.search = new URLSearchParams({
id_token_hint: tokens.id_token,
post_logout_redirect_uri: "https://book.hotel.com/", // must be registered
state,
}).toString();
// res.redirect(logout.toString())The logout signs the guest out of GuestMaker on every device and ends their access tokens. A logout from another engine or from the portal does the same, so your next silent check answers login_required. Without your logout, your own sign-out lasts until the next time you send the guest to us. When the id_token carries auth_time (it does whenever you send max_age), it is when the guest really signed in, which may be days before this request.
- 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.