@adonis-agora/authkit-server 1.0.0-rc.2 → 1.0.0-rc.4
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/commands/clients_create.d.ts +4 -0
- package/build/commands/clients_create.js +97 -0
- package/build/commands/commands.json +14 -0
- package/build/index.d.ts +3 -0
- package/build/index.js +1 -0
- package/build/src/audit/audit_sink.d.ts +1 -1
- package/build/src/audit/audit_sink.js +5 -0
- package/build/src/define_config.d.ts +16 -0
- package/build/src/define_config.js +2 -0
- package/build/src/host/access_token_verifier.js +7 -0
- package/build/src/host/admin_api/api_clients_controller.d.ts +37 -0
- package/build/src/host/admin_api/api_clients_controller.js +31 -1
- package/build/src/host/admin_api/dto.d.ts +3 -1
- package/build/src/host/admin_api/dto.js +2 -0
- package/build/src/host/admin_clients_service.d.ts +20 -0
- package/build/src/host/admin_clients_service.js +55 -0
- package/build/src/host/admin_console/console_clients_controller.d.ts +41 -0
- package/build/src/host/admin_console/console_clients_controller.js +34 -1
- package/build/src/host/admin_validators.d.ts +29 -0
- package/build/src/host/admin_validators.js +30 -0
- package/build/src/host/register_auth_host.js +4 -0
- package/build/src/host/ui-dist/assets/index-u0bMxkLg.js +67 -0
- package/build/src/host/ui-dist/index.html +1 -1
- package/build/src/provider/build_provider.js +37 -6
- package/build/src/service_tenants/client_metadata.d.ts +39 -0
- package/build/src/service_tenants/client_metadata.js +80 -0
- package/build/src/service_tenants/config.d.ts +44 -0
- package/build/src/service_tenants/config.js +44 -0
- package/build/src/service_tenants/provider_hooks.d.ts +52 -0
- package/build/src/service_tenants/provider_hooks.js +124 -0
- package/build/src/service_tenants/tenant_verifier.d.ts +73 -0
- package/build/src/service_tenants/tenant_verifier.js +104 -0
- package/package.json +3 -3
- package/build/src/host/ui-dist/assets/index-BqyJYRVX.js +0 -67
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
9
9
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
10
10
|
<link href="https://fonts.googleapis.com/css2?family=Sora:wght@400;500;600;700&family=JetBrains+Mono:ital,wght@0,400;0,500;0,600;1,400&display=swap" rel="stylesheet">
|
|
11
|
-
<script type="module" crossorigin src="/__AUTHKIT_BASE__/assets/index-
|
|
11
|
+
<script type="module" crossorigin src="/__AUTHKIT_BASE__/assets/index-u0bMxkLg.js"></script>
|
|
12
12
|
<link rel="stylesheet" crossorigin href="/__AUTHKIT_BASE__/assets/index-DTSmD4RU.css">
|
|
13
13
|
</head>
|
|
14
14
|
<body>
|
|
@@ -3,6 +3,8 @@ import { pickModelAdapterClass } from '../adapters/factory.js';
|
|
|
3
3
|
import { normalizeActiveOrg, readActiveOrgFromKoaCtx } from '../host/active_org_cookie.js';
|
|
4
4
|
import { consoleSsoClientMetadata } from '../host/console_sso.js';
|
|
5
5
|
import { findMcpResource, mcpAuthorizeMiddleware } from '../mcp/mcp_oauth.js';
|
|
6
|
+
import { hideDisabledClients } from '../service_tenants/client_metadata.js';
|
|
7
|
+
import { createServiceTenantHooks } from '../service_tenants/provider_hooks.js';
|
|
6
8
|
import { createDeviceSources } from './device_sources.js';
|
|
7
9
|
import { createLogoutSources } from './logout_sources.js';
|
|
8
10
|
import { registrationPolicyMiddleware } from './registration_policy.js';
|
|
@@ -79,21 +81,35 @@ export function buildProvider(config, options, sessionTtlHolder, tokenTtlHolder)
|
|
|
79
81
|
return key ? { key, rc: at.resources[key] } : null;
|
|
80
82
|
};
|
|
81
83
|
const mcp = config.mcp;
|
|
82
|
-
|
|
84
|
+
// Service tenants (`serviceTenants`): `client_credentials` clients with a partition,
|
|
85
|
+
// whose tokens are JWTs for a service-tenant resource. See `service_tenants/`.
|
|
86
|
+
const serviceTenants = createServiceTenantHooks({
|
|
87
|
+
serviceTenants: config.serviceTenants ?? { enabled: false, resources: {} },
|
|
88
|
+
AdapterClass: config.AdapterClass,
|
|
89
|
+
audit: config.audit,
|
|
90
|
+
});
|
|
91
|
+
const signAlg = config.jwks.keys[0]?.alg ?? 'RS256';
|
|
92
|
+
const resourceIndicatorFeatures = at.anyJwt || declaredResources.length > 0 || mcp.enabled || serviceTenants.enabled
|
|
83
93
|
? {
|
|
84
94
|
resourceIndicators: {
|
|
85
95
|
enabled: true,
|
|
86
|
-
defaultResource: async (
|
|
96
|
+
defaultResource: async (ctx, client, oneOf) => {
|
|
87
97
|
// Nas trocas (code/refresh/device), o provider passa `oneOf` com as
|
|
88
98
|
// resources já concedidas — devolvemos para não falhar a request.
|
|
89
99
|
if (oneOf)
|
|
90
100
|
return oneOf;
|
|
101
|
+
const serviceResource = await serviceTenants.defaultResource(ctx, client);
|
|
102
|
+
if (serviceResource)
|
|
103
|
+
return serviceResource;
|
|
91
104
|
// Authorize/sem resource explícito: no modo JWT, liga ao resource
|
|
92
105
|
// default (modo simples). Só opaco: sem resource (AT de sempre).
|
|
93
106
|
return at.anyJwt ? at.audience : undefined;
|
|
94
107
|
},
|
|
95
108
|
useGrantedResource: async () => true,
|
|
96
|
-
getResourceServerInfo: (
|
|
109
|
+
getResourceServerInfo: async (ctx, resourceIndicator, client) => {
|
|
110
|
+
const serviceInfo = await serviceTenants.resourceServerInfo(ctx, resourceIndicator, client, signAlg);
|
|
111
|
+
if (serviceInfo)
|
|
112
|
+
return serviceInfo;
|
|
97
113
|
const found = findResource(resourceIndicator);
|
|
98
114
|
const isDefault = at.anyJwt && resourceIndicator === at.audience;
|
|
99
115
|
// Servidores MCP (`mcp`): os declarados e os registrados em runtime.
|
|
@@ -122,7 +138,7 @@ export function buildProvider(config, options, sessionTtlHolder, tokenTtlHolder)
|
|
|
122
138
|
info.accessTokenTTL = ttl;
|
|
123
139
|
if (format === 'jwt') {
|
|
124
140
|
// Assina com a chave de assinatura corrente do keystore (mesma do JWKS).
|
|
125
|
-
info.jwt = { sign: { alg:
|
|
141
|
+
info.jwt = { sign: { alg: signAlg } };
|
|
126
142
|
}
|
|
127
143
|
return info;
|
|
128
144
|
},
|
|
@@ -138,13 +154,23 @@ export function buildProvider(config, options, sessionTtlHolder, tokenTtlHolder)
|
|
|
138
154
|
// O ator (`act`, RFC 8693) dos access tokens de impersonation — ver
|
|
139
155
|
// `impersonationExtraClaims` em `token_exchange.ts`. Sem isto o token trocado
|
|
140
156
|
// não se distingue de um token do próprio alvo no resource server.
|
|
141
|
-
|
|
157
|
+
// Service tenants add `partition`/`capabilities` (see `service_tenants/`).
|
|
158
|
+
extraTokenClaims: async (ctx, token) => {
|
|
159
|
+
const actor = impersonationExtraClaims(token);
|
|
160
|
+
const tenant = await serviceTenants.extraClaims(ctx, token);
|
|
161
|
+
return actor || tenant ? { ...tenant, ...actor } : undefined;
|
|
162
|
+
},
|
|
142
163
|
// Dispatcher por modelo (suportado pelo oidc-provider: `Adapter` aceita
|
|
143
164
|
// função `(name) => adapter` além de classe). Session-scoped vai pro
|
|
144
165
|
// `SessionAdapterClass`, o resto pro `AdapterClass` — mesma regra de
|
|
145
166
|
// `pickModelAdapterClass`, usada também pelos serviços que instanciam
|
|
146
167
|
// adapter na mão. Sem `session.adapter`, as duas classes são a mesma.
|
|
147
|
-
|
|
168
|
+
// A disabled client (`authkit_disabled`) is "not found" for the provider: every
|
|
169
|
+
// endpoint answers `invalid_client` on the next request.
|
|
170
|
+
adapter: ((name) => {
|
|
171
|
+
const instance = new (pickModelAdapterClass(name, config.AdapterClass, config.SessionAdapterClass))(name);
|
|
172
|
+
return name === 'Client' ? hideDisabledClients(instance) : instance;
|
|
173
|
+
}),
|
|
148
174
|
findAccount: options.findAccount,
|
|
149
175
|
// Grant REAPROVEITADO (consent já lembrado): o org capturado no primeiro
|
|
150
176
|
// consent ficaria VELHO se o usuário trocasse de org no console e voltasse a
|
|
@@ -244,6 +270,7 @@ export function buildProvider(config, options, sessionTtlHolder, tokenTtlHolder)
|
|
|
244
270
|
backchannelLogout: { enabled: true },
|
|
245
271
|
revocation: { enabled: true },
|
|
246
272
|
introspection: { enabled: true },
|
|
273
|
+
...(serviceTenants.enabled ? { clientCredentials: { enabled: true } } : {}),
|
|
247
274
|
...registrationFeatures,
|
|
248
275
|
...deviceFlowFeatures,
|
|
249
276
|
...dpopFeatures,
|
|
@@ -279,6 +306,10 @@ export function buildProvider(config, options, sessionTtlHolder, tokenTtlHolder)
|
|
|
279
306
|
? sessionTtlHolder.transientSec
|
|
280
307
|
: sessionTtlHolder.rememberSec
|
|
281
308
|
: config.ttl.session,
|
|
309
|
+
// `client_credentials` tokens: the resource's lifetime (a service-tenant resource
|
|
310
|
+
// always sets one), else the access-token TTL.
|
|
311
|
+
ClientCredentials: (_ctx, token) => token?.resourceServer?.accessTokenTTL ??
|
|
312
|
+
(tokenTtlHolder ? tokenTtlHolder.accessTokenSec : config.ttl.accessToken),
|
|
282
313
|
Interaction: 3600,
|
|
283
314
|
Grant: tokenTtlHolder
|
|
284
315
|
? () => tokenTtlHolder.refreshTokenSec
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { OidcAdapter } from '../adapters/adapter_contract.js';
|
|
2
|
+
/**
|
|
3
|
+
* authkit-owned keys in a persisted `Client` payload. oidc-provider strips unknown
|
|
4
|
+
* metadata when it builds a `Client` (and when dynamic registration persists one), so
|
|
5
|
+
* these keys can only be written by authkit itself (console, Admin API, CLI) and are
|
|
6
|
+
* read straight from the adapter.
|
|
7
|
+
*/
|
|
8
|
+
export declare const SERVICE_TENANT_KEY = "authkit_service_tenant";
|
|
9
|
+
export declare const CLIENT_DISABLED_KEY = "authkit_disabled";
|
|
10
|
+
/** The service-tenant part of a client. */
|
|
11
|
+
export interface ServiceTenantInfo {
|
|
12
|
+
/** Partition (tenant) the client acts as. Emitted as the `partition` claim. */
|
|
13
|
+
partition: string;
|
|
14
|
+
/** Granted capabilities. Emitted as the `capabilities` claim when non-empty. */
|
|
15
|
+
capabilities: string[];
|
|
16
|
+
}
|
|
17
|
+
/** Letters, digits, `.`, `_`, `:` and `-`; starts with a letter or digit; up to 128 chars. */
|
|
18
|
+
export declare const PARTITION_PATTERN: RegExp;
|
|
19
|
+
/** Like {@link PARTITION_PATTERN}, plus `*` and `/`. */
|
|
20
|
+
export declare const CAPABILITY_PATTERN: RegExp;
|
|
21
|
+
export declare const MAX_CAPABILITIES = 64;
|
|
22
|
+
/** Reads the service-tenant metadata from a raw client payload, or `null`. */
|
|
23
|
+
export declare function serviceTenantOf(payload: Record<string, unknown> | undefined | null): ServiceTenantInfo | null;
|
|
24
|
+
/** `true` when the raw client payload is disabled. */
|
|
25
|
+
export declare function isClientDisabled(payload: Record<string, unknown> | undefined | null): boolean;
|
|
26
|
+
/**
|
|
27
|
+
* Validates and normalizes service-tenant input. Returns an error message or the
|
|
28
|
+
* normalized value (trimmed, de-duplicated capabilities).
|
|
29
|
+
*/
|
|
30
|
+
export declare function normalizeServiceTenant(input: {
|
|
31
|
+
partition: string;
|
|
32
|
+
capabilities?: string[];
|
|
33
|
+
}): ServiceTenantInfo | string;
|
|
34
|
+
/**
|
|
35
|
+
* Wraps the provider's `Client` adapter so a disabled client is "not found": every
|
|
36
|
+
* endpoint answers `invalid_client` from the next request on, on every instance that
|
|
37
|
+
* shares the adapter. Writes are untouched.
|
|
38
|
+
*/
|
|
39
|
+
export declare function hideDisabledClients<T extends OidcAdapter>(adapter: T): T;
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* authkit-owned keys in a persisted `Client` payload. oidc-provider strips unknown
|
|
3
|
+
* metadata when it builds a `Client` (and when dynamic registration persists one), so
|
|
4
|
+
* these keys can only be written by authkit itself (console, Admin API, CLI) and are
|
|
5
|
+
* read straight from the adapter.
|
|
6
|
+
*/
|
|
7
|
+
export const SERVICE_TENANT_KEY = 'authkit_service_tenant';
|
|
8
|
+
export const CLIENT_DISABLED_KEY = 'authkit_disabled';
|
|
9
|
+
/** Letters, digits, `.`, `_`, `:` and `-`; starts with a letter or digit; up to 128 chars. */
|
|
10
|
+
export const PARTITION_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
|
11
|
+
/** Like {@link PARTITION_PATTERN}, plus `*` and `/`. */
|
|
12
|
+
export const CAPABILITY_PATTERN = /^[A-Za-z0-9*][A-Za-z0-9._:*/-]{0,127}$/;
|
|
13
|
+
export const MAX_CAPABILITIES = 64;
|
|
14
|
+
/** Reads the service-tenant metadata from a raw client payload, or `null`. */
|
|
15
|
+
export function serviceTenantOf(payload) {
|
|
16
|
+
const raw = payload?.[SERVICE_TENANT_KEY];
|
|
17
|
+
if (!raw || typeof raw !== 'object')
|
|
18
|
+
return null;
|
|
19
|
+
if (typeof raw.partition !== 'string' || !PARTITION_PATTERN.test(raw.partition))
|
|
20
|
+
return null;
|
|
21
|
+
const capabilities = Array.isArray(raw.capabilities)
|
|
22
|
+
? raw.capabilities.filter((c) => typeof c === 'string' && c.length > 0)
|
|
23
|
+
: [];
|
|
24
|
+
return { partition: raw.partition, capabilities };
|
|
25
|
+
}
|
|
26
|
+
/** `true` when the raw client payload is disabled. */
|
|
27
|
+
export function isClientDisabled(payload) {
|
|
28
|
+
return payload?.[CLIENT_DISABLED_KEY] === true;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Validates and normalizes service-tenant input. Returns an error message or the
|
|
32
|
+
* normalized value (trimmed, de-duplicated capabilities).
|
|
33
|
+
*/
|
|
34
|
+
export function normalizeServiceTenant(input) {
|
|
35
|
+
// Called directly too (not only behind the HTTP validators): check the types here.
|
|
36
|
+
const rawPartition = input?.partition;
|
|
37
|
+
const partition = typeof rawPartition === 'string' ? rawPartition.trim() : '';
|
|
38
|
+
if (!PARTITION_PATTERN.test(partition)) {
|
|
39
|
+
return `partition "${partition}" is invalid: use letters, digits, ".", "_", ":" or "-" (max 128 chars)`;
|
|
40
|
+
}
|
|
41
|
+
const rawCapabilities = input.capabilities ?? [];
|
|
42
|
+
if (!Array.isArray(rawCapabilities))
|
|
43
|
+
return 'capabilities must be an array of strings';
|
|
44
|
+
const capabilities = [];
|
|
45
|
+
for (const raw of rawCapabilities) {
|
|
46
|
+
if (typeof raw !== 'string')
|
|
47
|
+
return 'capabilities must be an array of strings';
|
|
48
|
+
const cap = raw.trim();
|
|
49
|
+
if (!CAPABILITY_PATTERN.test(cap)) {
|
|
50
|
+
return `capability "${cap}" is invalid: use letters, digits, ".", "_", ":", "*", "/" or "-" (max 128 chars)`;
|
|
51
|
+
}
|
|
52
|
+
if (!capabilities.includes(cap))
|
|
53
|
+
capabilities.push(cap);
|
|
54
|
+
}
|
|
55
|
+
if (capabilities.length > MAX_CAPABILITIES) {
|
|
56
|
+
return `at most ${MAX_CAPABILITIES} capabilities per client`;
|
|
57
|
+
}
|
|
58
|
+
return { partition, capabilities };
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Wraps the provider's `Client` adapter so a disabled client is "not found": every
|
|
62
|
+
* endpoint answers `invalid_client` from the next request on, on every instance that
|
|
63
|
+
* shares the adapter. Writes are untouched.
|
|
64
|
+
*/
|
|
65
|
+
export function hideDisabledClients(adapter) {
|
|
66
|
+
return new Proxy(adapter, {
|
|
67
|
+
get(target, prop, receiver) {
|
|
68
|
+
if (prop === 'find') {
|
|
69
|
+
return async (id) => {
|
|
70
|
+
const payload = await target.find(id);
|
|
71
|
+
return isClientDisabled(payload)
|
|
72
|
+
? undefined
|
|
73
|
+
: payload;
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
const value = Reflect.get(target, prop, receiver);
|
|
77
|
+
return typeof value === 'function' ? value.bind(target) : value;
|
|
78
|
+
},
|
|
79
|
+
});
|
|
80
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Service tenants: confidential `client_credentials` clients that act as a partition
|
|
3
|
+
* (`partition` claim) of a resource server (e.g. a durable control plane). Their tokens
|
|
4
|
+
* are JWT access tokens (RFC 9068) whose `aud` is the resource (RFC 8707).
|
|
5
|
+
*/
|
|
6
|
+
/** One resource that issues service-tenant tokens. */
|
|
7
|
+
export interface ServiceTenantResourceInput {
|
|
8
|
+
/** `aud` of the token. Default: the resource indicator itself. */
|
|
9
|
+
audience?: string;
|
|
10
|
+
/** Token lifetime in seconds. Default: 600 (10 minutes). */
|
|
11
|
+
expiresIn?: number;
|
|
12
|
+
/** Scopes a service tenant may request for this resource. Default: none. */
|
|
13
|
+
scopes?: string[];
|
|
14
|
+
}
|
|
15
|
+
export interface ServiceTenantsConfigInput {
|
|
16
|
+
/**
|
|
17
|
+
* Resource indicators (RFC 8707) that issue service-tenant tokens, e.g.
|
|
18
|
+
* `{ 'urn:agora:durable': {} }`. Only service-tenant clients can get tokens for them.
|
|
19
|
+
*/
|
|
20
|
+
resources: Record<string, ServiceTenantResourceInput>;
|
|
21
|
+
}
|
|
22
|
+
export interface ResolvedServiceTenantResource {
|
|
23
|
+
audience: string;
|
|
24
|
+
expiresIn: number;
|
|
25
|
+
scopes: string[];
|
|
26
|
+
}
|
|
27
|
+
export interface ResolvedServiceTenantsConfig {
|
|
28
|
+
/** `true` when at least one resource is declared. */
|
|
29
|
+
enabled: boolean;
|
|
30
|
+
resources: Record<string, ResolvedServiceTenantResource>;
|
|
31
|
+
}
|
|
32
|
+
/** Default token lifetime: 10 minutes. */
|
|
33
|
+
export declare const DEFAULT_SERVICE_TENANT_TTL = 600;
|
|
34
|
+
/**
|
|
35
|
+
* Resolves `serviceTenants`. Throws on an invalid resource indicator, a non-positive
|
|
36
|
+
* `expiresIn`, or a resource also declared in `accessTokens.resources` (a resource is
|
|
37
|
+
* either for users or for service tenants, never both).
|
|
38
|
+
*/
|
|
39
|
+
export declare function resolveServiceTenants(input: ServiceTenantsConfigInput | undefined, accessTokenResources?: Record<string, unknown>): ResolvedServiceTenantsConfig;
|
|
40
|
+
/** Finds a declared service-tenant resource, tolerating a trailing slash. */
|
|
41
|
+
export declare function findServiceTenantResource(config: ResolvedServiceTenantsConfig, indicator: string): {
|
|
42
|
+
key: string;
|
|
43
|
+
resource: ResolvedServiceTenantResource;
|
|
44
|
+
} | null;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Service tenants: confidential `client_credentials` clients that act as a partition
|
|
3
|
+
* (`partition` claim) of a resource server (e.g. a durable control plane). Their tokens
|
|
4
|
+
* are JWT access tokens (RFC 9068) whose `aud` is the resource (RFC 8707).
|
|
5
|
+
*/
|
|
6
|
+
/** Default token lifetime: 10 minutes. */
|
|
7
|
+
export const DEFAULT_SERVICE_TENANT_TTL = 600;
|
|
8
|
+
const trimSlash = (s) => s.replace(/\/+$/, '');
|
|
9
|
+
/**
|
|
10
|
+
* Resolves `serviceTenants`. Throws on an invalid resource indicator, a non-positive
|
|
11
|
+
* `expiresIn`, or a resource also declared in `accessTokens.resources` (a resource is
|
|
12
|
+
* either for users or for service tenants, never both).
|
|
13
|
+
*/
|
|
14
|
+
export function resolveServiceTenants(input, accessTokenResources = {}) {
|
|
15
|
+
const resources = {};
|
|
16
|
+
const userResources = new Set(Object.keys(accessTokenResources).map(trimSlash));
|
|
17
|
+
for (const [indicator, rc] of Object.entries(input?.resources ?? {})) {
|
|
18
|
+
const parsed = URL.parse(indicator);
|
|
19
|
+
if (!parsed || indicator.includes('#')) {
|
|
20
|
+
throw new Error(`authkit: serviceTenants.resources["${indicator}"] must be an absolute URI without a fragment (e.g. "urn:agora:durable").`);
|
|
21
|
+
}
|
|
22
|
+
if (userResources.has(trimSlash(indicator))) {
|
|
23
|
+
throw new Error(`authkit: "${indicator}" is declared in both accessTokens.resources and serviceTenants.resources. Pick one.`);
|
|
24
|
+
}
|
|
25
|
+
const expiresIn = rc?.expiresIn ?? DEFAULT_SERVICE_TENANT_TTL;
|
|
26
|
+
if (!Number.isInteger(expiresIn) || expiresIn <= 0) {
|
|
27
|
+
throw new Error(`authkit: serviceTenants.resources["${indicator}"].expiresIn must be a positive integer (seconds).`);
|
|
28
|
+
}
|
|
29
|
+
resources[indicator] = {
|
|
30
|
+
audience: rc?.audience ?? indicator,
|
|
31
|
+
expiresIn,
|
|
32
|
+
scopes: [...(rc?.scopes ?? [])],
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
return { enabled: Object.keys(resources).length > 0, resources };
|
|
36
|
+
}
|
|
37
|
+
/** Finds a declared service-tenant resource, tolerating a trailing slash. */
|
|
38
|
+
export function findServiceTenantResource(config, indicator) {
|
|
39
|
+
if (config.resources[indicator])
|
|
40
|
+
return { key: indicator, resource: config.resources[indicator] };
|
|
41
|
+
const trimmed = trimSlash(indicator);
|
|
42
|
+
const key = Object.keys(config.resources).find((k) => trimSlash(k) === trimmed);
|
|
43
|
+
return key ? { key, resource: config.resources[key] } : null;
|
|
44
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { OidcAdapterClass } from '../adapters/factory.js';
|
|
2
|
+
import type { AuditSink } from '../audit/audit_sink.js';
|
|
3
|
+
import { type ResolvedServiceTenantsConfig } from './config.js';
|
|
4
|
+
export interface ServiceTenantHooksDeps {
|
|
5
|
+
serviceTenants: ResolvedServiceTenantsConfig;
|
|
6
|
+
/** Adapter class of the `Client` model (raw: sees authkit's own keys). */
|
|
7
|
+
AdapterClass: OidcAdapterClass;
|
|
8
|
+
audit?: AuditSink;
|
|
9
|
+
}
|
|
10
|
+
/** Resource server info for a service-tenant resource, in oidc-provider's shape. */
|
|
11
|
+
export interface ServiceTenantResourceServerInfo {
|
|
12
|
+
scope: string;
|
|
13
|
+
audience: string;
|
|
14
|
+
accessTokenFormat: 'jwt';
|
|
15
|
+
accessTokenTTL: number;
|
|
16
|
+
jwt: {
|
|
17
|
+
sign: {
|
|
18
|
+
alg: string;
|
|
19
|
+
};
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* The provider hooks behind service tenants:
|
|
24
|
+
*
|
|
25
|
+
* - a service-tenant resource is granted only to a service-tenant client, only on the
|
|
26
|
+
* `client_credentials` grant, always as a JWT access token (RFC 9068);
|
|
27
|
+
* - a service-tenant client can only get tokens for a service-tenant resource (when
|
|
28
|
+
* it sends no `resource` and exactly one is declared, that one is used);
|
|
29
|
+
* - its tokens carry the `partition` (and `capabilities`) claims read from the client
|
|
30
|
+
* at issuance time, so an edit applies to the next token.
|
|
31
|
+
*/
|
|
32
|
+
export declare function createServiceTenantHooks(deps: ServiceTenantHooksDeps): {
|
|
33
|
+
enabled: boolean;
|
|
34
|
+
/**
|
|
35
|
+
* `defaultResource` for a service-tenant client: the only declared resource, or
|
|
36
|
+
* `invalid_target` when several are declared. `undefined` for any other client.
|
|
37
|
+
*/
|
|
38
|
+
defaultResource(ctx: any, client: any): Promise<string | undefined>;
|
|
39
|
+
/**
|
|
40
|
+
* `getResourceServerInfo` for service tenants. Returns the info for a
|
|
41
|
+
* service-tenant resource, `undefined` when neither the resource nor the client is
|
|
42
|
+
* a service-tenant one (the caller handles it), and throws `invalid_target` for a
|
|
43
|
+
* mismatch.
|
|
44
|
+
*/
|
|
45
|
+
resourceServerInfo(ctx: any, indicator: string, client: any, signAlg: string): Promise<ServiceTenantResourceServerInfo | undefined>;
|
|
46
|
+
/** `extraTokenClaims` for a service-tenant token; audits the issuance. */
|
|
47
|
+
extraClaims(ctx: any, token: any): Promise<{
|
|
48
|
+
partition: string;
|
|
49
|
+
capabilities?: string[];
|
|
50
|
+
} | undefined>;
|
|
51
|
+
};
|
|
52
|
+
export type ServiceTenantHooks = ReturnType<typeof createServiceTenantHooks>;
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { errors } from 'oidc-provider';
|
|
2
|
+
import { serviceTenantOf } from './client_metadata.js';
|
|
3
|
+
import { findServiceTenantResource } from './config.js';
|
|
4
|
+
/**
|
|
5
|
+
* The provider hooks behind service tenants:
|
|
6
|
+
*
|
|
7
|
+
* - a service-tenant resource is granted only to a service-tenant client, only on the
|
|
8
|
+
* `client_credentials` grant, always as a JWT access token (RFC 9068);
|
|
9
|
+
* - a service-tenant client can only get tokens for a service-tenant resource (when
|
|
10
|
+
* it sends no `resource` and exactly one is declared, that one is used);
|
|
11
|
+
* - its tokens carry the `partition` (and `capabilities`) claims read from the client
|
|
12
|
+
* at issuance time, so an edit applies to the next token.
|
|
13
|
+
*/
|
|
14
|
+
export function createServiceTenantHooks(deps) {
|
|
15
|
+
const { serviceTenants } = deps;
|
|
16
|
+
const memo = new WeakMap();
|
|
17
|
+
let adapter;
|
|
18
|
+
const read = async (clientId) => {
|
|
19
|
+
adapter ??= new deps.AdapterClass('Client');
|
|
20
|
+
return serviceTenantOf((await adapter.find(clientId)));
|
|
21
|
+
};
|
|
22
|
+
/** Service-tenant info of the client, memoized per request. */
|
|
23
|
+
const tenantOf = (ctx, clientId) => {
|
|
24
|
+
if (!clientId)
|
|
25
|
+
return Promise.resolve(null);
|
|
26
|
+
const key = ctx && typeof ctx === 'object' ? ctx : null;
|
|
27
|
+
if (!key)
|
|
28
|
+
return read(clientId);
|
|
29
|
+
let byClient = memo.get(key);
|
|
30
|
+
if (!byClient) {
|
|
31
|
+
byClient = new Map();
|
|
32
|
+
memo.set(key, byClient);
|
|
33
|
+
}
|
|
34
|
+
let pending = byClient.get(clientId);
|
|
35
|
+
if (!pending) {
|
|
36
|
+
pending = read(clientId);
|
|
37
|
+
byClient.set(clientId, pending);
|
|
38
|
+
}
|
|
39
|
+
return pending;
|
|
40
|
+
};
|
|
41
|
+
return {
|
|
42
|
+
enabled: serviceTenants.enabled,
|
|
43
|
+
/**
|
|
44
|
+
* `defaultResource` for a service-tenant client: the only declared resource, or
|
|
45
|
+
* `invalid_target` when several are declared. `undefined` for any other client.
|
|
46
|
+
*/
|
|
47
|
+
async defaultResource(ctx, client) {
|
|
48
|
+
if (!serviceTenants.enabled)
|
|
49
|
+
return undefined;
|
|
50
|
+
if (!(await tenantOf(ctx, client?.clientId)))
|
|
51
|
+
return undefined;
|
|
52
|
+
const keys = Object.keys(serviceTenants.resources);
|
|
53
|
+
if (keys.length === 1)
|
|
54
|
+
return keys[0];
|
|
55
|
+
throw new errors.InvalidTarget('a service tenant must send the resource parameter');
|
|
56
|
+
},
|
|
57
|
+
/**
|
|
58
|
+
* `getResourceServerInfo` for service tenants. Returns the info for a
|
|
59
|
+
* service-tenant resource, `undefined` when neither the resource nor the client is
|
|
60
|
+
* a service-tenant one (the caller handles it), and throws `invalid_target` for a
|
|
61
|
+
* mismatch.
|
|
62
|
+
*/
|
|
63
|
+
async resourceServerInfo(ctx, indicator, client, signAlg) {
|
|
64
|
+
if (!serviceTenants.enabled)
|
|
65
|
+
return undefined;
|
|
66
|
+
const found = findServiceTenantResource(serviceTenants, indicator);
|
|
67
|
+
const tenant = await tenantOf(ctx, client?.clientId);
|
|
68
|
+
if (found) {
|
|
69
|
+
const grantType = ctx?.oidc?.params?.grant_type;
|
|
70
|
+
if (!tenant || grantType !== 'client_credentials') {
|
|
71
|
+
throw new errors.InvalidTarget(`resource indicator not allowed: ${indicator}`);
|
|
72
|
+
}
|
|
73
|
+
return {
|
|
74
|
+
scope: found.resource.scopes.join(' '),
|
|
75
|
+
audience: found.resource.audience,
|
|
76
|
+
accessTokenFormat: 'jwt',
|
|
77
|
+
accessTokenTTL: found.resource.expiresIn,
|
|
78
|
+
jwt: { sign: { alg: signAlg } },
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
if (tenant) {
|
|
82
|
+
throw new errors.InvalidTarget(`resource indicator not allowed for a service tenant: ${indicator}`);
|
|
83
|
+
}
|
|
84
|
+
return undefined;
|
|
85
|
+
},
|
|
86
|
+
/** `extraTokenClaims` for a service-tenant token; audits the issuance. */
|
|
87
|
+
async extraClaims(ctx, token) {
|
|
88
|
+
if (!serviceTenants.enabled || token?.kind !== 'ClientCredentials')
|
|
89
|
+
return undefined;
|
|
90
|
+
const rs = token.resourceServer;
|
|
91
|
+
const indicator = typeof rs?.identifier === 'function' ? rs.identifier() : undefined;
|
|
92
|
+
if (typeof indicator !== 'string')
|
|
93
|
+
return undefined;
|
|
94
|
+
const found = findServiceTenantResource(serviceTenants, indicator);
|
|
95
|
+
if (!found)
|
|
96
|
+
return undefined;
|
|
97
|
+
const tenant = await tenantOf(ctx, token.clientId);
|
|
98
|
+
if (!tenant)
|
|
99
|
+
return undefined;
|
|
100
|
+
try {
|
|
101
|
+
await deps.audit
|
|
102
|
+
?.record({
|
|
103
|
+
type: 'service_tenant.token_issued',
|
|
104
|
+
clientId: token.clientId,
|
|
105
|
+
ip: typeof ctx?.ip === 'string' ? ctx.ip : null,
|
|
106
|
+
metadata: {
|
|
107
|
+
partition: tenant.partition,
|
|
108
|
+
resource: found.key,
|
|
109
|
+
jti: token.jti,
|
|
110
|
+
expiresIn: found.resource.expiresIn,
|
|
111
|
+
},
|
|
112
|
+
})
|
|
113
|
+
.catch(() => { });
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
// Auditing is best-effort: it never fails an issuance.
|
|
117
|
+
}
|
|
118
|
+
return {
|
|
119
|
+
partition: tenant.partition,
|
|
120
|
+
...(tenant.capabilities.length ? { capabilities: tenant.capabilities } : {}),
|
|
121
|
+
};
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { ResolvedServiceTenantsConfig } from './config.js';
|
|
2
|
+
/**
|
|
3
|
+
* What a verified tenant token authenticates as. Same shape as `VerifiedTenant` in
|
|
4
|
+
* `@adonis-agora/durable` (declared here: authkit never imports durable).
|
|
5
|
+
*/
|
|
6
|
+
export interface VerifiedTenant {
|
|
7
|
+
tenant: string;
|
|
8
|
+
capabilities?: string[] | undefined;
|
|
9
|
+
}
|
|
10
|
+
/** The input a control plane hands to its tenant verifier. */
|
|
11
|
+
export interface TenantVerifierInput {
|
|
12
|
+
/** The token the tenant presented. */
|
|
13
|
+
token?: string | undefined;
|
|
14
|
+
/** The tenant the request claims to be. Advisory only: the token decides. */
|
|
15
|
+
tenant?: string | undefined;
|
|
16
|
+
}
|
|
17
|
+
/** The issuer the verifier checks against: the local authkit server. */
|
|
18
|
+
export interface TenantTokenIssuer {
|
|
19
|
+
readonly publicJwks: {
|
|
20
|
+
keys: Record<string, any>[];
|
|
21
|
+
};
|
|
22
|
+
readonly config: {
|
|
23
|
+
issuer: string;
|
|
24
|
+
serviceTenants?: ResolvedServiceTenantsConfig;
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
export interface AuthkitTenantVerifierOptions {
|
|
28
|
+
/**
|
|
29
|
+
* Expected `aud`. Default: the audience of the only resource in
|
|
30
|
+
* `serviceTenants.resources` (required when several are declared).
|
|
31
|
+
*/
|
|
32
|
+
audience?: string;
|
|
33
|
+
/** Claim holding the partition. Default: `'partition'`. */
|
|
34
|
+
partitionClaim?: string;
|
|
35
|
+
/** Claim holding the capabilities. Default: `'capabilities'`. */
|
|
36
|
+
capabilitiesClaim?: string;
|
|
37
|
+
/** Allowed clock skew in seconds for `exp`/`nbf`/`iat`. Default: 30. */
|
|
38
|
+
clockToleranceSec?: number;
|
|
39
|
+
/**
|
|
40
|
+
* Where the issuer comes from. Default: the `authkit.server` binding of the booted
|
|
41
|
+
* app. Read on every call, so a signing-key rotation applies right away.
|
|
42
|
+
*/
|
|
43
|
+
issuer?: () => TenantTokenIssuer | Promise<TenantTokenIssuer>;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Verifies service-tenant tokens against the local authkit issuer and its JWKS (no
|
|
47
|
+
* network call), and returns the tenant they authenticate as, or `null`.
|
|
48
|
+
*
|
|
49
|
+
* Satisfies durable's `TenantVerifierContract` (`verify(input)`); usable as a class in
|
|
50
|
+
* `verifyTenant` (built by the container with the default options) or through
|
|
51
|
+
* {@link authkitTenantVerifier}.
|
|
52
|
+
*/
|
|
53
|
+
export declare class AuthkitTenantVerifier {
|
|
54
|
+
#private;
|
|
55
|
+
constructor(options?: AuthkitTenantVerifierOptions);
|
|
56
|
+
verify(input: TenantVerifierInput): Promise<VerifiedTenant | null>;
|
|
57
|
+
}
|
|
58
|
+
/** A tenant verifier usable both as a function and as an object with `verify`. */
|
|
59
|
+
export type AuthkitTenantVerifierFn = ((input: TenantVerifierInput) => Promise<VerifiedTenant | null>) & {
|
|
60
|
+
verify(input: TenantVerifierInput): Promise<VerifiedTenant | null>;
|
|
61
|
+
};
|
|
62
|
+
/**
|
|
63
|
+
* Tenant verifier for a durable control plane hosted by the authkit app:
|
|
64
|
+
*
|
|
65
|
+
* ```ts
|
|
66
|
+
* // config/durable.ts
|
|
67
|
+
* verifyTenant: authkitTenantVerifier()
|
|
68
|
+
* ```
|
|
69
|
+
*
|
|
70
|
+
* The result is a function (durable's `TenantVerifier`) that also has `verify`
|
|
71
|
+
* (durable's `TenantVerifierContract`).
|
|
72
|
+
*/
|
|
73
|
+
export declare function authkitTenantVerifier(options?: AuthkitTenantVerifierOptions): AuthkitTenantVerifierFn;
|