Client Cookbook

Copy-and-adapt client code for signing users in with the auth platform (HAP). The examples implement one thing — a standards-compliant OpenID Connect Authorization-Code + PKCE client — because that single client works for all redirect-based modes:

You switch modes by changing one config value (the authorization endpoint) — not code. Best practice is to read it from per-tenant discovery and override only when using a broker.

Examples are illustrative (trimmed error handling) and favour a maintained OIDC/OAuth library over hand-rolled crypto. Always: PKCE, state, exact redirect_uri, and verify the ID token (iss/aud/exp/nonce). Store client_secret in your secret manager, never in code.


Configuration (all languages)

HAP_ISSUER=https://auth.wbsp-demo.com/t/<tenant>            # per-tenant issuer
HAP_DISCOVERY=$HAP_ISSUER/.well-known/openid-configuration
HAP_CLIENT_ID=<your client_id>
HAP_CLIENT_SECRET=<your client_secret>                       # confidential web app; from secret store
HAP_REDIRECT_URI=https://yourapp.example/api/auth/callback   # exact match, registered

# Mode switch — leave unset to use discovery's authorization_endpoint (Hosted Login);
# set it to a broker handoff URL to use the SSO broker. Everything else is identical.
HAP_AUTHORIZATION_ENDPOINT=                                  # e.g. https://broker.example/sso/handoff

In-cluster (same K8s cluster as HAP): use the in-cluster Service for back-channel calls (discovery, token, JWKS) but keep the public issuer for iss/aud and the browser redirect (split-horizon — see API Reference §1).


TypeScript / Node (openid-client)

import * as client from "openid-client";

const issuer = new URL(process.env.HAP_ISSUER!);
const config = await client.discovery(issuer, process.env.HAP_CLIENT_ID!, process.env.HAP_CLIENT_SECRET!);

// Mode switch: override the authorization endpoint for broker mode; otherwise use discovery's.
const authorizationEndpoint =
  process.env.HAP_AUTHORIZATION_ENDPOINT || config.serverMetadata().authorization_endpoint!;

// --- Start login: redirect the browser ---
export async function startLogin(req, res) {
  const verifier = client.randomPKCECodeVerifier();
  const challenge = await client.calculatePKCECodeChallenge(verifier);
  const state = client.randomState();
  req.session.pkce = { verifier, state };               // server-side only

  const url = new URL(authorizationEndpoint);
  url.search = new URLSearchParams({
    response_type: "code",
    client_id: process.env.HAP_CLIENT_ID!,
    redirect_uri: process.env.HAP_REDIRECT_URI!,
    scope: "openid profile email",
    code_challenge: challenge, code_challenge_method: "S256",
    state,
  }).toString();
  res.redirect(url.toString());
}

// --- Callback: verify + exchange + mint your session ---
export async function callback(req, res) {
  const { verifier, state } = req.session.pkce ?? {};
  const tokens = await client.authorizationCodeGrant(config, new URL(req.url, "https://x"), {
    pkceCodeVerifier: verifier,
    expectedState: state,
  });
  const claims = tokens.claims()!;                       // id_token already verified vs JWKS/iss/aud
  const user = await upsertUserBySub(claims.sub, claims.email as string, claims.name as string);
  req.session.userId = user.id;                          // your own app session
  res.redirect("/");
}

Python (authlib, e.g. Flask)

import os
from authlib.integrations.flask_client import OAuth

oauth = OAuth(app)
oauth.register(
    name="hap",
    client_id=os.environ["HAP_CLIENT_ID"],
    client_secret=os.environ["HAP_CLIENT_SECRET"],
    server_metadata_url=os.environ["HAP_DISCOVERY"],   # discovery → endpoints + JWKS
    client_kwargs={"scope": "openid profile email", "code_challenge_method": "S256"},
)

@app.route("/login")
def login():
    redirect_uri = os.environ["HAP_REDIRECT_URI"]
    # Mode switch: pass authorization_endpoint=<broker handoff> to use the broker; omit for Hosted Login.
    override = os.environ.get("HAP_AUTHORIZATION_ENDPOINT")
    kwargs = {"authorization_endpoint": override} if override else {}
    return oauth.hap.authorize_redirect(redirect_uri, **kwargs)   # Authlib adds PKCE + state

@app.route("/api/auth/callback")
def callback():
    token = oauth.hap.authorize_access_token()   # verifies state, exchanges code, validates id_token
    claims = token["userinfo"]                    # sub / email / name
    user = upsert_user_by_sub(claims["sub"], claims.get("email"), claims.get("name"))
    session["user_id"] = user.id
    return redirect("/")

Go (golang.org/x/oauth2 + github.com/coreos/go-oidc/v3)

provider, _ := oidc.NewProvider(ctx, os.Getenv("HAP_ISSUER"))      // discovery
verifier := provider.Verifier(&oidc.Config{ClientID: os.Getenv("HAP_CLIENT_ID")})

endpoint := provider.Endpoint()                                     // Hosted Login
if a := os.Getenv("HAP_AUTHORIZATION_ENDPOINT"); a != "" {          // mode switch: broker
    endpoint.AuthURL = a
}
conf := &oauth2.Config{
    ClientID:     os.Getenv("HAP_CLIENT_ID"),
    ClientSecret: os.Getenv("HAP_CLIENT_SECRET"),
    RedirectURL:  os.Getenv("HAP_REDIRECT_URI"),
    Endpoint:     endpoint,
    Scopes:       []string{oidc.ScopeOpenID, "profile", "email"},
}

// Start login
func startLogin(w http.ResponseWriter, r *http.Request) {
    verifierStr := oauth2.GenerateVerifier()
    state := randomState()
    saveSession(w, r, state, verifierStr)                           // server-side
    http.Redirect(w, r, conf.AuthCodeURL(state,
        oauth2.S256ChallengeOption(verifierStr)), http.StatusFound)
}

// Callback
func callback(w http.ResponseWriter, r *http.Request) {
    st, verifierStr := loadSession(r)
    if r.URL.Query().Get("state") != st { http.Error(w, "bad state", 400); return }
    tok, _ := conf.Exchange(r.Context(), r.URL.Query().Get("code"),
        oauth2.VerifierOption(verifierStr))
    idTok, _ := verifier.Verify(r.Context(), tok.Extra("id_token").(string))
    var claims struct{ Sub, Email, Name string }
    _ = idTok.Claims(&claims)
    upsertUserBySub(claims.Sub, claims.Email, claims.Name)          // mint your session
}

Raw HTTP (any language) — the contract underneath

# 1. Browser → authorization endpoint (Hosted Login endpoint, or broker handoff URL)
GET {AUTHORIZATION_ENDPOINT}?response_type=code&client_id=…&redirect_uri=…
      &scope=openid%20profile%20email&code_challenge=…&code_challenge_method=S256&state=…

# 2. Browser returns to your callback:
GET {redirect_uri}?code=…&state=…          # (or ?error=login_required&state=… for prompt=none w/o session)

# 3. Server exchanges the code (token endpoint from discovery):
POST {token_endpoint}
  grant_type=authorization_code&code=…&redirect_uri=…&code_verifier=…
  &client_id=…&client_secret=…
# → { access_token, id_token, refresh_token }

# 4. Verify id_token signature against {jwks_uri}; check iss, aud, exp, nonce; key user on `sub`.

Embedded mode (no redirect) — for completeness

If instead of redirecting you want to render your own login UI (embedded/headless mode), call the auth API directly (register/login or passwordless OTP) to obtain a login_token, then drive /authorize + /token server-side. See the Integration Guide. The token-exchange and verification halves are the same as above; only the login step differs.


Checklist for any client

  • PKCE (S256) — verifier kept server-side, never sent to the authorization endpoint.
  • state generated and verified on callback (CSRF).
  • redirect_uri identical at authorize, token, and registration (exact match).
  • ID token verified: signature (JWKS), iss = public issuer, aud = your client_id, exp, nonce.
  • User keyed on stable sub; JIT-provision on first arrival; treat email as changeable.
  • client_secret from a secret store; never committed.
  • Authorization endpoint is configuration (discovery for Hosted Login; override for broker).
  • Refresh-failure ⇒ sign out; optionally subscribe to identity-lifecycle webhooks.