@adonis-agora/authkit-client 1.0.0-rc.5 → 1.0.0-rc.7
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 +39 -0
- package/build/index.d.ts +12 -2
- package/build/index.js +6 -0
- package/build/providers/authkit_client_provider.d.ts +12 -0
- package/build/providers/authkit_client_provider.js +36 -0
- package/build/src/agora_consoles.d.ts +31 -0
- package/build/src/agora_consoles.js +40 -0
- package/build/src/authenticator.js +4 -1
- package/build/src/define_config.d.ts +17 -0
- package/build/src/define_config.js +1 -0
- package/build/src/identity_bridge.d.ts +46 -0
- package/build/src/identity_bridge.js +99 -0
- package/build/src/org_labels.d.ts +49 -0
- package/build/src/org_labels.js +145 -0
- package/build/src/register_oidc_client.d.ts +9 -0
- package/build/src/register_oidc_client.js +22 -0
- package/build/src/resolvers/identity.d.ts +3 -3
- package/build/src/resolvers/identity.js +6 -4
- package/build/src/revocation/revocation_store.d.ts +9 -3
- package/build/src/revocation/revocation_store.js +0 -0
- package/build/src/tenant_token.d.ts +5 -1
- package/build/src/tenant_token.js +7 -4
- package/build/src/tenant_verifier.d.ts +88 -0
- package/build/src/tenant_verifier.js +104 -0
- package/build/src/webhook_receiver.d.ts +101 -0
- package/build/src/webhook_receiver.js +161 -0
- package/package.json +3 -3
- package/skills/authkit-rp-client/SKILL.md +28 -2
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Organization display labels on a relying-party app, for the org pickers of every
|
|
3
|
+
* `@adonis-agora` console (`Context.tenantLabels`). The active org's name comes from the
|
|
4
|
+
* session's `org_name` claim; any other id is asked to the authkit server
|
|
5
|
+
* (`GET /api/orgs/labels?ids=`, authenticated with this app's client id and secret) and
|
|
6
|
+
* cached.
|
|
7
|
+
*/
|
|
8
|
+
/** Default path of authkit-server's labels endpoint. */
|
|
9
|
+
export const ORG_LABELS_PATH = '/api/orgs/labels';
|
|
10
|
+
const DEFAULT_TTL_MS = 5 * 60_000;
|
|
11
|
+
const MAX_CACHE = 2_000;
|
|
12
|
+
/** Ids per request: the server answers at most 100. */
|
|
13
|
+
const BATCH = 100;
|
|
14
|
+
/** Labels learned from session claims (`org_id` + `org_name`), newest last. */
|
|
15
|
+
const fromClaims = new Map();
|
|
16
|
+
function bounded(map, key, value) {
|
|
17
|
+
map.delete(key);
|
|
18
|
+
map.set(key, value);
|
|
19
|
+
if (map.size > MAX_CACHE) {
|
|
20
|
+
const oldest = map.keys().next().value;
|
|
21
|
+
if (oldest !== undefined)
|
|
22
|
+
map.delete(oldest);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Records the active org's name from a resolved session (`org_name` claim), so the
|
|
27
|
+
* labeler answers it with no call. Called by the `Authenticator`; best effort.
|
|
28
|
+
*/
|
|
29
|
+
export function rememberOrgLabel(identity) {
|
|
30
|
+
try {
|
|
31
|
+
const id = identity?.orgId;
|
|
32
|
+
const name = identity?.raw?.org_name;
|
|
33
|
+
if (typeof id === 'string' && id && typeof name === 'string' && name) {
|
|
34
|
+
if (fromClaims.get(id) !== name)
|
|
35
|
+
bounded(fromClaims, id, name);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
// never breaks the request
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/** Clears the labels learned from claims (tests). */
|
|
43
|
+
export function __clearOrgLabelsForTests() {
|
|
44
|
+
fromClaims.clear();
|
|
45
|
+
}
|
|
46
|
+
function defaultEndpoint(issuer) {
|
|
47
|
+
return new URL(ORG_LABELS_PATH, issuer).href;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* A label resolver backed by the authkit server. Ids of another tenant kind (`kind` other
|
|
51
|
+
* than `'organization'`) get no label. Never throws: an unreachable server answers what is
|
|
52
|
+
* already known, and the ids it could not resolve show as ids.
|
|
53
|
+
*/
|
|
54
|
+
export function authkitOrgLabels(options) {
|
|
55
|
+
if (!options?.issuer || !options.clientId || !options.clientSecret) {
|
|
56
|
+
throw new TypeError('authkitOrgLabels: issuer, clientId and clientSecret are required');
|
|
57
|
+
}
|
|
58
|
+
const endpoint = options.endpoint ?? defaultEndpoint(options.issuer);
|
|
59
|
+
const ttl = options.ttlMs ?? DEFAULT_TTL_MS;
|
|
60
|
+
const doFetch = options.fetch ?? globalThis.fetch;
|
|
61
|
+
const basic = Buffer.from(`${encodeURIComponent(options.clientId)}:${encodeURIComponent(options.clientSecret)}`).toString('base64');
|
|
62
|
+
/** id → label (or `null` for "not an authkit org") until `expiresAt`. */
|
|
63
|
+
const cache = new Map();
|
|
64
|
+
const fetchBatch = async (ids) => {
|
|
65
|
+
const url = new URL(endpoint);
|
|
66
|
+
url.searchParams.set('ids', ids.join(','));
|
|
67
|
+
try {
|
|
68
|
+
const res = await doFetch(url.href, {
|
|
69
|
+
headers: { authorization: `Basic ${basic}`, accept: 'application/json' },
|
|
70
|
+
signal: AbortSignal.timeout(options.timeoutMs ?? 5_000),
|
|
71
|
+
});
|
|
72
|
+
if (!res.ok)
|
|
73
|
+
return null;
|
|
74
|
+
const body = (await res.json());
|
|
75
|
+
const labels = body?.labels;
|
|
76
|
+
if (!labels || typeof labels !== 'object')
|
|
77
|
+
return null;
|
|
78
|
+
const out = {};
|
|
79
|
+
for (const [id, label] of Object.entries(labels)) {
|
|
80
|
+
if (typeof label === 'string' && label)
|
|
81
|
+
out[id] = label;
|
|
82
|
+
}
|
|
83
|
+
return out;
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
return async (ids, opts) => {
|
|
90
|
+
if (opts?.kind !== undefined && opts.kind !== 'organization')
|
|
91
|
+
return {};
|
|
92
|
+
const wanted = [
|
|
93
|
+
...new Set((Array.isArray(ids) ? ids : []).filter((id) => typeof id === 'string' && id)),
|
|
94
|
+
];
|
|
95
|
+
const result = {};
|
|
96
|
+
const missing = [];
|
|
97
|
+
const now = Date.now();
|
|
98
|
+
for (const id of wanted) {
|
|
99
|
+
const claimed = fromClaims.get(id);
|
|
100
|
+
if (claimed) {
|
|
101
|
+
result[id] = claimed;
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
const hit = cache.get(id);
|
|
105
|
+
if (hit && hit.expiresAt > now) {
|
|
106
|
+
if (hit.label)
|
|
107
|
+
result[id] = hit.label;
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
missing.push(id);
|
|
111
|
+
}
|
|
112
|
+
for (let i = 0; i < missing.length; i += BATCH) {
|
|
113
|
+
const batch = missing.slice(i, i + BATCH);
|
|
114
|
+
const fetched = await fetchBatch(batch);
|
|
115
|
+
if (!fetched)
|
|
116
|
+
continue; // not cached: the next call retries
|
|
117
|
+
const expiresAt = Date.now() + ttl;
|
|
118
|
+
for (const id of batch) {
|
|
119
|
+
const label = fetched[id] ?? null;
|
|
120
|
+
bounded(cache, id, { label, expiresAt });
|
|
121
|
+
if (label)
|
|
122
|
+
result[id] = label;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return result;
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
const TENANCY_SLOT = Symbol.for('@agora/context:tenancy');
|
|
129
|
+
/**
|
|
130
|
+
* Registers `resolver` as the context's tenant labeler (`Context.setTenantLabeler`), so
|
|
131
|
+
* console org pickers show names. `tenancy.label` in `config/context.ts` still wins.
|
|
132
|
+
* Returns `false` (and does nothing) without `@adonis-agora/context`.
|
|
133
|
+
*/
|
|
134
|
+
export function installOrgLabeler(resolver) {
|
|
135
|
+
try {
|
|
136
|
+
const slot = globalThis[TENANCY_SLOT];
|
|
137
|
+
if (!slot || slot.version !== 1 || typeof slot.setTenantLabeler !== 'function')
|
|
138
|
+
return false;
|
|
139
|
+
slot.setTenantLabeler(resolver);
|
|
140
|
+
return true;
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
return false;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type Identity } from '@adonis-agora/authkit-core';
|
|
2
2
|
import type { HttpContext, Router } from '@adonisjs/core/http';
|
|
3
|
+
import { type ConsolesAppLike } from './agora_consoles.js';
|
|
3
4
|
/**
|
|
4
5
|
* Mapa role → rota de redirect pós-login. `byGlobalRole` checa as roles globais do
|
|
5
6
|
* IdP (claim do token) — a primeira correspondência vence; sem match → `default`.
|
|
@@ -36,6 +37,14 @@ export interface RegisterOidcClientOptions {
|
|
|
36
37
|
loginMiddleware?: any;
|
|
37
38
|
/** Query param repassado como hint ao authorize (ex.: `audience`). Default: `['audience']`. */
|
|
38
39
|
passthroughParams?: string[];
|
|
40
|
+
/**
|
|
41
|
+
* Announce `POST {prefix}/logout` as the sign-out of every `@adonis-agora` console
|
|
42
|
+
* (`account.signOut` on the `'agora.consoles'` registry). Skipped when authkit-server runs
|
|
43
|
+
* in this app (it announces its own) or when something announced one first. Default: `true`.
|
|
44
|
+
*/
|
|
45
|
+
announceSignOut?: boolean;
|
|
46
|
+
/** The application to announce on. Default: `@adonisjs/core/services/app`. */
|
|
47
|
+
app?: ConsolesAppLike;
|
|
39
48
|
}
|
|
40
49
|
/**
|
|
41
50
|
* Registra as rotas OIDC de CLIENT (login/callback/logout [+ back-channel logout])
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { ACCOUNT_EXTENSIONS_WELL_KNOWN } from '@adonis-agora/authkit-core';
|
|
2
|
+
import { announceAgoraSignOut } from './agora_consoles.js';
|
|
2
3
|
import { OidcCallbackError } from './authorization.js';
|
|
3
4
|
import { buildEndSessionUrl } from './oidc_login.js';
|
|
4
5
|
/**
|
|
@@ -117,6 +118,27 @@ export function registerOidcClient(router, options = {}) {
|
|
|
117
118
|
}
|
|
118
119
|
if (options.accountExtensions !== false)
|
|
119
120
|
registerAccountExtensionsEndpoint(router);
|
|
121
|
+
if (options.announceSignOut !== false) {
|
|
122
|
+
void announceRpSignOut(options.app, `${prefix}/logout`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Announces the RP logout on the consoles registry, unless authkit-server (which announces
|
|
127
|
+
* its account sign-out) runs in this app. Best effort: never throws.
|
|
128
|
+
*/
|
|
129
|
+
async function announceRpSignOut(app, action) {
|
|
130
|
+
try {
|
|
131
|
+
const target = app ??
|
|
132
|
+
(await import('@adonisjs/core/services/app')).default;
|
|
133
|
+
if (!target?.container)
|
|
134
|
+
return;
|
|
135
|
+
if (target.container.hasBinding('authkit.server'))
|
|
136
|
+
return;
|
|
137
|
+
await announceAgoraSignOut(target, action);
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
// no booted app (a bare router in tests) or a registry of another shape
|
|
141
|
+
}
|
|
120
142
|
}
|
|
121
143
|
/**
|
|
122
144
|
* Mounts `GET`/`POST /.well-known/agora-account-extensions`, where the IdP's account console
|
|
@@ -9,10 +9,10 @@ type FetchImpl = (url: string, init: any) => Promise<{
|
|
|
9
9
|
* produzia (a referência canônica): `sub`→userId, `email`, papéis na
|
|
10
10
|
* `globalRolesClaim`, `name`/`picture`→profile, `sid`→sessionId, `iat`/`exp`
|
|
11
11
|
* (segundos→ms, fallback 0), a org/tenant ativo (`orgId`/`orgSlug`/`orgRole`,
|
|
12
|
-
* ver `deriveOrgId` — vale para o authkit E para IdPs de terceiros)
|
|
13
|
-
* cruas em `raw`.
|
|
12
|
+
* ver `deriveOrgId` — vale para o authkit E para IdPs de terceiros), o ator real de
|
|
13
|
+
* uma impersonação (`actor`, da claim `act` — RFC 8693) e as claims cruas em `raw`.
|
|
14
14
|
*/
|
|
15
|
-
export declare function buildIdentityFromClaims(claims: Record<string, unknown>, globalRolesClaim
|
|
15
|
+
export declare function buildIdentityFromClaims(claims: Record<string, unknown>, globalRolesClaim?: string): Identity;
|
|
16
16
|
export interface IntrospectAuth {
|
|
17
17
|
/** `bearer` envia `Authorization: Bearer <value>`; `basic` envia `Authorization: Basic <value>`. */
|
|
18
18
|
type: 'bearer' | 'basic';
|
|
@@ -1,15 +1,16 @@
|
|
|
1
|
-
import { deriveOrgId, deriveOrgRole, deriveOrgSlug } from '@adonis-agora/authkit-core';
|
|
1
|
+
import { deriveActor, deriveOrgId, deriveOrgRole, deriveOrgSlug } from '@adonis-agora/authkit-core';
|
|
2
2
|
/**
|
|
3
3
|
* Mapeamento canônico de claims OIDC validadas → Identity, compartilhado pelos
|
|
4
4
|
* resolvers `jwt`, `pat` e `opaque`. Espelha exatamente o que o `JwtResolver`
|
|
5
5
|
* produzia (a referência canônica): `sub`→userId, `email`, papéis na
|
|
6
6
|
* `globalRolesClaim`, `name`/`picture`→profile, `sid`→sessionId, `iat`/`exp`
|
|
7
7
|
* (segundos→ms, fallback 0), a org/tenant ativo (`orgId`/`orgSlug`/`orgRole`,
|
|
8
|
-
* ver `deriveOrgId` — vale para o authkit E para IdPs de terceiros)
|
|
9
|
-
* cruas em `raw`.
|
|
8
|
+
* ver `deriveOrgId` — vale para o authkit E para IdPs de terceiros), o ator real de
|
|
9
|
+
* uma impersonação (`actor`, da claim `act` — RFC 8693) e as claims cruas em `raw`.
|
|
10
10
|
*/
|
|
11
|
-
export function buildIdentityFromClaims(claims, globalRolesClaim) {
|
|
11
|
+
export function buildIdentityFromClaims(claims, globalRolesClaim = 'roles') {
|
|
12
12
|
const roles = claims[globalRolesClaim];
|
|
13
|
+
const actor = deriveActor(claims);
|
|
13
14
|
return {
|
|
14
15
|
userId: String(claims.sub ?? ''),
|
|
15
16
|
email: typeof claims.email === 'string' ? claims.email : '',
|
|
@@ -22,6 +23,7 @@ export function buildIdentityFromClaims(claims, globalRolesClaim) {
|
|
|
22
23
|
orgId: deriveOrgId(claims),
|
|
23
24
|
orgSlug: deriveOrgSlug(claims),
|
|
24
25
|
orgRole: deriveOrgRole(claims),
|
|
26
|
+
...(actor ? { actor } : {}),
|
|
25
27
|
issuedAt: typeof claims.iat === 'number' ? claims.iat * 1000 : 0,
|
|
26
28
|
expiresAt: typeof claims.exp === 'number' ? claims.exp * 1000 : 0,
|
|
27
29
|
raw: claims,
|
|
@@ -37,11 +37,17 @@ export interface RevocationStore {
|
|
|
37
37
|
}
|
|
38
38
|
export interface LucidRevocationStoreOptions {
|
|
39
39
|
/**
|
|
40
|
-
* Conexão Lucid a usar. Default: conexão primária.
|
|
41
|
-
*
|
|
42
|
-
* compartilhável entre apps que apontam para o MESMO banco.
|
|
40
|
+
* Conexão Lucid a usar. Default: conexão primária. A tabela é compartilhável entre
|
|
41
|
+
* apps que apontam para o MESMO banco.
|
|
43
42
|
*/
|
|
44
43
|
connection?: string;
|
|
44
|
+
/**
|
|
45
|
+
* Schema do banco onde a tabela vive (MySQL: o database) — o `schema.name` do
|
|
46
|
+
* authkit-server, ex.: `'auth'`. Escrito em cada statement (`"auth"."auth_session_revocations"`).
|
|
47
|
+
* Não use `searchPath` na conexão para isso: atrás de um PgBouncer em modo
|
|
48
|
+
* transaction, o `SET search_path` vaza para as conexões de outros clientes.
|
|
49
|
+
*/
|
|
50
|
+
schema?: string;
|
|
45
51
|
/** Nome da tabela. Default: `auth_session_revocations` (criada pelo schema auto-manage do server). */
|
|
46
52
|
table?: string;
|
|
47
53
|
/**
|
|
Binary file
|
|
@@ -19,7 +19,11 @@ export interface AuthkitTenantTokenOptions {
|
|
|
19
19
|
scope?: string;
|
|
20
20
|
/** Refresh this many seconds before the token expires. Default: 60. */
|
|
21
21
|
refreshSkewSec?: number;
|
|
22
|
-
/**
|
|
22
|
+
/**
|
|
23
|
+
* Token endpoint. Default: `token_endpoint` from the issuer's discovery document
|
|
24
|
+
* (`{issuer}/.well-known/openid-configuration`). When discovery fails the token
|
|
25
|
+
* request fails too (no guessed path); the next call retries.
|
|
26
|
+
*/
|
|
23
27
|
tokenEndpoint?: string;
|
|
24
28
|
/** Custom `fetch` (proxy, tests). Default: the global `fetch`. */
|
|
25
29
|
fetch?: typeof fetch;
|
|
@@ -108,10 +108,13 @@ export class AuthkitTenantToken {
|
|
|
108
108
|
let endpoint = o.tokenEndpoint;
|
|
109
109
|
if (!endpoint) {
|
|
110
110
|
try {
|
|
111
|
-
endpoint =
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
111
|
+
endpoint =
|
|
112
|
+
// Strict discovery: the token endpoint comes from the issuer's
|
|
113
|
+
// `/.well-known/openid-configuration`, never from a guessed path.
|
|
114
|
+
(await discoverEndpoints(o.issuer, {
|
|
115
|
+
onError: 'throw',
|
|
116
|
+
...(o.fetch ? { fetchImpl: o.fetch } : {}),
|
|
117
|
+
})).tokenEndpoint;
|
|
115
118
|
}
|
|
116
119
|
catch (cause) {
|
|
117
120
|
throw new TenantTokenError('could not resolve the token endpoint', undefined, undefined, {
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The control-plane half of service-tenant auth: verifies the `client_credentials` JWT
|
|
3
|
+
* a tenant process presents (see `authkitTenantToken`) against the issuer's keys, found
|
|
4
|
+
* by OIDC discovery. Remote-safe: authkit-server does not have to run in this process.
|
|
5
|
+
*/
|
|
6
|
+
import { type DiscoverOptions } from './discovery.js';
|
|
7
|
+
/** What a control plane hands to its verifier. Only `token` is trusted, once verified. */
|
|
8
|
+
export interface TenantVerifierRequest {
|
|
9
|
+
/** The token the tenant presented. */
|
|
10
|
+
token?: string | undefined;
|
|
11
|
+
/** The partition the request claims to be. Advisory only: the token decides. */
|
|
12
|
+
partition?: string | undefined;
|
|
13
|
+
/** Same as `partition` (the wire name some libs use). Advisory only. */
|
|
14
|
+
tenant?: string | undefined;
|
|
15
|
+
}
|
|
16
|
+
/** What a verified tenant token authenticates as. Everything comes from the token. */
|
|
17
|
+
export interface VerifiedTenantToken {
|
|
18
|
+
/** The `partition` claim: the app this tenant is. */
|
|
19
|
+
partition: string;
|
|
20
|
+
/** The `environment` claim, when the token has one. */
|
|
21
|
+
environment?: string;
|
|
22
|
+
/** The `capabilities` claim (product-defined, e.g. `flags:ruleset:read`). `[]` when absent. */
|
|
23
|
+
capabilities: string[];
|
|
24
|
+
/** The `client_id` of the service-tenant client. */
|
|
25
|
+
clientId: string;
|
|
26
|
+
/** Epoch ms at which the token expires. */
|
|
27
|
+
expiresAt: number;
|
|
28
|
+
}
|
|
29
|
+
/** The function form every Agora control plane accepts as `verifyTenant`. */
|
|
30
|
+
export type TenantVerifier = (request: TenantVerifierRequest) => Promise<VerifiedTenantToken | null>;
|
|
31
|
+
export interface AuthkitTenantVerifierOptions {
|
|
32
|
+
/** Issuer URL of the authkit server (the `iss` of its tokens). */
|
|
33
|
+
issuer: string;
|
|
34
|
+
/**
|
|
35
|
+
* The resource this control plane answers for, e.g. `'urn:agora:flags'`. The token's
|
|
36
|
+
* `aud` must contain it.
|
|
37
|
+
*/
|
|
38
|
+
resource: string;
|
|
39
|
+
/**
|
|
40
|
+
* The environment this control plane serves. Only tokens whose `environment` claim is
|
|
41
|
+
* exactly this value are accepted (a token without the claim is rejected). Without it,
|
|
42
|
+
* any environment is accepted and returned as claimed.
|
|
43
|
+
*/
|
|
44
|
+
environment?: string;
|
|
45
|
+
/** Expected `aud` when the server declares a different `audience` for the resource. Default: `resource`. */
|
|
46
|
+
audience?: string;
|
|
47
|
+
/** Allowed clock skew in seconds for `exp`/`nbf`/`iat`. Default: 30. */
|
|
48
|
+
clockToleranceSec?: number;
|
|
49
|
+
/** Discovery options (custom `fetchImpl`, cache TTL, resilience policy). */
|
|
50
|
+
discovery?: Pick<DiscoverOptions, 'fetchImpl' | 'cacheTtlMs' | 'resilience' | 'overrides'>;
|
|
51
|
+
}
|
|
52
|
+
/** A tenant verifier usable both as a function and as an object with `verify`. */
|
|
53
|
+
export type AuthkitTenantVerifierFn = TenantVerifier & {
|
|
54
|
+
verify(request: TenantVerifierRequest): Promise<VerifiedTenantToken | null>;
|
|
55
|
+
};
|
|
56
|
+
/**
|
|
57
|
+
* Verifies service-tenant tokens issued by an authkit server, on any server:
|
|
58
|
+
*
|
|
59
|
+
* - finds `jwks_uri` and the exact `issuer` through `{issuer}/.well-known/openid-configuration`
|
|
60
|
+
* (cached; never a guessed path);
|
|
61
|
+
* - checks the signature (asymmetric algorithms only), `iss`, `aud` (= `resource`),
|
|
62
|
+
* `typ: at+jwt`, `exp`, and that `sub` is the client (a `client_credentials` token);
|
|
63
|
+
* - reads `partition`, `capabilities` and `environment` from the token, never from the request.
|
|
64
|
+
*
|
|
65
|
+
* Returns `null` for a token that does not verify; throws when the issuer cannot be
|
|
66
|
+
* discovered (the request fails instead of being silently refused).
|
|
67
|
+
*/
|
|
68
|
+
export declare class AuthkitTenantVerifier {
|
|
69
|
+
#private;
|
|
70
|
+
constructor(options: AuthkitTenantVerifierOptions);
|
|
71
|
+
verify(request: TenantVerifierRequest): Promise<VerifiedTenantToken | null>;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* The `verifyTenant` of a control plane, wherever authkit runs:
|
|
75
|
+
*
|
|
76
|
+
* ```ts
|
|
77
|
+
* // config/flags.ts (control plane)
|
|
78
|
+
* import { authkitTenantVerifier } from '@adonis-agora/authkit-client'
|
|
79
|
+
*
|
|
80
|
+
* verifyTenant: authkitTenantVerifier({
|
|
81
|
+
* issuer: env.get('AUTHKIT_ISSUER'),
|
|
82
|
+
* resource: 'urn:agora:flags',
|
|
83
|
+
* })
|
|
84
|
+
* ```
|
|
85
|
+
*
|
|
86
|
+
* The result is a function that also has `verify` (the class form).
|
|
87
|
+
*/
|
|
88
|
+
export declare function authkitTenantVerifier(options: AuthkitTenantVerifierOptions): AuthkitTenantVerifierFn;
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The control-plane half of service-tenant auth: verifies the `client_credentials` JWT
|
|
3
|
+
* a tenant process presents (see `authkitTenantToken`) against the issuer's keys, found
|
|
4
|
+
* by OIDC discovery. Remote-safe: authkit-server does not have to run in this process.
|
|
5
|
+
*/
|
|
6
|
+
import { errors as joseErrors, jwtVerify } from 'jose';
|
|
7
|
+
import { discoverEndpoints } from './discovery.js';
|
|
8
|
+
import { DEFAULT_ALGS, getJwks } from './jwks.js';
|
|
9
|
+
function stringArray(value) {
|
|
10
|
+
if (value === undefined)
|
|
11
|
+
return [];
|
|
12
|
+
if (!Array.isArray(value) || value.some((v) => typeof v !== 'string'))
|
|
13
|
+
return null;
|
|
14
|
+
return value;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Verifies service-tenant tokens issued by an authkit server, on any server:
|
|
18
|
+
*
|
|
19
|
+
* - finds `jwks_uri` and the exact `issuer` through `{issuer}/.well-known/openid-configuration`
|
|
20
|
+
* (cached; never a guessed path);
|
|
21
|
+
* - checks the signature (asymmetric algorithms only), `iss`, `aud` (= `resource`),
|
|
22
|
+
* `typ: at+jwt`, `exp`, and that `sub` is the client (a `client_credentials` token);
|
|
23
|
+
* - reads `partition`, `capabilities` and `environment` from the token, never from the request.
|
|
24
|
+
*
|
|
25
|
+
* Returns `null` for a token that does not verify; throws when the issuer cannot be
|
|
26
|
+
* discovered (the request fails instead of being silently refused).
|
|
27
|
+
*/
|
|
28
|
+
export class AuthkitTenantVerifier {
|
|
29
|
+
#options;
|
|
30
|
+
constructor(options) {
|
|
31
|
+
if (!options?.issuer || !options.resource) {
|
|
32
|
+
throw new TypeError('authkitTenantVerifier: issuer and resource are required');
|
|
33
|
+
}
|
|
34
|
+
this.#options = options;
|
|
35
|
+
}
|
|
36
|
+
async verify(request) {
|
|
37
|
+
const token = request?.token;
|
|
38
|
+
if (typeof token !== 'string' || !token)
|
|
39
|
+
return null;
|
|
40
|
+
const o = this.#options;
|
|
41
|
+
const endpoints = await discoverEndpoints(o.issuer, { ...o.discovery, onError: 'throw' });
|
|
42
|
+
let payload;
|
|
43
|
+
try {
|
|
44
|
+
({ payload } = await jwtVerify(token, getJwks(endpoints.jwksUri), {
|
|
45
|
+
issuer: endpoints.issuer,
|
|
46
|
+
audience: o.audience ?? o.resource,
|
|
47
|
+
algorithms: DEFAULT_ALGS,
|
|
48
|
+
typ: 'at+jwt',
|
|
49
|
+
clockTolerance: o.clockToleranceSec ?? 30,
|
|
50
|
+
requiredClaims: ['exp', 'sub'],
|
|
51
|
+
}));
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
if (error instanceof joseErrors.JOSEError)
|
|
55
|
+
return null;
|
|
56
|
+
throw error;
|
|
57
|
+
}
|
|
58
|
+
const claims = payload;
|
|
59
|
+
// A client_credentials token: no user behind it, `sub` is the client.
|
|
60
|
+
const clientId = claims.client_id;
|
|
61
|
+
if (typeof clientId !== 'string' || payload.sub !== clientId)
|
|
62
|
+
return null;
|
|
63
|
+
const partition = claims.partition;
|
|
64
|
+
if (typeof partition !== 'string' || !partition)
|
|
65
|
+
return null;
|
|
66
|
+
const capabilities = stringArray(claims.capabilities);
|
|
67
|
+
if (capabilities === null)
|
|
68
|
+
return null;
|
|
69
|
+
const claimed = claims.environment;
|
|
70
|
+
if (claimed !== undefined && (typeof claimed !== 'string' || !claimed))
|
|
71
|
+
return null;
|
|
72
|
+
// Pinned: fail closed, a token without the claim is not "this environment".
|
|
73
|
+
if (o.environment && claimed !== o.environment)
|
|
74
|
+
return null;
|
|
75
|
+
const environment = claimed;
|
|
76
|
+
return {
|
|
77
|
+
partition,
|
|
78
|
+
...(environment ? { environment } : {}),
|
|
79
|
+
capabilities,
|
|
80
|
+
clientId,
|
|
81
|
+
expiresAt: payload.exp * 1000,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* The `verifyTenant` of a control plane, wherever authkit runs:
|
|
87
|
+
*
|
|
88
|
+
* ```ts
|
|
89
|
+
* // config/flags.ts (control plane)
|
|
90
|
+
* import { authkitTenantVerifier } from '@adonis-agora/authkit-client'
|
|
91
|
+
*
|
|
92
|
+
* verifyTenant: authkitTenantVerifier({
|
|
93
|
+
* issuer: env.get('AUTHKIT_ISSUER'),
|
|
94
|
+
* resource: 'urn:agora:flags',
|
|
95
|
+
* })
|
|
96
|
+
* ```
|
|
97
|
+
*
|
|
98
|
+
* The result is a function that also has `verify` (the class form).
|
|
99
|
+
*/
|
|
100
|
+
export function authkitTenantVerifier(options) {
|
|
101
|
+
const verifier = new AuthkitTenantVerifier(options);
|
|
102
|
+
const verify = (request) => verifier.verify(request);
|
|
103
|
+
return Object.assign(verify, { verify });
|
|
104
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Receives authkit-server's signed event webhook on another server and re-emits each
|
|
3
|
+
* event on the local `@adonis-agora/diagnostics` bus as `agora:authkit:<type>`, with the
|
|
4
|
+
* same payload authkit-server emits in-process. Listeners such as authz provisioning
|
|
5
|
+
* (`onDiagnostic('authkit', 'organization.created', ...)`) then work unchanged when
|
|
6
|
+
* authkit runs elsewhere.
|
|
7
|
+
*/
|
|
8
|
+
import type { HttpContext, Router } from '@adonisjs/core/http';
|
|
9
|
+
/** The event as authkit-server's webhook sends it (the signed JSON body). */
|
|
10
|
+
export interface AuthkitWebhookEvent {
|
|
11
|
+
/** Unique per event; a repeated id is ignored. */
|
|
12
|
+
id: string;
|
|
13
|
+
/** The audit event type, e.g. `organization.created`, `member.added`. */
|
|
14
|
+
type: string;
|
|
15
|
+
accountId: string | null;
|
|
16
|
+
actorId: string | null;
|
|
17
|
+
email: string | null;
|
|
18
|
+
clientId: string | null;
|
|
19
|
+
orgId: string | null;
|
|
20
|
+
ip: string | null;
|
|
21
|
+
metadata: Record<string, unknown>;
|
|
22
|
+
/** When authkit dispatched it (ISO 8601). */
|
|
23
|
+
ts: string;
|
|
24
|
+
}
|
|
25
|
+
/** What is emitted on the diagnostics bus: authkit-server's in-process payload (no PII). */
|
|
26
|
+
export interface AuthkitDiagnosticPayload {
|
|
27
|
+
type: string;
|
|
28
|
+
accountId: string | null;
|
|
29
|
+
actorId: string | null;
|
|
30
|
+
clientId: string | null;
|
|
31
|
+
orgId: string | null;
|
|
32
|
+
}
|
|
33
|
+
/** Remembers delivered ids for replay protection. */
|
|
34
|
+
export interface WebhookReplayStore {
|
|
35
|
+
/**
|
|
36
|
+
* Records `id` for `ttlMs`. Returns `false` when it was already recorded (a replay).
|
|
37
|
+
* Shared stores (Redis `SET NX PX`) protect every instance of the app.
|
|
38
|
+
*/
|
|
39
|
+
claim(id: string, ttlMs: number): boolean | Promise<boolean>;
|
|
40
|
+
}
|
|
41
|
+
export interface AuthkitWebhookOptions {
|
|
42
|
+
/** The secret configured in authkit-server's `events.webhook.secret`. */
|
|
43
|
+
secret: string;
|
|
44
|
+
/** Accept events whose `ts` is at most this many seconds away from now. Default: 300. */
|
|
45
|
+
toleranceSec?: number;
|
|
46
|
+
/** Where delivered ids are remembered. Default: in memory, per process. */
|
|
47
|
+
replayStore?: WebhookReplayStore;
|
|
48
|
+
/** Also receive the full event (with `email`, `ip`, `metadata`). Runs before the bus emit. */
|
|
49
|
+
onEvent?: (event: AuthkitWebhookEvent) => void | Promise<void>;
|
|
50
|
+
/** Re-emit on the diagnostics bus. Default: `true`. */
|
|
51
|
+
emit?: boolean;
|
|
52
|
+
}
|
|
53
|
+
/** Why a delivery was refused. */
|
|
54
|
+
export type WebhookRejection = 'signature' | 'malformed' | 'expired' | 'replay';
|
|
55
|
+
/** The verdict of {@link verifyAuthkitWebhook}. */
|
|
56
|
+
export type WebhookVerification = {
|
|
57
|
+
ok: true;
|
|
58
|
+
event: AuthkitWebhookEvent;
|
|
59
|
+
} | {
|
|
60
|
+
ok: false;
|
|
61
|
+
reason: WebhookRejection;
|
|
62
|
+
};
|
|
63
|
+
/** In-memory replay store: ids live for their TTL, at most `max` at a time. */
|
|
64
|
+
export declare function memoryReplayStore(max?: number): WebhookReplayStore;
|
|
65
|
+
/**
|
|
66
|
+
* Checks one delivery: the `x-authkit-signature` HMAC over the raw body, the shape, the
|
|
67
|
+
* `ts` window and the `id` (each id is accepted once). Pure apart from the replay store.
|
|
68
|
+
*/
|
|
69
|
+
export declare function verifyAuthkitWebhook(input: {
|
|
70
|
+
body: string;
|
|
71
|
+
signature: string | undefined | null;
|
|
72
|
+
}, options: Pick<AuthkitWebhookOptions, 'secret' | 'toleranceSec'> & {
|
|
73
|
+
replayStore: WebhookReplayStore;
|
|
74
|
+
}): Promise<WebhookVerification>;
|
|
75
|
+
/**
|
|
76
|
+
* Emits `event` on the diagnostics bus as `agora:authkit:<type>` with authkit-server's
|
|
77
|
+
* redacted payload (`type`, `accountId`, `actorId`, `clientId`, `orgId`). No-op without
|
|
78
|
+
* `@adonis-agora/diagnostics`.
|
|
79
|
+
*/
|
|
80
|
+
export declare function emitAuthkitEvent(event: AuthkitWebhookEvent): void;
|
|
81
|
+
/**
|
|
82
|
+
* The route handler for authkit-server's webhook. Answers `204` for an accepted (or
|
|
83
|
+
* replayed) delivery, `401` for a bad signature, `400` for a malformed or expired one.
|
|
84
|
+
* Needs the raw body: keep the route under the JSON body parser (the default) and out of
|
|
85
|
+
* CSRF (shield's `csrf.exceptRoutes`).
|
|
86
|
+
*/
|
|
87
|
+
export declare function authkitWebhookHandler(options: AuthkitWebhookOptions): (ctx: HttpContext) => Promise<unknown>;
|
|
88
|
+
/**
|
|
89
|
+
* Mounts `POST path` (default `/authkit/webhook`) with {@link authkitWebhookHandler}:
|
|
90
|
+
*
|
|
91
|
+
* ```ts
|
|
92
|
+
* // start/routes.ts
|
|
93
|
+
* registerAuthkitWebhook(router, { secret: env.get('AUTHKIT_WEBHOOK_SECRET') })
|
|
94
|
+
* ```
|
|
95
|
+
*
|
|
96
|
+
* Point authkit-server's `events.webhook` at `https://<this app>/authkit/webhook` with the
|
|
97
|
+
* same secret, and add the path to shield's `csrf.exceptRoutes`.
|
|
98
|
+
*/
|
|
99
|
+
export declare function registerAuthkitWebhook(router: Router, options: AuthkitWebhookOptions & {
|
|
100
|
+
path?: string;
|
|
101
|
+
}): void;
|