ark-oauth-client

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

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

中文使用文档
文档目录24 节
  1. 目录
  2. 快速启动
  3. 它实现了什么
  4. 配置
  5. 中间件
  6. req.ark
  7. 警卫
  8. 中间件的选择
  9. 帐户切换共享浏览器
  10. 加载appsettings.json
  11. 保护API
  12. 会话
  13. 直接使用客户端
  14. 手动授权码
  15. 刷新
  16. 客户端凭证
  17. 设备格兰特
  18. 其余的
  19. 检查设置
  20. 错误
  21. 注册客户端
  22. 安全笔记
  23. 故障排除
  24. 开发

ark-oauth-client

OAuth 2.1 / OpenID连接客户端 node . js 应用程序与 ARK身份服务器.

只有一个URL需要配置——发行者, {BaseUrl}/{TenantId}. 端点、签名密钥和 功能是从提供者的发现文档中读取的,因此应用程序不必这样做 当键旋转或端点移动时重新部署。

没有运行时依赖项。一切都建立在 node:crypto 全球的 fetch.

npm install ark-oauth-client

目录


快速启动

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);

这就是整个积分。授权码+ PKCE, state, nonce, iss, at_hash, JWKS旋转,静默令牌刷新和签出在下面处理。

此流和所有其他流的可运行版本都在 examples/ - - - - - - examples/web-app.js 根本不需要依赖项。


它实现了什么

规范客户做什么
OpenID连接发现1.0读取和缓存 /.well-known/openid-configuration;验证 issuer 与配置的权限匹配
RFC 8414元数据又回到 oauth-authorization-server 其他提供者的拼写
授权码(RFC 6749)唯一的互动流程;隐式和混合在OAuth 2.1中消失了,不再提供
Pkce (rfc 7636)总是发送, S256 只有,验证者来自 crypto.randomBytes
OpenID连接核心1.0ID令牌验证:签名, iss, aud, azp, exp, nbf, iat, nonce, at_hash, c_hash, auth_time/max_age
RFC 7517 JWKS缓存,并在不可见的对象上重新获取 kid,因此两相键旋转不需要重新启动
RFC 9068访问令牌typ: at+jwt 强制执行,因此ID令牌不能在其位置上呈现给API
刷新(RFC 6749§6)旋转感知,范围缩小,每次每个会话刷新一次
客户端凭证使用每个客户端和作用域缓存
设备授权(RFC 8628)轮询处理 authorization_pending 和 slow_down
标准(rfc 9126)usePar: true,或在租户需要时自动执行
自省(RFC 7662)使用客户端身份验证
撤销(RFC 7009)签到的时候到了,换洗代币家族真的结束了
RP-Initiated Logout 1.0id_token_hint, post_logout_redirect_uri, state
RFC 9207 iss检查每个授权响应
动态注册(RFC 7591/7592)注册,读取和删除
客户端身份验证client_secret_basic, client_secret_post, private_key_jwt, none

Ark的每用户每客户端授权声明(ark_claims 在访问令牌中)显示为 req.ark.claims,是什么? requireClaims() 检查。


配置

只有 authority 和 clientId 是必需的; redirectUri 交互式登录也是如此。

选项默认的意义
authority- - - - - -发行者URL: {BaseUrl}/{TenantId}
authServerUrl + tenantId- - - - - -替代 authority;为你而加入,为你而匹配。. NET客户端的配置形状
clientId- - - - - -向租户注册的客户端id
clientSecretnull仅限保密客户。忽略SPA、本机和CLI客户端
tokenEndpointAuthMethod派生的client_secret_basic 当一个秘密被设定, private_key_jwt 当键为时,否则为 none
privateKeyJwtnull{ privateKey, kid, alg } - PEM、JWK或 KeyObject
redirectUri- - - - - -绝对的,没有碎片, https 除非回送。服务器匹配它 完全
postLogoutRedirectUri- - - - - -还必须注册
scopesopenid profile email offline_accessoffline_access 是什么获得刷新令牌
audiencenull预期 aud 在验证API中的访问令牌时
useParfalse通过反向通道推送授权参数
responseModequeryquery, fragment 或者 form_post
prompt, acrValues, extraAuthorizationParams- - - - - -传递给 /authorize
clockToleranceSeconds60空间上 exp, nbf 和 iat
requireHttpstrue只 false 促进地方发展
requireTokenHashestrue需要 at_hash/c_hash;方舟总会送他们去
timeoutMs10000每个请求超时
metadataTtlMs, jwksTtlMs300000缓存生存期,与服务器匹配 max-age=300
jwksMinRefreshIntervalMs10000多么快就看不见 kid 可能触发另一个JWKS取回
fetch全球注入您自己的代码用于跟踪、代理或测试

这里的错误是在构建时发现的,而不是在用户登录时发现的 redirectUri 用一个片段, 纯http授权, private_key_jwt 没有钥匙,a clientSecret 与 tokenEndpointAuthMethod: 'none'.


中间件

arkExpress(options) 返回一个普通的connect样式的中间件。它与Express,和 @fastify/middie, koa-connect,并与一个裸露 http.createServer -包取决于 都没有。

它占据了这些路径,并让其他所有东西通过:

路径目的
loginPath (/login)开始登录。 ?returnTo=/somewhere 返回到那个页面——只有同源路径
callbackPath (路径 redirectUri,否则 /signin-oidc)完成它。 永远不要在这里添加自己的路线
logoutPath (/logout)撤销刷新令牌,销毁会话,清除cookie,然后重定向到提供者的 end_session_endpoint
/ark/no-access, /ark/switch-user, /ark/sign-out拒绝访问的页面和它的两个post -参见 账户切换

req.ark

成员
isAuthenticated
userID令牌声明,加上UserInfo时 fetchUserInfo: true
sub
claims方舟授权声明- 授权什么
scopes授予范围
tokens这个 TokenSet
await accessToken()访问令牌,如果即将到期,首先更新
await authorize(headers?)返回带有 Authorization: Bearer … 设置,用于下游呼叫
hasClaim(…), hasScope(…)
await login({ returnTo, prompt, loginHint }), await logout({ returnTo })从你自己的路线驱动流
await switchUser({ returnUrl })放弃这个环节,用 prompt=login
await signOutEverywhere(), await signOutLocally()提供程序会话,或者仅此应用程序
deniedAccount()上次拒绝是关于谁的,为了你自己被拒绝访问的页面

读取访问令牌 accessToken() 在每次调用时,而不是保留它:它被更新 在您下面,它被更新时使用的刷新令牌将在此发生的那一刻退役。

警卫

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);

未经过身份验证的 浏览器 请求被重定向到登录页面 returnTo. 任何东西 其他- - - - - - fetchXHR,一个移动客户端 401 使用RFC 6750挑战,因为重定向 对登录页面的后台请求产生CORS错误,而不是调用者可以执行的任何操作 上。丢失索赔的登录用户将获得 403,而不是重定向循环——对于浏览器来说 带有出路的拒绝访问页面;其他的,请参考RFC 6750 insufficient_scope 的身体。

中间件的选择

选项默认的
secret- - - - - -要求, 16+字符。对会话cookie进行签名。每个实例的值相同
client- - - - - -重用现有的 ArkOAuthClient 而不是在这里配置一个
storeMemorySessionStore看到 会话
cookieNameark_session
cookie{ httpOnly: true, secure: true, sameSite: 'Lax', path: '/' }secure: false 仅适用于本地HTTP
sessionTtlSeconds28800匹配服务器的默认会话生存期
refreshLeewaySeconds120在访问令牌到期之前更新它。. NET和Python窗口
fetchUserInfofalse也叫 /userinfo 并将其合并到 req.ark.user
defaultReturnTo/
errorPathnull签到失败的地方是什么 ?auth_error=…. 否则就是普通的400
onErrornull(error, req, res, next) -完全控制而不是 errorPath
trustProxytrue荣誉 X-Forwarded-Proto + X-Forwarded-Host (两者都有,或者两者都没有)。当应用程序前面没有设置时,关闭它
accountSwitch所有从看到 下面
events{}onEvaluateAccess, onAccessDenied

responseMode: 'form_post' 是一个跨站点POST,浏览器发送时没有 SameSite=Lax 饼干;中间件拒绝从该组合开始,而不是每次登录都失败。使用 cookie: { sameSite: 'None', secure: true } 使用它,或者保持默认值 query.


帐户切换共享浏览器

单点登录是浏览器范围的。一旦一个人在任何地方登录,提供者的会话 响应该浏览器中所有其他客户端的授权请求:下一个人打开它 应用程序,以静默方式登录 作为第一人称她被告知他们无权进入,也确实没有 出路,因为登录链接返回到相同的会话并给出相同的答案。

解决方案在所有三个方舟客户端都是一样的, 默认关闭:

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 将授权检查移动到回调。提供者颁发的帐户 否 ark_claims 因为被拒绝了 之前 会话是写入的——浏览器永远不会保持会话 对于不能使用该应用程序的人—并且它的刷新令牌被撤销。 requiredClaims 将其缩小到特定的值。 集 requiredClaims 在实践中: 方舟服务器发出 整个访问映射列表为 ark_claims、身分名称(sub, email)包括在内,所以a 没有授权声明的映射仍然满足“any” ark_claims".
  • /ark/no-access 命名已登录的帐户并提供 以不同的用户登录, 这对 prompt=login 因此,提供者绘制其登录表单而不是回答 从本届会议开始。它是一个独立的页面-没有脚本,严格的CSP, ark-oauth-client <version> 在页脚。一条有守卫的路线,一个登录的浏览器在这里也没有土地所有权。
  • /ark/switch-user 和 /ark/sign-out 只接受同源 POST. 返回URL为 保持同源路径。 endProviderSessionOnSwitch: true 使切换一个完整的提供商 登记,为一个亭子。
accountSwitch 选项默认的
enabledtrue为端点提供服务,并向页面发送浏览器的403
requireArkClaimsfalse使案例可恢复的检查
requiredClaims[]至少其中一个(不区分大小写);空表示任何
accessDeniedPath, switchUserPath, signOutPath/ark/…仅限本地路径
serveDefaultPagetrue当 accessDeniedPath 是你自己的路线;读 req.ark.deniedAccount() 有
appDisplayName客户机id
showSignedInAccount, allowFullSignOuttrue
endProviderSessionOnSwitchfalse
promptlogin
homePath/用于丢失或非站点返回URL的回退
supportUrl, supportEmail- - - - - -在页面上显示为“需要访问?

页面上的帐户在五分钟内从回呼开始, HttpOnly 饼干密封 AES-256-GCM下的密钥来源于 secret - Python客户端的格式,字节对字节。


加载appsettings.json

一个Ark应用程序通常已经有一个 ark_oauth_client 部分。 ArkAuthConfig.fromMapping 读取它不变- c# (ClientId),骆驼或蛇的拼写,整个文件或只是部分:

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 (或 AuthServerUrl + TenantId), ClientId, ClientSecret, Scopes, CallbackPath, LoginPath, LogoutPath, AuthErrorPath, CookieName, RoleClaimType, RequireHttpsMetadata, ExpireMins, PostLogoutRedirectUri 和 AccountSwitch 是阅读。v1密钥(RsaPublic, Issuer, Tenants, RedirectUri)被故意忽略:重定向URI是从 CallbackPath,如。. NET处理程序。


保护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);

验证是针对缓存的JWKS进行的本地验证,因此请求不需要往返于标识 服务器。 optional: true 允许匿名请求通过 req.ark.isAuthenticated === false.


会话

令牌永远不会到达浏览器。cookie携带一个不透明的会话id和一个HMAC; 访问令牌、刷新令牌和声明留在服务器端的存储中。

MemorySessionStore 是默认值,适用于一个进程。在负载平衡器后面,或者跨 重新启动,用这些方法提供一个共享存储-任何对象:

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 });

有一件事你要知道。这个服务器 旋转刷新令牌,并提出了一个退休的 被视为全家失窃。对进程内的并发刷新进行序列化 你;而在进程之间则不是这样,因此共享存储还应该在进程周围携带一个短锁 如果您的流量可以在同一实例上对同一会话发出两个请求,则刷新 即时。


直接使用客户端

对于cli、worker、tify/Koa/Hapi或任何中间件没有覆盖的流。

import { ArkOAuthClient } from 'ark-oauth-client';

const client = new ArkOAuthClient({ authority, clientId, clientSecret, redirectUri });

手动授权码

// 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 和 codeVerifier 是整个流程的安全性。把它们放在 用户无法读取或编辑-隐藏的表单字段或普通cookie将攻击者准确地传递给攻击者 进行检查的三个值。

刷新

const next = await client.refresh(tokens.refreshToken);   // store what comes back: the old one is now dead

客户端凭证

const token = await client.clientCredentials({ scopes: ['reports.read'] });   // cached until near expiry

设备格兰特

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);

其余的

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);

检查设置

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)));

将本地配置与提供商的实时元数据配对,并返回不一致的内容: 错误的端口、停止的提供程序、颁发者不匹配、此客户端未注册的范围等 租户不提供身份验证方法,需要PAR,但未启用PAR。把它渲染成健康值 页面,或运行 node examples/setup-check.js 在CI。没有它,这些症状的第一个症状就是 invalid_request 在用户正在浏览的页面上。


错误

每个错误都带有决定下一步操作所需的内容,而不是要grep的字符串。

类意义通常的反应
ArkConfigError这个应用程序配置错误修复和重新部署;在可能的情况下扔到建筑中
ArkOAuthError服务器拒绝。 .error 是RFC 6749代码, .status HTTP状态; .endpoint URL分支 .error
ArkTokenError令牌验证失败——签名、发行者、受众、到期、 nonce, at_hash从来没有重试。答案不可信
ArkCallbackError授权响应不属于此客户端启动的请求将其视为CSRF或混淆尝试
ArkNetworkError呼叫未完成:拒绝,DNS,超时后退重试
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;
}

注册客户端

在管理控制台中 /{tenant}/admin,或从生成的设置页面 /{tenant}/oauth2/integrate/{client_id}:

场价值
重定向的URI到底是你的 redirectUri,如。 https://app.example.com/signin-oidc
注销后重定向URI你的 postLogoutRedirectUri
格兰特类型authorization_code, refresh_token
作用域openid, profile, email, offline_access
身份验证方法none 对于公共客户来说, client_secret_basic 为了保密

然后 给每个用户访问客户端的权限. Ark显式地将用户映射到客户端,而不使用 映射登录失败的方式看起来就像一个错误的密码。

服务器端Node应用程序不需要租户的任何内容 CorsOrigins -那张单子是给 浏览器客户端赎回自己的代码。


安全笔记

  • 令牌永远不会到达浏览器。 cookie是一个不透明的id加上一个签名;它是 HttpOnly, Secure 和 SameSite=Lax.
  • 在登录时替换会话id,所以在身份验证之前植入的id永远不会是正确的 最终被认证。
  • state 在常数时间内比较, iss 在每个授权响应中检查。
  • 一个失踪的 nonce 或者 at_hash 失败和犯错一样艰难。 缺席是最容易核实的 Skip是最容易攻击的。
  • alg: none 被拒绝,并且在令牌自己的标头之前确定可接受的算法 是阅读。
  • JWKS取回是速率限制的,和 kid 已经知道的缺席永远不会触发另一个 获取-否则伪造的令牌流将该客户端变成指向的请求放大器 您的身份服务器。
  • returnTo 只接受同源路径,因此登录路由不能成为打开的重定向。
  • 签出撤销刷新令牌 在本地会议结束前;换账户也是如此 还有一次拒绝登录。
  • 刷新是共享的,它的结果到达每个等待的请求。 旋转打开时,请求 持有已退出的刷新令牌将再次呈现它并撤销整个家族。
  • 体是有界的,形参是字符串。 超过64 kb的回调或帐户表单是 拒绝; state 它只被看作是自己的财产。
  • 错误文本不会到达未转义的标头。 伪造的令牌 kid 不能注入标头或 使流程崩溃 WWW-Authenticate 挑战。
  • 不能替换额外的授权参数 state, nonce, PKCE挑战或 重定向的URI。

故障排除

症状导致
redirect_uri does not match a registered value注册值的不同之处是字符方案、端口、尾斜杠。匹配是精确的,有一个环回端口的雕刻
登录失败,好像密码是错误的用户没有到该客户端的访问映射
共享浏览器上的下一个用户将以前一个用户的身份登录SSO。打开 accountSwitch.requireArkClaims;拒绝访问页面提供 prompt=login
responseMode 'form_post' needs cookie: { sameSite: 'None' … } 在启动时看到 中间件的选择
this sign-in could not be matched to a request from this browser登录过期(10分钟),或者使用内存存储重新启动进程
invalid_grant 在第二次刷新时给出了一个退役的刷新令牌;这个家庭被撤销了。总是存储刷新返回的内容
the provider … identifies itself as '…'authority 不是发行人。它是 {BaseUrl}/{TenantId},包括租户身份证
no key with kid '…' is published来自不同提供商的令牌-或多于 jwksMinRefreshIntervalMs 到现在还没有取回
unable to verify the first certificate 在开发提供商的开发证书不受Node信任。相信它,或设置 NODE_TLS_REJECT_UNAUTHORIZED=0 只在本地

开发

npm test          # 135 tests, no install required

这套房正对着 test/stub-idp.js它是Ark服务器的进程内镜像 它的电线行为:相同的路径,RS256与a kid 旋转两个键, at+jwt 访问令牌 ark_claims、带有 at_hash/c_hash, iss 关于授权 RFC 6749§5.2错误体。 它在您自己的测试中也很有用。

要求: Node 20或更新版本。ESM;在节点22.12+上 require() 这个包也可以工作。

许可: 麻省理工学院。

版本与发布记录

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

版本发布日期距今
2.1.7latest2026/11/03今天
2.1.62026/11/02今天
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 和提交记录判断。