@merediv/sso-sdk
v1.2.0
Published
First-party OIDC relying-party SDK for mmadwn-sso. Runtime-agnostic BFF auth (authorization-code + PKCE, RS256 id_token verification, central + back-channel logout) for Cloudflare Workers, Node and React Native. Zero runtime dependencies.
Maintainers
Readme
@merediv/sso-sdk
First-party OIDC relying-party SDK for mmadwn-sso. It extracts the
proven Backend-For-Frontend (BFF) auth pattern — authorization-code + PKCE, RS256 id_token
verification, server-side refresh/id_token storage, and RP-initiated central logout — into a
reusable, runtime-agnostic, zero-dependency package.
- Runs anywhere: Cloudflare Workers / Pages Functions, Node 18+, Deno, Bun, React Native.
Web-standard APIs only (WebCrypto +
fetch) — nonode:*, noBuffer, noprocess. - Secure by default, not configurable down: RS256-only id_token verification, S256-only PKCE,
full claim checks (
iss/aud/exp/iat/nonce),state+iss(RFC 9207) CSRF/mix-up defense, single-use transactions,__Host-HttpOnly cookies, tokens never reach the browser, denylist revocation, and CWE-601 open-redirect hardening. - Tokens stay server-side: the browser only ever holds an HttpOnly session cookie.
Confidential (BFF) client model. For pure SPA/mobile without a server, register a public client and use the low-level primitives directly; the four handlers assume a server-side secret.
Install
bun add @merediv/sso-sdk@^1.1.0 # or: npm i @merediv/sso-sdk@^1.1.0Quickstart (Cloudflare Pages Functions)
// functions/auth/[[route]].ts — one file wires all four endpoints.
import { createSsoClient, cloudflareKvStore, subAllowlist } from "@merediv/sso-sdk";
interface Env {
AUTH_KV: KVNamespace;
SSO_ISSUER: string;
SSO_CLIENT_ID: string;
SSO_CLIENT_SECRET: string;
SSO_REDIRECT_URI: string;
SESSION_SECRET: string;
ADMIN_SUBS: string;
APP_ORIGIN: string;
}
const clientFor = (env: Env) =>
createSsoClient({
issuer: env.SSO_ISSUER, // e.g. https://sso.mmadwn.com/api/auth
clientId: env.SSO_CLIENT_ID,
clientSecret: env.SSO_CLIENT_SECRET,
redirectUri: env.SSO_REDIRECT_URI,
sessionSecret: env.SESSION_SECRET,
appOrigin: env.APP_ORIGIN,
store: cloudflareKvStore(env.AUTH_KV),
deriveSession: subAllowlist(env.ADMIN_SUBS), // adds { isAdmin } live on every probe
});
export const onRequest: PagesFunction<Env> = ({ request, env }) => {
const sso = clientFor(env);
const { pathname } = new URL(request.url);
if (pathname.endsWith("/auth/login")) return sso.handleLogin(request);
if (pathname.endsWith("/auth/callback")) return sso.handleCallback(request);
if (pathname.endsWith("/auth/logout")) return sso.handleLogout(request);
if (pathname.endsWith("/auth/me")) return sso.handleMe(request);
if (pathname.endsWith("/auth/refresh")) return sso.handleRefresh(request);
if (pathname.endsWith("/auth/backchannel-logout")) return sso.handleBackchannelLogout(request);
return new Response("not found", { status: 404 });
};Guard your own API routes with getSession (it verifies the cookie + denylist and returns the
live session, with derived fields, or null):
const session = await clientFor(env).getSession(request);
if (!session) return new Response("unauthorized", { status: 401 });
if (!session.isAdmin) return new Response("forbidden", { status: 403 });Authorization: the roles claim (SSO M5)
The SSO now emits a real per-application roles claim (plus an org slug) in the id_token
and /userinfo — the authorization data an RP used to lack. Enable it per client in the SSO
dashboard (roles are defined + assigned there), then gate on roles instead of a hardcoded
sub-allowlist:
import { authorize, hasRole } from "@merediv/sso-sdk";
createSsoClient({
// …
// Persists the `roles` (+ `org`) claim onto the session and sets `isAuthorized`
// live on every probe. `mode: "any"` requires at least one; default requires all.
deriveSession: authorize({ require: "admin" }),
});
// or inspect roles directly off the verified session / id_token claims:
if (!hasRole(session, "billing")) return new Response("forbidden", { status: 403 });subAllowlist still works for apps already on it, but role-based authz is preferred: roles are
managed centrally in the SSO and take effect on the next login, with no per-RP redeploy. Only
clients that opt in receive the claim — everyone else is unaffected.
Back-channel logout (1.1.0)
When a user's IdP session ends elsewhere, an OP that supports
OIDC Back-Channel Logout 1.0 POSTs a
signed logout_token straight to your server. handleBackchannelLogout is the receiver and works
with any such OP.
mmadwn-sso sends one on every session end, admin offboarding and tenant suspension included.
// POST /auth/backchannel-logout — server-to-server: no cookies, no CSRF token, no session.
if (pathname.endsWith("/auth/backchannel-logout"))
return sso.handleBackchannelLogout(request, async (claims) => {
// optional: your own cleanup (close websockets, drop caches keyed on claims.sub, …)
});It verifies the token (below), revokes every local session minted under the token's sid
(the id_token sid is indexed at callback), drops their refresh tokens, then awaits your
callback. 200 on success; 400 {"error":"invalid_request"} for a bad, replayed or sub-only
token and 400 {"error":"logout_failed"} if your callback throws. Always Cache-Control: no-store.
- Replay: each
jtiis accepted once while the token could still verify (abcl-jti:KV record, written only once the logout succeeded, so a retry after a400 logout_failedis accepted). Cloudflare KV is eventually consistent, so this is best effort — harmless, since a replay can only repeat a logout. UsingverifyLogoutTokenon its own? Keep your ownjtiset. sub-only tokens → 400. The spec allows them, but the SDK keeps no per-subindex and the mmadwn-sso sender always includessid. Handlesubyourself withverifyLogoutTokenif you need it.- Delivery is not guaranteed. A receiver that is down misses any token the OP does not
retry (mmadwn-sso makes a single attempt). Pair it with the
session.endedwebhook for a durable signal.
Register the endpoint's absolute https URL as the client's back-channel logout URI at the OP
(on mmadwn-sso, the application's Back-channel logout URL field).
Without the handler, verifyLogoutToken(token, jwksUri, { iss, aud }) is the Back-Channel
Logout 1.0 §2.6 check on its own: RS256 signature against the issuer JWKS, typ =
logout+jwt when present, iss, aud contains your client_id, iat present and not in the
future, exp not passed, jti present, events carries
http://schemas.openid.net/event/backchannel-logout, sid and/or sub present, and no
nonce. It returns the claims or null, like verifyIdToken.
SSO client registration (prerequisites)
Register a confidential client in the SSO dashboard and provide:
redirect_uri= yourSSO_REDIRECT_URI(exact match)- a post-logout redirect URI = your
APP_ORIGIN(required for central logout to return) - scopes
openid profile email offline_access(offline_accessenables refresh; PKCE is always sent and is required wheneveroffline_accessis requested) - a token endpoint auth method matching the SDK's
tokenEndpointAuthMethod(below)
Token endpoint auth method (1.2.0)
The SDK authenticates to the token endpoint (code exchange and refresh) with
client_secret_basic by default: an Authorization: Basic header, RFC 6749 §2.3.1. The SSO
refuses any method other than the one the client is registered for (invalid_client), so the two
must match. Console-created confidential clients default to client_secret_basic, and the
application's page shows and changes it.
createSsoClient({
// ...
tokenEndpointAuthMethod: "client_secret_post", // default "client_secret_basic"
});Upgrading from 1.1.0 or earlier: those versions always sent client_secret_post, so a relying
party that already signs in is registered for client_secret_post. Set
tokenEndpointAuthMethod: "client_secret_post" explicitly, or switch the client row to
client_secret_basic in the console when you deploy the upgrade.
API
| Export | Purpose |
| --- | --- |
| createSsoClient(config) | The four handlers + handleRefresh + handleBackchannelLogout + getSession. |
| verifyLogoutToken(token, jwksUri, { iss, aud }, options?) | Back-Channel Logout 1.0 logout_token validation (1.1.0). |
| authorize({ require?, mode?, field?, flag? }) | A deriveSession role gate backed by the roles claim (M5). |
| rolesFromClaims(claims) / hasRole(claims, role) / orgFromClaims(claims) | Read the roles / org claim off a session or id_token. |
| subAllowlist(subs, field?) | A deriveSession admin gate keyed on sub (pre-M5; still supported). |
| cloudflareKvStore(kv) / memoryKvStore() | KVStore adapters. |
| discoverEndpoints / resolveEndpoints / defaultEndpoints | Endpoint resolution. |
| exchangeCode / refreshTokens / basicAuthHeader | Token endpoint calls with client authentication (1.2.0: method, default basic). |
| verifyIdToken, signSession, verifySession, createPkcePair, safeReturnTo, … | Low-level primitives for custom flows. |
License
MIT.
