@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.
@@ -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) e as claims
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: string): Identity;
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) e as claims
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. Aponte para uma conexão cujo
41
- * `searchPath` enxergue o schema onde a tabela vive (ex.: `auth`). A tabela é
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
  /**
@@ -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
- /** Token endpoint. Default: `token_endpoint` from the issuer's discovery document. */
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 = (await discoverEndpoints(o.issuer, {
112
- onError: 'fallback',
113
- ...(o.fetch ? { fetchImpl: o.fetch } : {}),
114
- })).tokenEndpoint;
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;