AMBRYX PLAYGROUND
Sign in
Try the experience. Then bring it to your own website.
Register your Sign-in Application
Create an active CustomerWeb application bound to your website. Register your exact callback and post-logout URLs. Keep the client secret on your backend.
{
"issuer": "https://api.ambryx.io/",
"clientId": "YOUR_CUSTOMERWEB_CLIENT_ID",
"clientSecret": "SERVER_ONLY_SECRET",
"redirectUri": "https://your-website.com/auth/callback",
"postLogoutRedirectUri": "https://your-website.com/login",
"scope": "openid profile email account:access",
"responseType": "code",
"codeChallengeMethod": "S256"
}Bind the application to exactly one website UUID. The Login template is selected from that binding; do not send a template ID or site ID in the authorization request. Register APP_ORIGIN/auth/callback and APP_ORIGIN/login exactly as configured below.
OIDC_ISSUER=https://api.ambryx.io/
OIDC_CLIENT_ID=YOUR_CUSTOMERWEB_CLIENT_ID
OIDC_CLIENT_SECRET=YOUR_SERVER_ONLY_SECRET
APP_ORIGIN=https://your-website.com
REDIS_URL=redis://127.0.0.1:6379
SESSION_SECRET=REPLACE_WITH_RANDOM_SECRET
SESSION_ENCRYPTION_KEY=REPLACE_WITH_64_HEX_CHARACTERS
PORT=3000
# Generate SESSION_SECRET and SESSION_ENCRYPTION_KEY separately:
# node -e "console.log(require('node:crypto').randomBytes(32).toString('hex'))"
# Keep .env out of source control. Use a private Redis instance/ACL and TLS remotely.
# Route APP_ORIGIN through an HTTPS proxy on this server to 127.0.0.1:3000.
# The example trusts loopback proxies only; preserve the original HTTPS protocol.Start sign-in from your backend
Use an OIDC client library to discover the issuer, generate PKCE, state and nonce, and keep the transaction server-side. Your /auth/login endpoint redirects the browser to the authorization endpoint.
<a href="/auth/login">Sign in with Ambryx</a>npm init -y
npm install express@5 express-session@1 connect-redis@9 redis@5 openid-client@6
# Create .env and server.mjs from the examples below.
node --env-file=.env server.mjsCopy the complete server.mjs below to your website backend. It includes issuer discovery, /auth/login, /auth/callback, /account and /auth/logout. Run it behind an HTTPS proxy on the same server; the example trusts loopback proxies only. Use Redis 6.2 or newer for one-time transactions.
Complete backend: initialization, callback and session
// server.mjs — Node.js 24, backend only.
import express from "express";
import session from "express-session";
import { RedisStore } from "connect-redis";
import { createClient } from "redis";
import * as oidc from "openid-client";
import { randomBytes, createHash, createCipheriv, createDecipheriv } from "node:crypto";
function required(name) {
const value = process.env[name];
if (!value) throw new Error("Missing " + name);
return value;
}
const origin = new URL(required("APP_ORIGIN"));
const issuer = new URL(required("OIDC_ISSUER"));
if (origin.protocol !== "https:" || origin.href !== origin.origin + "/" ||
origin.username || origin.password || issuer.protocol !== "https:" ||
issuer.username || issuer.password)
throw new Error("Configure HTTPS APP_ORIGIN and OIDC_ISSUER.");
const secret = required("SESSION_SECRET");
const keyText = required("SESSION_ENCRYPTION_KEY");
if (secret.length < 32 || !/^[a-f0-9]{64}$/i.test(keyText))
throw new Error("Generate independent random session secrets.");
const key = Buffer.from(keyText, "hex");
function seal(value) {
const iv = randomBytes(12);
const cipher = createCipheriv("aes-256-gcm", key, iv);
const body = Buffer.concat([cipher.update(JSON.stringify(value), "utf8"), cipher.final()]);
return Buffer.concat([iv, cipher.getAuthTag(), body]).toString("base64url");
}
function open(value) {
const data = Buffer.from(value, "base64url");
const cipher = createDecipheriv("aes-256-gcm", key, data.subarray(0, 12));
cipher.setAuthTag(data.subarray(12, 28));
return JSON.parse(Buffer.concat([cipher.update(data.subarray(28)), cipher.final()]).toString("utf8"));
}
const prefix = "customerweb:" + createHash("sha256").update(origin.origin).digest("hex") + ":v1:";
const redis = createClient({ url: required("REDIS_URL") });
redis.on("error", () => console.error("Session store unavailable."));
await redis.connect();
const config = await oidc.discovery(issuer, required("OIDC_CLIENT_ID"),
{ client_secret: required("OIDC_CLIENT_SECRET") },
oidc.ClientSecretPost(required("OIDC_CLIENT_SECRET")), { timeout: 10 });
const redirectUri = origin.origin + "/auth/callback";
const postLogoutUri = origin.origin + "/login";
const logoutEndpoint = config.serverMetadata().end_session_endpoint;
const logoutOrigin = logoutEndpoint ? new URL(logoutEndpoint).origin : "";
const cookieName = "__Host-customerweb";
const lifetime = 8 * 60 * 60 * 1000;
const app = express();
app.set("trust proxy", "loopback");
app.disable("x-powered-by");
app.use((req, res, next) => {
if (!req.secure) return res.status(400).send("HTTPS required.");
res.set({ "Cache-Control": "no-store", "Referrer-Policy": "no-referrer",
"Content-Security-Policy": "default-src 'none'; form-action 'self' " + logoutOrigin +
"; frame-ancestors 'none'; base-uri 'none'" });
next();
});
app.use(session({
name: cookieName, secret, resave: false, saveUninitialized: false,
store: new RedisStore({ client: redis, prefix: prefix + "session:",
disableTouch: true, serializer: { stringify: seal, parse: open } }),
cookie: { secure: true, httpOnly: true, sameSite: "lax", path: "/", maxAge: lifetime }
}));
app.use(express.urlencoded({ extended: false, limit: "2kb" }));
const save = req => new Promise((resolve, reject) => req.session.save(error => error ? reject(error) : resolve()));
const regenerate = req => new Promise((resolve, reject) => req.session.regenerate(error => error ? reject(error) : resolve()));
const destroy = req => new Promise((resolve, reject) => req.session.destroy(error => error ? reject(error) : resolve()));
app.get("/login", (req, res) => {
res.type("html").send('<h1>Sign in</h1><a href="/auth/login">Sign in with Ambryx</a>');
});
app.get("/auth/login", async (req, res) => {
const tx = { state: oidc.randomState(), nonce: oidc.randomNonce(),
verifier: oidc.randomPKCECodeVerifier() };
// Commit the cookie/session and transaction BEFORE redirecting.
req.session.loginStarted = true;
await save(req);
await redis.set(prefix + "tx:" + req.sessionID, seal(tx), { EX: 600 });
const url = oidc.buildAuthorizationUrl(config, {
redirect_uri: redirectUri, response_type: "code",
scope: "openid profile email account:access",
state: tx.state, nonce: tx.nonce,
code_challenge: await oidc.calculatePKCECodeChallenge(tx.verifier),
code_challenge_method: "S256"
});
res.redirect(url.href);
});
app.get("/auth/callback", async (req, res) => {
// Atomic consumption prevents callback replay across backend instances.
const saved = await redis.getDel(prefix + "tx:" + req.sessionID);
if (!saved) return res.status(400).send("Sign-in expired. Start again at /login.");
const tx = open(saved);
const current = new URL(redirectUri);
current.search = new URL(req.originalUrl, origin).search;
let tokens;
try {
tokens = await oidc.authorizationCodeGrant(config, current, {
pkceCodeVerifier: tx.verifier, expectedState: tx.state,
expectedNonce: tx.nonce, idTokenExpected: true
});
} catch {
return res.status(400).send("Unable to complete sign-in. Start again at /login.");
}
const claims = tokens.claims();
if (typeof claims?.sub !== "string" || !tokens.id_token)
return res.status(400).send("Invalid sign-in response.");
await regenerate(req); // Replace the pre-login session ID.
req.session.auth = { subject: claims.sub, idToken: tokens.id_token,
expiresAt: Date.now() + lifetime };
req.session.csrf = randomBytes(32).toString("hex");
await save(req);
// Access/refresh tokens are not needed for this sign-in-only example.
res.redirect("/account");
});
app.get("/account", async (req, res) => {
if (!req.session.auth || req.session.auth.expiresAt <= Date.now()) {
await destroy(req);
res.clearCookie(cookieName, { secure: true, httpOnly: true, sameSite: "lax", path: "/" });
return res.redirect("/login");
}
res.type("html").send('<h1>Signed in</h1><form method="post" action="/auth/logout">' +
'<input type="hidden" name="csrf" value="' + req.session.csrf + '">' +
'<button type="submit">Sign out</button></form>');
});
app.post("/auth/logout", async (req, res) => {
if (req.get("origin") !== origin.origin || !req.session.csrf || req.body.csrf !== req.session.csrf)
return res.status(403).send("Invalid sign-out request.");
const idToken = req.session.auth?.idToken;
await redis.del(prefix + "tx:" + req.sessionID);
await destroy(req);
res.clearCookie(cookieName, { secure: true, httpOnly: true, sameSite: "lax", path: "/" });
if (!idToken || !config.serverMetadata().end_session_endpoint) return res.redirect("/login");
res.redirect(oidc.buildEndSessionUrl(config, {
id_token_hint: idToken, post_logout_redirect_uri: postLogoutUri
}).href);
});
app.use((error, req, res, next) => {
// Never log callback URLs, codes, tokens, passwords or raw OIDC errors.
if (res.headersSent) return next(error);
res.status(503).send("Sign-in is unavailable. Please try again.");
});
app.listen(Number(process.env.PORT ?? 3000), "127.0.0.1");Ambryx selects your site’s published Login template, falling back to the Master default. Credentials go directly to Ambryx Identity.
Complete the callback
Validate state and nonce, exchange the authorization code with its PKCE verifier, then establish your website session. Keep tokens server-side and use an opaque HttpOnly cookie.
The callback in server.mjs consumes the 10-minute transaction once, validates the ID token and rotates the session cookie. /account requires that session; its Sign out form posts with Origin and CSRF checks before clearing the session and continuing to Ambryx logout. This sign-in example does not request offline access or retain API access/refresh tokens.
Verify on your website: start at /login, sign in, reach /account, then sign out. Replaying the callback or using an expired transaction must fail. Keep secrets in server configuration and use your normal request limits.
Login uses a top-level redirect, rather than an iframe. It authenticates Ambryx accounts; passwords are not stored as form submissions. This playground never starts that real flow.
