ark-oauth-client

中文文档
2.1.7发布于 2026/11/03MITTypeScriptES Module零依赖构建来源已验证

The OAuth 2.1 / OpenID Connect client for Node.js applications, built for Ark.oAuth.Oidc. Authorization code + PKCE with state, nonce, iss and at_hash checks, JWKS rotation, silent refresh, client credentials, the device grant, PAR, introspection, revocat

原始使用文档
文档目录24 节
  1. Contents
  2. Quick start
  3. What it implements
  4. Configuration
  5. The middleware
  6. req.ark
  7. Guards
  8. Middleware options
  9. Account switching on a shared browser
  10. Loading appsettings.json
  11. Protecting an API
  12. Sessions
  13. Using the client directly
  14. Authorization code by hand
  15. Refresh
  16. Client credentials
  17. Device grant
  18. The rest
  19. Checking the setup
  20. Errors
  21. Registering the client
  22. Security notes
  23. Troubleshooting
  24. Development

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

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

SpecificationWhat the client does
OpenID Connect Discovery 1.0Reads and caches /.well-known/openid-configuration; verifies issuer matches the configured authority
RFC 8414 metadataFalls 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.0ID token verification: signature, iss, aud, azp, exp, nbf, iat, nonce, at_hash, c_hash, auth_time/max_age
RFC 7517 JWKSCached, and refetched on an unseen kid, so a two-phase key rotation needs no restart
RFC 9068 access tokenstyp: 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 credentialsWith 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.0id_token_hint, post_logout_redirect_uri, state
RFC 9207 issChecked on every authorization response
Dynamic registration (RFC 7591/7592)Register, read and delete
Client authenticationclient_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.

OptionDefaultMeaning
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
clientSecretnullConfidential clients only. Omit for SPA, native and CLI clients
tokenEndpointAuthMethodderivedclient_secret_basic when a secret is set, private_key_jwt when a key is, otherwise none
privateKeyJwtnull{ privateKey, kid, alg } — PEM, JWK or KeyObject
redirectUri—Absolute, no fragment, https unless loopback. The server matches it exactly
postLogoutRedirectUri—Must also be registered
scopesopenid profile email offline_accessoffline_access is what earns a refresh token
audiencenullExpected aud when verifying access tokens in an API
useParfalsePush authorization parameters over the back channel
responseModequeryquery, fragment or form_post
prompt, acrValues, extraAuthorizationParams—Passed through to /authorize
clockToleranceSeconds60Leeway on exp, nbf and iat
requireHttpstrueOnly ever false for local development
requireTokenHashestrueRequire at_hash/c_hash; Ark always sends them
timeoutMs10000Per-request timeout
metadataTtlMs, jwksTtlMs300000Cache lifetimes, matching the server's max-age=300
jwksMinRefreshIntervalMs10000How soon an unseen kid may trigger another JWKS fetch
fetchglobalInject 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:

PathPurpose
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-outThe access-denied page and its two POSTs — see account switching

req.ark

Member
isAuthenticated
userID token claims, plus UserInfo when fetchUserInfo: true
sub
claimsArk authorization claims — what to authorise on
scopesGranted scopes
tokensThe 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

OptionDefault
secret—Required, 16+ characters. Signs the session cookie. Same value on every instance
client—Reuse an existing ArkOAuthClient instead of configuring one here
storeMemorySessionStoreSee Sessions
cookieNameark_session
cookie{ httpOnly: true, secure: true, sameSite: 'Lax', path: '/' }secure: false only for local http
sessionTtlSeconds28800Matches the server's default session lifetime
refreshLeewaySeconds120Renew the access token this long before it expires — the .NET and Python window
fetchUserInfofalseAlso call /userinfo at sign-in and merge it into req.ark.user
defaultReturnTo/
errorPathnullWhere a failed sign-in lands, with ?auth_error=…. Otherwise a plain 400
onErrornull(error, req, res, next) — full control instead of errorPath
trustProxytrueHonour 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
accountSwitchall offSee 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)
  }
});
  • requireArkClaims moves the entitlement check to the callback. An account the provider issued no ark_claims for 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. requiredClaims narrows it to particular values. Set requiredClaims in practice: the Ark server issues the whole access-mapping list as ark_claims, identity names (sub, email, …) included, so a mapping with no authorization claim still satisfies "any ark_claims".
  • /ark/no-access names the account that is signed in and offers Sign in as a different user, which challenges with prompt=login so 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-user and /ark/sign-out accept only a same-origin POST. The return URL is kept to same-origin paths. endProviderSessionOnSwitch: true makes switching a full provider sign-out, for a kiosk.
accountSwitch optionDefault
enabledtrueServe the endpoints and send a browser's 403 to the page
requireArkClaimsfalseThe check that makes the case recoverable
requiredClaims[]At least one of these (case-insensitive); empty means any
accessDeniedPath, switchUserPath, signOutPath/ark/…Local paths only
serveDefaultPagetrueOff when accessDeniedPath is your own route; read req.ark.deniedAccount() there
appDisplayNameclient id
showSignedInAccount, allowFullSignOuttrue
endProviderSessionOnSwitchfalse
promptlogin
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.

ClassMeaningUsual response
ArkConfigErrorThis application is misconfiguredFix and redeploy; thrown at construction where possible
ArkOAuthErrorThe server refused. .error is the RFC 6749 code, .status the HTTP status, .endpoint the URLBranch on .error
ArkTokenErrorA token failed validation — signature, issuer, audience, expiry, nonce, at_hashNever retry. The answer cannot be trusted
ArkCallbackErrorThe authorization response does not belong to a request this client startedTreat as CSRF or a mix-up attempt
ArkNetworkErrorThe call did not complete: refused, DNS, timeoutRetry 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}:

FieldValue
Redirect URIExactly your redirectUri, e.g. https://app.example.com/signin-oidc
Post-logout redirect URIYour postLogoutRedirectUri
Grant typesauthorization_code, refresh_token
Scopesopenid, profile, email, offline_access
Auth methodnone 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, Secure and SameSite=Lax.
  • The session id is replaced at sign-in, so an id planted before authentication is never the one that ends up authenticated.
  • state is compared in constant time, and iss is checked on every authorization response.
  • A missing nonce or at_hash fails as hard as a wrong one. Absence is the easiest check to skip and the easiest one to attack.
  • alg: none is refused, and the accepted algorithms are decided before the token's own header is read.
  • JWKS refetching is rate-limited, and a kid already 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.
  • returnTo accepts 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; state is looked up as an own property only.
  • Error text never reaches a header unescaped. A forged token's kid cannot inject a header or crash the process through a WWW-Authenticate challenge.
  • Extra authorization parameters cannot replace state, nonce, the PKCE challenge or the redirect URI.

Troubleshooting

SymptomCause
redirect_uri does not match a registered valueThe 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 wrongThe user has no access mapping to this client
The next person at a shared browser is signed in as the previous oneSSO. Turn on accountSwitch.requireArkClaims; the access-denied page offers prompt=login
responseMode 'form_post' needs cookie: { sameSite: 'None' … } at startupSee Middleware options
this sign-in could not be matched to a request from this browserThe login expired (10 minutes), or the process restarted with the in-memory store
invalid_grant on the second refreshA 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 publishedA 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 developmentThe 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.

版本与发布记录

ark-oauth-client 共发布 8 个版本,首次发布于 2026/11/02;过去 12 个月发布了 8 个版本。最新版本附带 npm provenance 构建来源证明,可追溯到公开的 CI 构建记录。

版本发布日期距今
2.1.7latest2026/11/03今天
2.1.62026/11/021 天前
2.1.52026/11/021 天前
2.1.42026/11/021 天前
2.1.32026/11/021 天前
2.1.22026/11/021 天前

关于 ark-oauth-client 的常见问题

ark-oauth-client 是什么?

用于Node.js应用程序的OAuth 2.1 / OpenID Connect客户端,为Ark.oAuth.Oidc构建。授权码+ PKCE与状态,nonce, iss和at_hash检查,JWKS旋转,静默刷新,客户端凭证,设备授予,PAR,自省,撤销

如何安装 ark-oauth-client?

在项目目录运行 npm install ark-oauth-client,或使用 pnpm add / yarn add / bun add。

ark-oauth-client 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

ark-oauth-client 支持 ESM 还是 CommonJS?

以 ES Module 形式发布,推荐使用 import 引入。在 CommonJS 项目中,Node.js 20.19+ / 22.12+ 可以直接 require() 不含顶层 await 的 ES Module,更早的版本需要改用动态 import()。

ark-oauth-client 需要什么版本的 Node.js?

package.json 的 engines 字段要求 Node.js >=20.0.0。版本不符时 npm 默认只给出警告,开启 engine-strict 后会拒绝安装。

ark-oauth-client 可以商用吗?

ark-oauth-client 使用 MIT 许可。宽松许可:允许商业使用、修改和再分发,通常只需在分发时保留原版权与许可声明。以上为概括,不构成法律意见,请以许可原文为准。

ark-oauth-client 还在维护吗?

最新版本 2.1.7 发布于 2026/11/03(今天);至今共发布 8 个版本,其中过去 12 个月发布了 8 个;首次发布于 2026/11/02。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。