@adonis-agora/authkit-client 1.0.0-rc.1 → 1.0.0-rc.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +19 -0
- package/build/index.d.ts +1 -0
- package/build/index.js +1 -0
- package/build/src/tenant_token.d.ts +76 -0
- package/build/src/tenant_token.js +190 -0
- package/package.json +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,24 @@
|
|
|
1
1
|
# @adonis-agora/authkit-client
|
|
2
2
|
|
|
3
|
+
## 1.0.0-rc.3
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- fix(deps): update dependency jose to v6.2.16 ([#454](https://github.com/DavideCarvalho/adonis-agora-authkit/issues/454))
|
|
8
|
+
|
|
9
|
+
## 1.0.0-rc.2
|
|
10
|
+
|
|
11
|
+
### Minor Changes
|
|
12
|
+
|
|
13
|
+
- [#460](https://github.com/DavideCarvalho/adonis-agora-authkit/pull/460) [`1858ee8`](https://github.com/DavideCarvalho/adonis-agora-authkit/commit/1858ee837bb4edcf0578f86d224693ac9a86a495) Thanks [@DavideCarvalho](https://github.com/DavideCarvalho)! - Service tenants: a client can act as one partition of another service, such as a durable control plane.
|
|
14
|
+
|
|
15
|
+
- `serviceTenants: { resources: { 'urn:agora:durable': { expiresIn: 600 } } }` in `config/authkit.ts` declares the resources (RFC 8707) that issue service-tenant tokens: JWT access tokens (RFC 9068) with `aud` = the resource and the client's `partition` and `capabilities` as claims.
|
|
16
|
+
- A service-tenant client is confidential, uses only `client_credentials` and has no redirect URIs. Create and edit it from the console (**Service tenant** kind), the Admin API and the SDKs (`serviceTenant: { partition, capabilities? }`), or `node ace authkit:clients:create --partition=acme --capability=reports`.
|
|
17
|
+
- Any client can now be disabled and enabled again (console, `POST /clients/:id/disable|enable`, SDK `clients.disable/enable`): the token endpoint answers `invalid_client` from the next request on.
|
|
18
|
+
- `authkitTenantVerifier()` (authkit-server) verifies those tokens against the local issuer and keys and returns `{ tenant, capabilities }`; `authkitTenantToken({ issuer, clientId, clientSecret, resource })` (authkit-client) fetches, caches and refreshes them on the tenant side. Both fit durable's `verifyTenant` and `tenant.token` as a function or a class, without depending on durable.
|
|
19
|
+
- New audit events: `client.disabled`, `client.enabled`, `service_tenant.token_issued`.
|
|
20
|
+
- Access-token verification (`oidcBearerGuard`, in-process and remote) now refuses a token whose `sub` is a client (a `client_credentials` token), so a service-tenant token never authenticates a user.
|
|
21
|
+
|
|
3
22
|
## 1.0.0-rc.1
|
|
4
23
|
|
|
5
24
|
### Major Changes
|
package/build/index.d.ts
CHANGED
|
@@ -36,6 +36,7 @@ export type { PostLoginRedirects, RegisterOidcClientOptions, } from './src/regis
|
|
|
36
36
|
export { registerOidcClient } from './src/register_oidc_client.js';
|
|
37
37
|
export type { LucidRevocationStoreOptions, RevocationStore, } from './src/revocation/revocation_store.js';
|
|
38
38
|
export { DEFAULT_REVOCATION_TABLE, lucidRevocationStore, } from './src/revocation/revocation_store.js';
|
|
39
|
+
export { AuthkitTenantToken, type AuthkitTenantTokenFn, type AuthkitTenantTokenOptions, authkitTenantToken, TenantTokenError, } from './src/tenant_token.js';
|
|
39
40
|
export type { TokenSet } from './src/types.js';
|
|
40
41
|
export type { ClaimsUser, ResolveUserContext, UserinfoResolverOptions, } from './src/user_resolvers.js';
|
|
41
42
|
export { createUserinfoResolver, identityToUser, } from './src/user_resolvers.js';
|
package/build/index.js
CHANGED
|
@@ -19,5 +19,6 @@ export { default as RequireOrgMiddleware } from './src/middleware/require_org_mi
|
|
|
19
19
|
export { buildAuthorizeUrl, buildEndSessionUrl, exchangeCode, exchangeToken, generatePkce, refreshTokens, } from './src/oidc_login.js';
|
|
20
20
|
export { registerOidcClient } from './src/register_oidc_client.js';
|
|
21
21
|
export { DEFAULT_REVOCATION_TABLE, lucidRevocationStore, } from './src/revocation/revocation_store.js';
|
|
22
|
+
export { AuthkitTenantToken, authkitTenantToken, TenantTokenError, } from './src/tenant_token.js';
|
|
22
23
|
export { createUserinfoResolver, identityToUser, } from './src/user_resolvers.js';
|
|
23
24
|
export { verifyJwtAccessToken } from './src/verify_access_token.js';
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The token a service tenant presents to its control plane (e.g. durable's
|
|
3
|
+
* `tenant.token`): a `client_credentials` JWT access token for one resource (RFC 8707),
|
|
4
|
+
* fetched from the authkit issuer, cached, and refreshed before it expires.
|
|
5
|
+
*/
|
|
6
|
+
export interface AuthkitTenantTokenOptions {
|
|
7
|
+
/** Issuer URL of the authkit server (the `issuer` in its config/authkit.ts). */
|
|
8
|
+
issuer: string;
|
|
9
|
+
/** Client id of the service-tenant client. */
|
|
10
|
+
clientId: string;
|
|
11
|
+
/** Client secret of the service-tenant client. */
|
|
12
|
+
clientSecret: string;
|
|
13
|
+
/**
|
|
14
|
+
* Resource indicator the token is for (one of `serviceTenants.resources` on the
|
|
15
|
+
* server). Optional when the server declares exactly one.
|
|
16
|
+
*/
|
|
17
|
+
resource?: string;
|
|
18
|
+
/** Space-separated scopes to request. Default: none. */
|
|
19
|
+
scope?: string;
|
|
20
|
+
/** Refresh this many seconds before the token expires. Default: 60. */
|
|
21
|
+
refreshSkewSec?: number;
|
|
22
|
+
/** Token endpoint. Default: `token_endpoint` from the issuer's discovery document. */
|
|
23
|
+
tokenEndpoint?: string;
|
|
24
|
+
/** Custom `fetch` (proxy, tests). Default: the global `fetch`. */
|
|
25
|
+
fetch?: typeof fetch;
|
|
26
|
+
/** Abort a token request after this many ms. Default: 10 000. */
|
|
27
|
+
timeoutMs?: number;
|
|
28
|
+
}
|
|
29
|
+
/** Thrown when the token endpoint refuses the credentials or cannot be reached. */
|
|
30
|
+
export declare class TenantTokenError extends Error {
|
|
31
|
+
readonly status?: number | undefined;
|
|
32
|
+
readonly code?: string | undefined;
|
|
33
|
+
constructor(message: string, status?: number | undefined, code?: string | undefined, options?: {
|
|
34
|
+
cause?: unknown;
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Fetches and caches a service-tenant token. One request at a time (concurrent callers
|
|
39
|
+
* share it); after a failure, callers get the same error until a backoff (500 ms,
|
|
40
|
+
* doubling, capped at 30 s) runs out, unless the cached token is still valid, which is
|
|
41
|
+
* then served instead. Never logs or exposes the secret or the token in errors.
|
|
42
|
+
*
|
|
43
|
+
* Satisfies durable's `TenantCredentialsContract` (`token()`).
|
|
44
|
+
*/
|
|
45
|
+
export declare class AuthkitTenantToken {
|
|
46
|
+
#private;
|
|
47
|
+
constructor(options: AuthkitTenantTokenOptions);
|
|
48
|
+
/** The current token: cached while fresh, fetched (single-flight) otherwise. */
|
|
49
|
+
token(): Promise<string>;
|
|
50
|
+
/** Drops the cached token: the next `token()` fetches a new one. */
|
|
51
|
+
reset(): void;
|
|
52
|
+
}
|
|
53
|
+
/** A token provider usable both as a function and as an object with `token()`. */
|
|
54
|
+
export type AuthkitTenantTokenFn = (() => Promise<string>) & {
|
|
55
|
+
token(): Promise<string>;
|
|
56
|
+
reset(): void;
|
|
57
|
+
};
|
|
58
|
+
/**
|
|
59
|
+
* The token function for a service-tenant process:
|
|
60
|
+
*
|
|
61
|
+
* ```ts
|
|
62
|
+
* // config/durable.ts (tenant process)
|
|
63
|
+
* tenant: {
|
|
64
|
+
* token: authkitTenantToken({
|
|
65
|
+
* issuer: env.get('AUTHKIT_ISSUER'),
|
|
66
|
+
* clientId: env.get('TENANT_CLIENT_ID'),
|
|
67
|
+
* clientSecret: env.get('TENANT_CLIENT_SECRET'),
|
|
68
|
+
* resource: 'urn:agora:durable',
|
|
69
|
+
* }),
|
|
70
|
+
* }
|
|
71
|
+
* ```
|
|
72
|
+
*
|
|
73
|
+
* The result is a function (`() => Promise<string>`) that also has `token()` (durable's
|
|
74
|
+
* `TenantCredentialsContract`).
|
|
75
|
+
*/
|
|
76
|
+
export declare function authkitTenantToken(options: AuthkitTenantTokenOptions): AuthkitTenantTokenFn;
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The token a service tenant presents to its control plane (e.g. durable's
|
|
3
|
+
* `tenant.token`): a `client_credentials` JWT access token for one resource (RFC 8707),
|
|
4
|
+
* fetched from the authkit issuer, cached, and refreshed before it expires.
|
|
5
|
+
*/
|
|
6
|
+
import { discoverEndpoints } from './discovery.js';
|
|
7
|
+
/** Thrown when the token endpoint refuses the credentials or cannot be reached. */
|
|
8
|
+
export class TenantTokenError extends Error {
|
|
9
|
+
status;
|
|
10
|
+
code;
|
|
11
|
+
constructor(message, status, code, options) {
|
|
12
|
+
super(message, options);
|
|
13
|
+
this.status = status;
|
|
14
|
+
this.code = code;
|
|
15
|
+
this.name = 'TenantTokenError';
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
const MIN_BACKOFF_MS = 500;
|
|
19
|
+
const MAX_BACKOFF_MS = 30_000;
|
|
20
|
+
/**
|
|
21
|
+
* Fetches and caches a service-tenant token. One request at a time (concurrent callers
|
|
22
|
+
* share it); after a failure, callers get the same error until a backoff (500 ms,
|
|
23
|
+
* doubling, capped at 30 s) runs out, unless the cached token is still valid, which is
|
|
24
|
+
* then served instead. Never logs or exposes the secret or the token in errors.
|
|
25
|
+
*
|
|
26
|
+
* Satisfies durable's `TenantCredentialsContract` (`token()`).
|
|
27
|
+
*/
|
|
28
|
+
export class AuthkitTenantToken {
|
|
29
|
+
#options;
|
|
30
|
+
#cached;
|
|
31
|
+
#inflight;
|
|
32
|
+
#failures = 0;
|
|
33
|
+
#retryAt = 0;
|
|
34
|
+
#lastError;
|
|
35
|
+
/** Bumped by `reset()`: a fetch started before it does not touch the new state. */
|
|
36
|
+
#generation = 0;
|
|
37
|
+
constructor(options) {
|
|
38
|
+
if (!options?.issuer || !options.clientId || !options.clientSecret) {
|
|
39
|
+
throw new TypeError('authkitTenantToken: issuer, clientId and clientSecret are required');
|
|
40
|
+
}
|
|
41
|
+
this.#options = options;
|
|
42
|
+
}
|
|
43
|
+
/** The current token: cached while fresh, fetched (single-flight) otherwise. */
|
|
44
|
+
async token() {
|
|
45
|
+
const now = Date.now();
|
|
46
|
+
const cached = this.#cached;
|
|
47
|
+
if (cached && now < cached.refreshAt)
|
|
48
|
+
return cached.value;
|
|
49
|
+
let pending = this.#inflight;
|
|
50
|
+
if (!pending) {
|
|
51
|
+
if (now < this.#retryAt) {
|
|
52
|
+
if (cached && now < cached.expiresAt)
|
|
53
|
+
return cached.value;
|
|
54
|
+
throw this.#lastError;
|
|
55
|
+
}
|
|
56
|
+
const started = this.#fetch().finally(() => {
|
|
57
|
+
if (this.#inflight === started)
|
|
58
|
+
this.#inflight = undefined;
|
|
59
|
+
});
|
|
60
|
+
this.#inflight = started;
|
|
61
|
+
pending = started;
|
|
62
|
+
}
|
|
63
|
+
// Still-valid token while refreshing early: a failed refresh breaks no caller,
|
|
64
|
+
// whether it started the refresh or joined it.
|
|
65
|
+
if (cached && now < cached.expiresAt) {
|
|
66
|
+
return pending.catch(() => {
|
|
67
|
+
if (Date.now() < cached.expiresAt)
|
|
68
|
+
return cached.value;
|
|
69
|
+
throw this.#lastError;
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
return pending;
|
|
73
|
+
}
|
|
74
|
+
/** Drops the cached token: the next `token()` fetches a new one. */
|
|
75
|
+
reset() {
|
|
76
|
+
this.#generation += 1;
|
|
77
|
+
this.#cached = undefined;
|
|
78
|
+
this.#inflight = undefined;
|
|
79
|
+
this.#failures = 0;
|
|
80
|
+
this.#retryAt = 0;
|
|
81
|
+
this.#lastError = undefined;
|
|
82
|
+
}
|
|
83
|
+
async #fetch() {
|
|
84
|
+
const generation = this.#generation;
|
|
85
|
+
try {
|
|
86
|
+
const { value, cached } = await this.#request();
|
|
87
|
+
if (generation !== this.#generation)
|
|
88
|
+
return value;
|
|
89
|
+
this.#cached = cached;
|
|
90
|
+
this.#failures = 0;
|
|
91
|
+
this.#retryAt = 0;
|
|
92
|
+
this.#lastError = undefined;
|
|
93
|
+
return value;
|
|
94
|
+
}
|
|
95
|
+
catch (error) {
|
|
96
|
+
if (generation !== this.#generation)
|
|
97
|
+
throw error;
|
|
98
|
+
this.#failures += 1;
|
|
99
|
+
const delay = Math.min(MAX_BACKOFF_MS, MIN_BACKOFF_MS * 2 ** (this.#failures - 1));
|
|
100
|
+
this.#retryAt = Date.now() + delay;
|
|
101
|
+
this.#lastError = error;
|
|
102
|
+
throw error;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
async #request() {
|
|
106
|
+
const o = this.#options;
|
|
107
|
+
const doFetch = o.fetch ?? globalThis.fetch;
|
|
108
|
+
let endpoint = o.tokenEndpoint;
|
|
109
|
+
if (!endpoint) {
|
|
110
|
+
try {
|
|
111
|
+
endpoint = (await discoverEndpoints(o.issuer, {
|
|
112
|
+
onError: 'fallback',
|
|
113
|
+
...(o.fetch ? { fetchImpl: o.fetch } : {}),
|
|
114
|
+
})).tokenEndpoint;
|
|
115
|
+
}
|
|
116
|
+
catch (cause) {
|
|
117
|
+
throw new TenantTokenError('could not resolve the token endpoint', undefined, undefined, {
|
|
118
|
+
cause,
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
const body = new URLSearchParams({ grant_type: 'client_credentials' });
|
|
123
|
+
if (o.resource)
|
|
124
|
+
body.set('resource', o.resource);
|
|
125
|
+
if (o.scope)
|
|
126
|
+
body.set('scope', o.scope);
|
|
127
|
+
// RFC 6749 §2.3.1: form-urlencode id and secret before Basic.
|
|
128
|
+
const basic = Buffer.from(`${encodeURIComponent(o.clientId)}:${encodeURIComponent(o.clientSecret)}`).toString('base64');
|
|
129
|
+
const startedAt = Date.now();
|
|
130
|
+
let res;
|
|
131
|
+
try {
|
|
132
|
+
res = await doFetch(endpoint, {
|
|
133
|
+
method: 'POST',
|
|
134
|
+
headers: {
|
|
135
|
+
authorization: `Basic ${basic}`,
|
|
136
|
+
'content-type': 'application/x-www-form-urlencoded',
|
|
137
|
+
accept: 'application/json',
|
|
138
|
+
},
|
|
139
|
+
body: body.toString(),
|
|
140
|
+
signal: AbortSignal.timeout(o.timeoutMs ?? 10_000),
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
catch (cause) {
|
|
144
|
+
throw new TenantTokenError(`token request to ${endpoint} failed: ${cause?.name ?? 'Error'}`, undefined, undefined, { cause });
|
|
145
|
+
}
|
|
146
|
+
let json = {};
|
|
147
|
+
try {
|
|
148
|
+
json = (await res.json());
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
// A non-JSON body is reported by status below.
|
|
152
|
+
}
|
|
153
|
+
if (!res.ok) {
|
|
154
|
+
const code = typeof json.error === 'string' ? json.error : undefined;
|
|
155
|
+
const description = typeof json.error_description === 'string' ? `: ${json.error_description}` : '';
|
|
156
|
+
throw new TenantTokenError(`token endpoint answered ${res.status}${code ? ` ${code}` : ''}${description}`, res.status, code);
|
|
157
|
+
}
|
|
158
|
+
const value = json.access_token;
|
|
159
|
+
if (typeof value !== 'string' || !value) {
|
|
160
|
+
throw new TenantTokenError('token endpoint answered without an access_token', res.status);
|
|
161
|
+
}
|
|
162
|
+
const expiresIn = typeof json.expires_in === 'number' && json.expires_in > 0 ? json.expires_in : 60;
|
|
163
|
+
const expiresAt = startedAt + expiresIn * 1000;
|
|
164
|
+
const skew = Math.min((o.refreshSkewSec ?? 60) * 1000, (expiresIn * 1000) / 2);
|
|
165
|
+
return { value, cached: { value, expiresAt, refreshAt: expiresAt - skew } };
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* The token function for a service-tenant process:
|
|
170
|
+
*
|
|
171
|
+
* ```ts
|
|
172
|
+
* // config/durable.ts (tenant process)
|
|
173
|
+
* tenant: {
|
|
174
|
+
* token: authkitTenantToken({
|
|
175
|
+
* issuer: env.get('AUTHKIT_ISSUER'),
|
|
176
|
+
* clientId: env.get('TENANT_CLIENT_ID'),
|
|
177
|
+
* clientSecret: env.get('TENANT_CLIENT_SECRET'),
|
|
178
|
+
* resource: 'urn:agora:durable',
|
|
179
|
+
* }),
|
|
180
|
+
* }
|
|
181
|
+
* ```
|
|
182
|
+
*
|
|
183
|
+
* The result is a function (`() => Promise<string>`) that also has `token()` (durable's
|
|
184
|
+
* `TenantCredentialsContract`).
|
|
185
|
+
*/
|
|
186
|
+
export function authkitTenantToken(options) {
|
|
187
|
+
const source = new AuthkitTenantToken(options);
|
|
188
|
+
const token = () => source.token();
|
|
189
|
+
return Object.assign(token, { token, reset: () => source.reset() });
|
|
190
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adonis-agora/authkit-client",
|
|
3
|
-
"version": "1.0.0-rc.
|
|
3
|
+
"version": "1.0.0-rc.3",
|
|
4
4
|
"description": "AdonisJS OIDC/OAuth2 relying-party (client) toolkit: session-based authentication against an OpenID Connect identity provider, with JWT/PAT user resolvers and OpenTelemetry metrics.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "dudousxd",
|
|
@@ -78,10 +78,10 @@
|
|
|
78
78
|
},
|
|
79
79
|
"dependencies": {
|
|
80
80
|
"@adonis-agora/authkit-core": "1.0.0-rc.0",
|
|
81
|
-
"jose": "6.2.
|
|
81
|
+
"jose": "6.2.16"
|
|
82
82
|
},
|
|
83
83
|
"devDependencies": {
|
|
84
|
-
"@adonis-agora/authkit-server": "1.0.0-rc.
|
|
84
|
+
"@adonis-agora/authkit-server": "1.0.0-rc.4",
|
|
85
85
|
"@adonis-agora/resilience": "0.4.3",
|
|
86
86
|
"@adonisjs/auth": "10.1.0",
|
|
87
87
|
"@adonisjs/core": "7.6.0",
|