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.0 | ID令牌验证:签名, 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.0 | id_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 |
clientSecret | null | 仅限保密客户。忽略SPA、本机和CLI客户端 |
tokenEndpointAuthMethod | 派生的 | client_secret_basic 当一个秘密被设定, private_key_jwt 当键为时,否则为 none |
privateKeyJwt | null | { privateKey, kid, alg } - PEM、JWK或 KeyObject |
redirectUri | - - - - - - | 绝对的,没有碎片, https 除非回送。服务器匹配它 完全 |
postLogoutRedirectUri | - - - - - - | 还必须注册 |
scopes | openid profile email offline_access | offline_access 是什么获得刷新令牌 |
audience | null | 预期 aud 在验证API中的访问令牌时 |
usePar | false | 通过反向通道推送授权参数 |
responseMode | query | query, fragment 或者 form_post |
prompt, acrValues, extraAuthorizationParams | - - - - - - | 传递给 /authorize |
clockToleranceSeconds | 60 | 空间上 exp, nbf 和 iat |
requireHttps | true | 只 false 促进地方发展 |
requireTokenHashes | true | 需要 at_hash/c_hash;方舟总会送他们去 |
timeoutMs | 10000 | 每个请求超时 |
metadataTtlMs, jwksTtlMs | 300000 | 缓存生存期,与服务器匹配 max-age=300 |
jwksMinRefreshIntervalMs | 10000 | 多么快就看不见 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 | |
user | ID令牌声明,加上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 而不是在这里配置一个 |
store | MemorySessionStore | 看到 会话 |
cookieName | ark_session | |
cookie | { httpOnly: true, secure: true, sameSite: 'Lax', path: '/' } | secure: false 仅适用于本地HTTP |
sessionTtlSeconds | 28800 | 匹配服务器的默认会话生存期 |
refreshLeewaySeconds | 120 | 在访问令牌到期之前更新它。. NET和Python窗口 |
fetchUserInfo | false | 也叫 /userinfo 并将其合并到 req.ark.user |
defaultReturnTo | / | |
errorPath | null | 签到失败的地方是什么 ?auth_error=…. 否则就是普通的400 |
onError | null | (error, req, res, next) -完全控制而不是 errorPath |
trustProxy | true | 荣誉 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 选项 | 默认的 | |
|---|---|---|
enabled | true | 为端点提供服务,并向页面发送浏览器的403 |
requireArkClaims | false | 使案例可恢复的检查 |
requiredClaims | [] | 至少其中一个(不区分大小写);空表示任何 |
accessDeniedPath, switchUserPath, signOutPath | /ark/… | 仅限本地路径 |
serveDefaultPage | true | 当 accessDeniedPath 是你自己的路线;读 req.ark.deniedAccount() 有 |
appDisplayName | 客户机id | |
showSignedInAccount, allowFullSignOut | true | |
endProviderSessionOnSwitch | false | |
prompt | login | |
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() 这个包也可以工作。
许可: 麻省理工学院。