Browse Identity Trust

stable · guide · 0.1.0

Project Backend OIDC

Discovery and registration

Fetch /.well-known/openid-configuration from the environment issuer and use its authorization_endpoint, token_endpoint, userinfo_endpoint, jwks_uri, and issuer value. Do not hard-code endpoints from another environment. The Operator registers every exact HTTPS callback URI and the minimum openid profile email scopes for the Project Client.

Authorization Code with PKCE

The browser starts at the Project Backend BFF. The BFF stores random state, nonce, and PKCE verifier in a server-side login transaction that expires after 10 minutes, sending only the S256 challenge to MiniCenter:

TypeScript example
import { authorizationUrl, createPkce } from "@minicenter/sdk";

export async function startOidcLogin({
  saveLogin, request = fetch, issuer = "https://sandbox.minicenter.otus.tw",
  clientId = process.env.MINICENTER_PROJECT_CLIENT_ID ?? "YOUR_SANDBOX_PROJECT_CLIENT_ID",
  redirectUri = process.env.PROJECT_OIDC_REDIRECT_URI ?? "https://your-project.example/auth/callback",
}) {
  const { verifier, challenge } = await createPkce();
  const state = crypto.randomUUID();
  const nonce = crypto.randomUUID();
  const noRedirect = async (url, options = {}) => {
    const response = await request(url, { ...options, redirect: "error" });
    if (response.redirected) throw new Error("redirected OIDC fetch");
    return response;
  };
  const discovery = await noRedirect(new URL("/.well-known/openid-configuration", issuer)).then((response) => response.json());
  if (discovery.issuer !== issuer) throw new Error("issuer mismatch");
  for (const name of ["authorization_endpoint", "token_endpoint", "userinfo_endpoint", "jwks_uri"]) {
    const endpoint = new URL(discovery[name]);
    if (endpoint.protocol !== "https:" || endpoint.origin !== new URL(issuer).origin) throw new Error(`untrusted ${name}`);
  }
  const url = authorizationUrl({
    issuer, authorizationEndpoint: discovery.authorization_endpoint, clientId, redirectUri,
    state, nonce, challenge, scopes: ["openid", "profile", "email"],
  });
  if (!url.startsWith(discovery.authorization_endpoint) || !url.includes("code_challenge_method=S256")) throw new Error("discovery or PKCE mismatch");
  await saveLogin(state, { verifier, state, nonce, createdAt: Math.floor(Date.now() / 1000) }, { ttlSeconds: 600 });
  return url;
}

saveLogin uses a server-side, single-use store with a 10-minute TTL. Redirect to the returned URL; never return or log the stored transaction.

On callback, atomically delete and return the login transaction by state; consumeLogin returns null when absent or already consumed. Then exchange the code once using the stored verifier. This executable Node BFF example discovers endpoints, verifies the RS256 signature and claims, and creates only a server-side project session:

TypeScript example
import { createPublicKey, verify } from "node:crypto";

const json = (part) => JSON.parse(Buffer.from(part, "base64url"));

export async function completeOidcCallback({
  callbackUrl, consumeLogin, issuer, clientId, redirectUri,
  request = fetch, saveSession, now = Math.floor(Date.now() / 1000),
}) {
  const noRedirect = async (url, options = {}) => {
    const response = await request(url, { ...options, redirect: "error" });
    if (response.redirected) throw new Error("redirected OIDC fetch");
    return response;
  };
  const callback = new URL(callbackUrl);
  const callbackState = callback.searchParams.get("state");
  const login = callbackState ? await consumeLogin(callbackState) : null;
  if (!login || callbackState !== login.state
    || !Number.isInteger(login.createdAt) || now - login.createdAt > 600 || login.createdAt > now + 60) throw new Error("state mismatch or expired");
  const code = callback.searchParams.get("code");
  if (!code) throw new Error("authorization code missing");

  const discovery = await noRedirect(new URL("/.well-known/openid-configuration", issuer)).then((response) => response.json());
  if (discovery.issuer !== issuer) throw new Error("issuer mismatch");
  for (const name of ["authorization_endpoint", "token_endpoint", "userinfo_endpoint", "jwks_uri"]) {
    const endpoint = new URL(discovery[name]);
    if (endpoint.protocol !== "https:" || endpoint.origin !== new URL(issuer).origin) throw new Error(`untrusted ${name}`);
  }
  const tokenResponse = await noRedirect(discovery.token_endpoint, {
    method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" },
    body: new URLSearchParams({
      grant_type: "authorization_code", code, client_id: clientId,
      redirect_uri: redirectUri, code_verifier: login.verifier,
    }),
  });
  if (!tokenResponse.ok) throw new Error("token exchange failed");
  const tokens = await tokenResponse.json();
  const [encodedHeader, encodedClaims, encodedSignature] = tokens.id_token.split(".");
  const header = json(encodedHeader);
  const claims = json(encodedClaims);
  const jwks = await noRedirect(discovery.jwks_uri).then((response) => response.json());
  const jwk = jwks.keys.find((candidate) => candidate.kid === header.kid && candidate.alg === "RS256");
  const validSignature = jwk && verify(
    "RSA-SHA256", Buffer.from(`${encodedHeader}.${encodedClaims}`),
    createPublicKey({ key: jwk, format: "jwk" }), Buffer.from(encodedSignature, "base64url"),
  );
  const audience = Array.isArray(claims.aud) ? claims.aud : [claims.aud];
  if (!validSignature || claims.iss !== issuer || !audience.includes(clientId)
    || claims.nonce !== login.nonce || !Number.isFinite(claims.exp) || !Number.isFinite(claims.iat)
    || claims.exp <= now || claims.iat > now + 60
    || typeof claims.sub !== "string" || claims.sub === "") throw new Error("invalid ID token");

  await saveSession({ accountId: claims.sub });
  return { accountId: claims.sub };
}

Never put the code, verifier, access token, refresh token, or ID token in browser storage or application URLs.

OIDC endpoints are not duplicated in the REST OpenAPI; discovery is authoritative. Every endpoint must use HTTPS and the exact same scheme://host[:port] origin as the issuer. Server fetches use redirect: "error" and reject redirected responses. Browser authorization may return only to the exact registered Project Backend callback URI, which still requires the stored transaction.

Validate identity

Validate the ID token signature with the current JWKS, allowing key rotation by kid; require the exact discovery issuer, this Project Client as audience, a valid time window, and the stored nonce. Use the stable sub claim as the MiniCenter Account ID. Email and profile claims are attributes, not Membership or authorization. If UserInfo is used, require its sub to equal the ID token sub.

Create a Secure, HttpOnly, SameSite Project Backend session after validation. Project Membership, roles and Entitlements are loaded and enforced by the Project Backend, never inferred from scopes or email.

Session, logout and recovery

Expire the local BFF session independently of MiniCenter. For coordinated logout, POST a valid ID token hint, registered post_logout_redirect_uri, and 1–2048 byte opaque state to /oidc/logout; MiniCenter rejects requests without that OIDC proof. Verify returned state before showing completion. A local logout must still work when MiniCenter is unavailable.

If callback state is lost, token exchange is uncertain, JWKS refresh fails, or the code was already consumed, discard the login transaction and restart authorization. Do not replay a code with a new verifier. Preserve correlation IDs in server logs without tokens.

Common errors

  • invalid_request: redirect, PKCE, nonce, prompt or logout parameters are invalid; restart from the BFF after correcting configuration.
  • invalid_grant: code expired, was consumed, or verifier mismatched; restart authorization.
  • 401: token or session is absent/invalid; clear the local login transaction.
  • 403: the Platform Account cannot complete the requested hosted flow; do not create a local Membership.
  • Unknown kid: refresh JWKS once, then fail closed if the key remains absent.
  • Issuer, audience, nonce or Account ID mismatch: reject the login and investigate; never fall back to email matching.