Booking engine quickstart

OIDC · framework-neutral

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.

Before you start
You integrate as a confidential Relying Party (RP).
  • We issue you a client_id and client_secret (shown once) and register your exact redirect_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_secret never 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, ...
Step 1
Generate PKCE + state + nonce
Step 2
Redirect the guest to authorize
Step 3
Exchange the code for tokens
Step 4
Verify the id_token
Step 5
Read live member data

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 state

2 · 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 login

3 · 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_token

4 · 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).

Don’t skip these
We register and enforce them — integrations that skip them break.
  • Fresh code_verifier per attempt; PKCE S256 only (never plain).
  • Verify state, nonce, and the iss response parameter.
  • Verify the id_token signature vs JWKS pinned to RS256; reject alg: none / HS256.
  • redirect_uri is exact-match, HTTPS, no wildcards or trailing-slash variants.
  • Keep client_secret server-side; run the token exchange from your backend only.

Full detail, token model and revocation behaviour live in the Loyalty SSO reference.