ark-oauth-client
The OAuth 2.1 / OpenID Connect client for Node.js applications talking to an ARK Identity Server.
There is one URL to configure — the issuer, {BaseUrl}/{TenantId}. Endpoints, signing keys and
capabilities are read from the provider's discovery document, so the application does not have to
be redeployed when a key rotates or an endpoint moves.
No runtime dependencies. Everything is built on node:crypto and the global fetch.
npm install ark-oauth-client
Contents
- Quick start
- What it implements
- Configuration
- The middleware
- Account switching on a shared browser
- Loading appsettings.json
- Protecting an API
- Sessions
- Using the client directly
- Checking the setup
- Errors
- Registering the client
- Security notes
- Troubleshooting
- Development
Quick start
import express from 'express';
import { arkExpress } from 'ark-oauth-client';
const app = express();
const auth = arkExpress({
authority: 'https://idp.example.com/my_idp', // {BaseUrl}/{TenantId}
clientId: 'my-app',
clientSecret: process.env.ARK_CLIENT_SECRET, // omit for public clients
redirectUri: 'https://app.example.com/signin-oidc',
postLogoutRedirectUri: 'https://app.example.com/',
secret: process.env.ARK_SESSION_SECRET // signs the session cookie
});
app.use(auth); // serves /login, /signin-oidc and /logout
app.get('/', (req, res) => {
res.send(req.ark.isAuthenticated ? `Hello ${req.ark.user.name}` : '<a href="/login">Sign in</a>');
});
app.get('/billing', auth.requireClaims('billing.admin'), (req, res) => res.send('billing'));
app.listen(3000);
That is the whole integration. Authorization code + PKCE, state, nonce, iss, at_hash,
JWKS rotation, silent token refresh and sign-out are handled underneath.
Runnable versions of this and every other flow are in examples/ — examples/web-app.js
needs no dependencies at all.
What it implements
| Specification | What the client does |
|---|---|
| OpenID Connect Discovery 1.0 | Reads and caches /.well-known/openid-configuration; verifies issuer matches the configured authority |
| RFC 8414 metadata | Falls back to the oauth-authorization-server spelling for other providers |
| Authorization code (RFC 6749) | The only interactive flow; implicit and hybrid are gone in OAuth 2.1 and are not offered |
| PKCE (RFC 7636) | Always sent, S256 only, verifier from crypto.randomBytes |
| OpenID Connect Core 1.0 | ID token verification: signature, iss, aud, azp, exp, nbf, iat, nonce, at_hash, c_hash, auth_time/max_age |
| RFC 7517 JWKS | Cached, and refetched on an unseen kid, so a two-phase key rotation needs no restart |
| RFC 9068 access tokens | typ: at+jwt enforced, so an ID token cannot be presented to an API in its place |
| Refresh (RFC 6749 §6) | Rotation-aware, scope narrowing, one refresh per session at a time |
| Client credentials | With per-client-and-scope caching |
| Device grant (RFC 8628) | Polling that handles authorization_pending and slow_down |
| PAR (RFC 9126) | usePar: true, or automatically when the tenant requires it |
| Introspection (RFC 7662) | With client authentication |
| Revocation (RFC 7009) | Called on sign-out, so the refresh-token family really ends |
| RP-Initiated Logout 1.0 | id_token_hint, post_logout_redirect_uri, state |
RFC 9207 iss | Checked on every authorization response |
| Dynamic registration (RFC 7591/7592) | Register, read and delete |
| Client authentication | client_secret_basic, client_secret_post, private_key_jwt, none |
Ark's per-user-per-client authorization claims (ark_claims in the access token) are surfaced as
req.ark.claims, and are what requireClaims() checks.
Configuration
Only authority and clientId are required; redirectUri too for interactive sign-in.
| Option | Default | Meaning |
|---|---|---|
authority | — | The issuer URL: {BaseUrl}/{TenantId} |
authServerUrl + tenantId | — | Alternative to authority; joined for you, matching the .NET client's config shape |
clientId | — | The client id registered with the tenant |
clientSecret | null | Confidential clients only. Omit for SPA, native and CLI clients |
tokenEndpointAuthMethod | derived | client_secret_basic when a secret is set, private_key_jwt when a key is, otherwise none |
privateKeyJwt | null | { privateKey, kid, alg } — PEM, JWK or KeyObject |
redirectUri | — | Absolute, no fragment, https unless loopback. The server matches it exactly |
postLogoutRedirectUri | — | Must also be registered |
scopes | openid profile email offline_access | offline_access is what earns a refresh token |
audience | null | Expected aud when verifying access tokens in an API |
usePar | false | Push authorization parameters over the back channel |
responseMode | query | query, fragment or form_post |
prompt, acrValues, extraAuthorizationParams | — | Passed through to /authorize |
clockToleranceSeconds | 60 | Leeway on exp, nbf and iat |
requireHttps | true | Only ever false for local development |
requireTokenHashes | true | Require at_hash/c_hash; Ark always sends them |
timeoutMs | 10000 | Per-request timeout |
metadataTtlMs, jwksTtlMs | 300000 | Cache lifetimes, matching the server's max-age=300 |
jwksMinRefreshIntervalMs | 10000 | How soon an unseen kid may trigger another JWKS fetch |
fetch | global | Inject your own for tracing, proxies or tests |
Mistakes here are caught at construction, not at a user's sign-in: a redirectUri with a fragment,
a plain-http authority, private_key_jwt with no key, a clientSecret alongside
tokenEndpointAuthMethod: 'none'.
The middleware
arkExpress(options) returns a plain Connect-style middleware. It works with Express, with
@fastify/middie, with koa-connect, and with a bare http.createServer — the package depends on
none of them.
It claims these paths and passes everything else through:
| Path | Purpose |
|---|---|
loginPath (/login) | Starts a sign-in. ?returnTo=/somewhere comes back to that page — same-origin paths only |
callbackPath (path of redirectUri, else /signin-oidc) | Completes it. Never add a route of your own here |
logoutPath (/logout) | Revokes the refresh token, destroys the session, clears the cookie, then redirects to the provider's end_session_endpoint |
/ark/no-access, /ark/switch-user, /ark/sign-out | The access-denied page and its two POSTs — see account switching |
req.ark
| Member | |
|---|---|
isAuthenticated | |
user | ID token claims, plus UserInfo when fetchUserInfo: true |
sub | |
claims | Ark authorization claims — what to authorise on |
scopes | Granted scopes |
tokens | The TokenSet |
await accessToken() | The access token, renewed first if it is close to expiring |
await authorize(headers?) | Returns headers with Authorization: Bearer … set, for a downstream call |
hasClaim(…), hasScope(…) | |
await login({ returnTo, prompt, loginHint }), await logout({ returnTo }) | Drive the flows from your own routes |
await switchUser({ returnUrl }) | Drop this session and challenge with prompt=login |
await signOutEverywhere(), await signOutLocally() | Provider session too, or this application only |
deniedAccount() | Who the last denial was about, for an access-denied page of your own |
Read the access token through accessToken() on every call rather than keeping it: it is renewed
underneath you, and the refresh token it was renewed with is retired the moment that happens.
Guards
app.get('/account', auth.requireAuth(), handler);
app.get('/billing', auth.requireClaims('billing.admin'), handler);
app.get('/reports', auth.requireScopes('reports.read'), handler);
app.get('/admin', auth.requireAuth({ claims: ['tenant.root'], scopes: ['admin'] }), handler);
An unauthenticated browser request is redirected to the login page with a returnTo. Anything
else — fetch, XHR, a mobile client — gets 401 with an RFC 6750 challenge, because redirecting a
background request to a sign-in page produces a CORS error rather than anything the caller can act
on. A signed-in user missing a claim gets 403, never a redirect loop — for a browser, the
access-denied page with a way out; for anything else, an RFC 6750 insufficient_scope body.
Middleware options
| Option | Default | |
|---|---|---|
secret | — | Required, 16+ characters. Signs the session cookie. Same value on every instance |
client | — | Reuse an existing ArkOAuthClient instead of configuring one here |
store | MemorySessionStore | See Sessions |
cookieName | ark_session | |
cookie | { httpOnly: true, secure: true, sameSite: 'Lax', path: '/' } | secure: false only for local http |
sessionTtlSeconds | 28800 | Matches the server's default session lifetime |
refreshLeewaySeconds | 120 | Renew the access token this long before it expires — the .NET and Python window |
fetchUserInfo | false | Also call /userinfo at sign-in and merge it into req.ark.user |
defaultReturnTo | / | |
errorPath | null | Where a failed sign-in lands, with ?auth_error=…. Otherwise a plain 400 |
onError | null | (error, req, res, next) — full control instead of errorPath |
trustProxy | true | Honour X-Forwarded-Proto + X-Forwarded-Host (both, or neither) when deriving this app's own origin. Turn it off when nothing in front of the app sets them |
accountSwitch | all off | See below |
events | {} | onEvaluateAccess, onAccessDenied |
responseMode: 'form_post' is a cross-site POST, which a browser sends without a SameSite=Lax
cookie; the middleware refuses to start with that combination rather than fail every sign-in. Use
cookie: { sameSite: 'None', secure: true } with it, or stay on the default query.
Account switching on a shared browser
Single sign-on is browser-wide. Once one person has signed in anywhere, the provider's session answers the authorize request of every other client in that browser: the next person opens this application, is silently signed in as the first person, is told they have no access — and has no way out, because the sign-in link returns to the same session and gives the same answer.
The fix is the same in all three Ark clients and, as there, off by default:
const auth = arkExpress({
authority, clientId, secret,
accountSwitch: {
requireArkClaims: true, // refuse, at the callback, an account with no ark_claims here
appDisplayName: 'Billing Portal',
supportEmail: '[email protected]'
},
events: {
// Optional: your own entitlement rule, replacing the configured one.
onEvaluateAccess: ({ arkClaims, allowedByConfiguration }) => allowedByConfiguration || arkClaims.includes('tenant.root'),
// Optional: log it, or draw your own page (respond, or set denied.handled = true).
onAccessDenied: (denied, req, res) => console.warn('access denied', denied.email, denied.reason)
}
});
requireArkClaimsmoves the entitlement check to the callback. An account the provider issued noark_claimsfor is refused before the session is written — the browser never holds a session for somebody who cannot use the application — and its refresh token is revoked.requiredClaimsnarrows it to particular values. SetrequiredClaimsin practice: the Ark server issues the whole access-mapping list asark_claims, identity names (sub,email, …) included, so a mapping with no authorization claim still satisfies "anyark_claims"./ark/no-accessnames the account that is signed in and offers Sign in as a different user, which challenges withprompt=loginso the provider draws its sign-in form instead of answering from its session. It is a self-contained page — no script, a strict CSP,ark-oauth-client <version>in the footer. A guarded route a signed-in browser lacks a claim for lands here too./ark/switch-userand/ark/sign-outaccept only a same-originPOST. The return URL is kept to same-origin paths.endProviderSessionOnSwitch: truemakes switching a full provider sign-out, for a kiosk.
accountSwitch option | Default | |
|---|---|---|
enabled | true | Serve the endpoints and send a browser's 403 to the page |
requireArkClaims | false | The check that makes the case recoverable |
requiredClaims | [] | At least one of these (case-insensitive); empty means any |
accessDeniedPath, switchUserPath, signOutPath | /ark/… | Local paths only |
serveDefaultPage | true | Off when accessDeniedPath is your own route; read req.ark.deniedAccount() there |
appDisplayName | client id | |
showSignedInAccount, allowFullSignOut | true | |
endProviderSessionOnSwitch | false | |
prompt | login | |
homePath | / | Fallback for a missing or off-site return URL |
supportUrl, supportEmail | — | Shown as "Need access?" on the page |
The account named on the page travels from the callback in a five-minute, HttpOnly cookie sealed
with AES-256-GCM under a key derived from secret — the Python client's format, byte for byte.
Loading appsettings.json
An Ark application usually already has an ark_oauth_client section. ArkAuthConfig.fromMapping
reads it unchanged — C# (ClientId), camel or snake spellings, the whole file or just the section:
import { readFileSync } from 'node:fs';
import { arkExpress, ArkAuthConfig } from 'ark-oauth-client';
const settings = JSON.parse(readFileSync('appsettings.json', 'utf8'));
app.use(arkExpress({ ...ArkAuthConfig.fromMapping(settings), secret: process.env.ARK_SESSION_SECRET }));
Authority (or AuthServerUrl + TenantId), ClientId, ClientSecret, Scopes, CallbackPath,
LoginPath, LogoutPath, AuthErrorPath, CookieName, RoleClaimType, RequireHttpsMetadata,
ExpireMins, PostLogoutRedirectUri and AccountSwitch are read. The v1 keys (RsaPublic,
Issuer, Tenants, RedirectUri) are ignored on purpose: the redirect URI is derived from
CallbackPath, as the .NET handler does.
Protecting an API
import { arkBearer } from 'ark-oauth-client';
const bearer = arkBearer({
authority: 'https://idp.example.com/my_idp',
clientId: 'my-api',
audience: 'my_idp_api' // omit to skip the audience check
});
app.use('/api', bearer);
app.get('/api/me', (req, res) => res.json({ sub: req.ark.sub, claims: req.ark.claims }));
app.get('/api/reports', bearer.require({ claims: ['reports.read'] }), handler);
Verification is local, against the cached JWKS, so a request costs no round trip to the identity
server. optional: true lets anonymous requests through with req.ark.isAuthenticated === false.
Sessions
Tokens never reach the browser. The cookie carries an opaque session id and an HMAC over it; the access token, refresh token and claims stay server-side in a store.
MemorySessionStore is the default and is fine for one process. Behind a load balancer, or across
a restart, supply a shared store — any object with these methods:
const redisStore = {
async get(id) { const v = await redis.get(`ark:${id}`); return v ? JSON.parse(v) : null; },
async set(id, data, ttl) { await redis.set(`ark:${id}`, JSON.stringify(data), { EX: ttl }); },
async destroy(id) { await redis.del(`ark:${id}`); },
async touch(id, ttl) { await redis.expire(`ark:${id}`, ttl); }
};
const auth = arkExpress({ /* … */, store: redisStore });
One thing to know when you do. This server rotates refresh tokens, and presenting a retired one is treated as theft of the whole family. Concurrent refreshes within a process are serialised for you; across processes they are not, so a shared store should also carry a short lock around the refresh if your traffic can put two requests for the same session on two instances at the same instant.
Using the client directly
For CLIs, workers, Fastify/Koa/Hapi, or any flow the middleware does not cover.
import { ArkOAuthClient } from 'ark-oauth-client';
const client = new ArkOAuthClient({ authority, clientId, clientSecret, redirectUri });
Authorization code by hand
// 1. starting a sign-in
const tx = await client.createAuthorizationUrl({ returnTo: '/dashboard' });
// store tx (state, nonce, codeVerifier) server-side, keyed to this browser
redirect(tx.url);
// 2. on the callback
const tokens = await client.handleCallback(req.query, tx);
tokens.accessToken; tokens.idToken; tokens.refreshToken;
tokens.claims; // validated ID token claims
tokens.arkClaims(); // Ark authorization claims
state, nonce and codeVerifier are the entire security of the flow. Keep them somewhere the
user cannot read or edit — a hidden form field or a plain cookie hands an attacker exactly the
three values the checks are made of.
Refresh
const next = await client.refresh(tokens.refreshToken); // store what comes back: the old one is now dead
Client credentials
const token = await client.clientCredentials({ scopes: ['reports.read'] }); // cached until near expiry
Device grant
const authorization = await client.deviceAuthorization({ scopes: ['openid', 'profile'] });
console.log(`Go to ${authorization.verification_uri} and enter ${authorization.user_code}`);
const tokens = await client.pollDeviceToken(authorization);
The rest
await client.userInfo(accessToken);
await client.introspect(token, { tokenTypeHint: 'refresh_token' });
await client.revoke(refreshToken);
await client.endSessionUrl({ idTokenHint: tokens.idToken, state });
await client.verifyAccessToken(token, { scopes: ['reports.read'], arkClaims: ['billing.admin'] });
await client.verifyIdToken(idToken, { nonce });
await client.pushAuthorizationRequest(params);
await client.registerClient(metadata, initialAccessToken);
Checking the setup
const report = await client.checkSetup();
if (report.problems.length) console.warn(report.problems);
// Inside the app: the redirect URI this request would really send, and who is signed in.
app.get('/setup', async (req, res) => res.json(await auth.checkSetup(req)));
Pairs local configuration with the provider's live metadata and returns what does not line up:
a wrong port, a stopped provider, an issuer mismatch, a scope this client is not registered for, an
authentication method the tenant does not offer, PAR required but not enabled. Render it on a health
page, or run node examples/setup-check.js in CI. Without it, the first symptom of any of these is
invalid_request on a page a user is looking at.
Errors
Every error carries what is needed to decide what to do next, rather than a string to grep.
| Class | Meaning | Usual response |
|---|---|---|
ArkConfigError | This application is misconfigured | Fix and redeploy; thrown at construction where possible |
ArkOAuthError | The server refused. .error is the RFC 6749 code, .status the HTTP status, .endpoint the URL | Branch on .error |
ArkTokenError | A token failed validation — signature, issuer, audience, expiry, nonce, at_hash | Never retry. The answer cannot be trusted |
ArkCallbackError | The authorization response does not belong to a request this client started | Treat as CSRF or a mix-up attempt |
ArkNetworkError | The call did not complete: refused, DNS, timeout | Retry with backoff |
import { ArkOAuthError } from 'ark-oauth-client';
try {
await client.refresh(token);
} catch (error) {
if (error instanceof ArkOAuthError && error.error === 'invalid_grant') signInAgain();
else throw error;
}
Registering the client
In the admin console at /{tenant}/admin, or from the generated setup page at
/{tenant}/oauth2/integrate/{client_id}:
| Field | Value |
|---|---|
| Redirect URI | Exactly your redirectUri, e.g. https://app.example.com/signin-oidc |
| Post-logout redirect URI | Your postLogoutRedirectUri |
| Grant types | authorization_code, refresh_token |
| Scopes | openid, profile, email, offline_access |
| Auth method | none for a public client, client_secret_basic for a confidential one |
Then give each user access to the client. Ark maps users to clients explicitly, and without a mapping sign-in fails in a way that looks exactly like a wrong password.
A server-side Node application needs nothing in the tenant's CorsOrigins — that list is for
browser clients redeeming their own codes.
Security notes
- Tokens never reach the browser. The cookie is an opaque id plus a signature; it is
HttpOnly,SecureandSameSite=Lax. - The session id is replaced at sign-in, so an id planted before authentication is never the one that ends up authenticated.
stateis compared in constant time, andissis checked on every authorization response.- A missing
nonceorat_hashfails as hard as a wrong one. Absence is the easiest check to skip and the easiest one to attack. alg: noneis refused, and the accepted algorithms are decided before the token's own header is read.- JWKS refetching is rate-limited, and a
kidalready known to be absent never triggers another fetch — otherwise a stream of forged tokens turns this client into a request amplifier pointed at your identity server. returnToaccepts same-origin paths only, so the login route cannot become an open redirect.- Sign-out revokes the refresh token before ending the local session; so do switching accounts and a refused sign-in.
- A refresh is shared, and its result reaches every waiting request. With rotation on, a request left holding the retired refresh token would present it again and revoke the whole family.
- Bodies are bounded and parameters are strings. A callback or account form over 64 KiB is
refused;
stateis looked up as an own property only. - Error text never reaches a header unescaped. A forged token's
kidcannot inject a header or crash the process through aWWW-Authenticatechallenge. - Extra authorization parameters cannot replace
state,nonce, the PKCE challenge or the redirect URI.
Troubleshooting
| Symptom | Cause |
|---|---|
redirect_uri does not match a registered value | The registered value differs by a character — scheme, port, trailing slash. Matching is exact, with one carve-out for loopback ports |
| Sign-in fails as though the password were wrong | The user has no access mapping to this client |
| The next person at a shared browser is signed in as the previous one | SSO. Turn on accountSwitch.requireArkClaims; the access-denied page offers prompt=login |
responseMode 'form_post' needs cookie: { sameSite: 'None' … } at startup | See Middleware options |
this sign-in could not be matched to a request from this browser | The login expired (10 minutes), or the process restarted with the in-memory store |
invalid_grant on the second refresh | A retired refresh token was presented; the family is revoked. Always store what a refresh returns |
the provider … identifies itself as '…' | authority is not the issuer. It is {BaseUrl}/{TenantId}, tenant id included |
no key with kid '…' is published | A token from a different provider — or a rotation more than jwksMinRefreshIntervalMs ago that has not been refetched yet |
unable to verify the first certificate in development | The provider's development certificate is not trusted by Node. Trust it, or set NODE_TLS_REJECT_UNAUTHORIZED=0 locally only |
Development
npm test # 135 tests, no install required
The suite runs against test/stub-idp.js, an in-process stand-in for the Ark server that mirrors
its wire behaviour: the same paths, RS256 with a kid and two keys across a rotation, at+jwt
access tokens carrying ark_claims, ID tokens with at_hash/c_hash, iss on the authorization
response, refresh-token rotation where a replay revokes the family, and RFC 6749 §5.2 error bodies.
It is useful in your own tests too.
Requirements: Node 20 or newer. ESM; on Node 22.12+ require() of this package works as well.
License: MIT.