@adonis-agora/authkit-client 1.0.0-rc.6 → 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,161 @@
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 { createHmac, timingSafeEqual } from 'node:crypto';
9
+ /** In-memory replay store: ids live for their TTL, at most `max` at a time. */
10
+ export function memoryReplayStore(max = 10_000) {
11
+ const seen = new Map();
12
+ return {
13
+ claim(id, ttlMs) {
14
+ const now = Date.now();
15
+ const expiresAt = seen.get(id);
16
+ if (expiresAt !== undefined && expiresAt > now)
17
+ return false;
18
+ seen.delete(id);
19
+ seen.set(id, now + ttlMs);
20
+ if (seen.size > max) {
21
+ for (const [key, exp] of seen) {
22
+ if (exp <= now || seen.size > max)
23
+ seen.delete(key);
24
+ else
25
+ break;
26
+ }
27
+ }
28
+ return true;
29
+ },
30
+ };
31
+ }
32
+ function validSignature(body, header, secret) {
33
+ if (typeof header !== 'string' || !header.startsWith('sha256='))
34
+ return false;
35
+ const expected = Buffer.from(createHmac('sha256', secret).update(body).digest('hex'));
36
+ const given = Buffer.from(header.slice('sha256='.length));
37
+ return given.length === expected.length && timingSafeEqual(given, expected);
38
+ }
39
+ const str = (v) => (typeof v === 'string' && v ? v : null);
40
+ /**
41
+ * Checks one delivery: the `x-authkit-signature` HMAC over the raw body, the shape, the
42
+ * `ts` window and the `id` (each id is accepted once). Pure apart from the replay store.
43
+ */
44
+ export async function verifyAuthkitWebhook(input, options) {
45
+ if (!validSignature(input.body, input.signature, options.secret)) {
46
+ return { ok: false, reason: 'signature' };
47
+ }
48
+ let raw;
49
+ try {
50
+ raw = JSON.parse(input.body);
51
+ }
52
+ catch {
53
+ return { ok: false, reason: 'malformed' };
54
+ }
55
+ const id = str(raw?.id);
56
+ const type = str(raw?.type);
57
+ const ts = str(raw?.ts);
58
+ if (!id || !type || !ts)
59
+ return { ok: false, reason: 'malformed' };
60
+ const at = Date.parse(ts);
61
+ if (Number.isNaN(at))
62
+ return { ok: false, reason: 'malformed' };
63
+ const toleranceMs = (options.toleranceSec ?? 300) * 1000;
64
+ if (Math.abs(Date.now() - at) > toleranceMs)
65
+ return { ok: false, reason: 'expired' };
66
+ // Remember the id for as long as its `ts` stays acceptable.
67
+ if (!(await options.replayStore.claim(id, toleranceMs * 2))) {
68
+ return { ok: false, reason: 'replay' };
69
+ }
70
+ const metadata = raw.metadata && typeof raw.metadata === 'object' && !Array.isArray(raw.metadata)
71
+ ? raw.metadata
72
+ : {};
73
+ return {
74
+ ok: true,
75
+ event: {
76
+ id,
77
+ type,
78
+ accountId: str(raw.accountId),
79
+ actorId: str(raw.actorId),
80
+ email: str(raw.email),
81
+ clientId: str(raw.clientId),
82
+ orgId: str(raw.orgId),
83
+ ip: str(raw.ip),
84
+ metadata,
85
+ ts,
86
+ },
87
+ };
88
+ }
89
+ const EMIT_SLOT = Symbol.for('@agora/diagnostics:emit');
90
+ /**
91
+ * Emits `event` on the diagnostics bus as `agora:authkit:<type>` with authkit-server's
92
+ * redacted payload (`type`, `accountId`, `actorId`, `clientId`, `orgId`). No-op without
93
+ * `@adonis-agora/diagnostics`.
94
+ */
95
+ export function emitAuthkitEvent(event) {
96
+ try {
97
+ const emit = globalThis[EMIT_SLOT];
98
+ emit?.('authkit', event.type, {
99
+ type: event.type,
100
+ accountId: event.accountId,
101
+ actorId: event.actorId,
102
+ clientId: event.clientId,
103
+ orgId: event.orgId,
104
+ });
105
+ }
106
+ catch {
107
+ // best effort
108
+ }
109
+ }
110
+ /**
111
+ * The route handler for authkit-server's webhook. Answers `204` for an accepted (or
112
+ * replayed) delivery, `401` for a bad signature, `400` for a malformed or expired one.
113
+ * Needs the raw body: keep the route under the JSON body parser (the default) and out of
114
+ * CSRF (shield's `csrf.exceptRoutes`).
115
+ */
116
+ export function authkitWebhookHandler(options) {
117
+ if (!options?.secret)
118
+ throw new TypeError('authkitWebhookHandler: secret is required');
119
+ const replayStore = options.replayStore ?? memoryReplayStore();
120
+ return async (ctx) => {
121
+ const body = ctx.request.raw();
122
+ if (typeof body !== 'string' || !body) {
123
+ return ctx.response.badRequest({ error: 'malformed' });
124
+ }
125
+ const verdict = await verifyAuthkitWebhook({ body, signature: ctx.request.header('x-authkit-signature') }, { secret: options.secret, toleranceSec: options.toleranceSec, replayStore });
126
+ if (!verdict.ok) {
127
+ if (verdict.reason === 'replay')
128
+ return ctx.response.noContent();
129
+ if (verdict.reason === 'signature') {
130
+ return ctx.response.unauthorized({ error: 'invalid_signature' });
131
+ }
132
+ return ctx.response.badRequest({ error: verdict.reason });
133
+ }
134
+ try {
135
+ if (options.onEvent)
136
+ await options.onEvent(verdict.event);
137
+ }
138
+ catch {
139
+ // A failing listener must not suppress the bus emit: the delivery was valid and its id
140
+ // is already claimed, so a redelivery would be ignored.
141
+ }
142
+ if (options.emit !== false)
143
+ emitAuthkitEvent(verdict.event);
144
+ return ctx.response.noContent();
145
+ };
146
+ }
147
+ /**
148
+ * Mounts `POST path` (default `/authkit/webhook`) with {@link authkitWebhookHandler}:
149
+ *
150
+ * ```ts
151
+ * // start/routes.ts
152
+ * registerAuthkitWebhook(router, { secret: env.get('AUTHKIT_WEBHOOK_SECRET') })
153
+ * ```
154
+ *
155
+ * Point authkit-server's `events.webhook` at `https://<this app>/authkit/webhook` with the
156
+ * same secret, and add the path to shield's `csrf.exceptRoutes`.
157
+ */
158
+ export function registerAuthkitWebhook(router, options) {
159
+ const { path = '/authkit/webhook', ...rest } = options;
160
+ router.post(path, authkitWebhookHandler(rest)).as('authkit.webhook');
161
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adonis-agora/authkit-client",
3
- "version": "1.0.0-rc.6",
3
+ "version": "1.0.0-rc.7",
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",
@@ -77,11 +77,11 @@
77
77
  }
78
78
  },
79
79
  "dependencies": {
80
- "@adonis-agora/authkit-core": "1.0.0-rc.2",
80
+ "@adonis-agora/authkit-core": "1.0.0-rc.3",
81
81
  "jose": "6.2.16"
82
82
  },
83
83
  "devDependencies": {
84
- "@adonis-agora/authkit-server": "1.0.0-rc.12",
84
+ "@adonis-agora/authkit-server": "1.0.0-rc.24",
85
85
  "@adonis-agora/resilience": "0.4.3",
86
86
  "@adonisjs/auth": "10.1.0",
87
87
  "@adonisjs/core": "7.6.0",
@@ -206,6 +206,30 @@ validated `{ sid, sub }` (store runs first, then the hook).
206
206
 
207
207
  Source: `src/define_config.ts` (`BackchannelLogoutInput`), README.
208
208
 
209
+ ### Pattern 5 — authkit-server on another server
210
+
211
+ Nothing on this server may need authkit-server in-process. The remote-safe helpers:
212
+
213
+ ```ts
214
+ import {
215
+ authkitTenantToken, // tenant side: client_credentials token for one resource; endpoint from discovery only
216
+ authkitTenantVerifier, // control-plane side: (request) => { partition, environment?, capabilities, clientId, expiresAt } | null
217
+ registerAuthkitWebhook, // signed authkit events → diagnostics bus as agora:authkit:<type>
218
+ bridgeAuthkitToken, // bearer token (MCP/API) → ctx.authkit, so authkitDashboardAuthorize admits it
219
+ } from '@adonis-agora/authkit-client'
220
+
221
+ verifyTenant: authkitTenantVerifier({ issuer: env.get('AUTHKIT_ISSUER'), resource: 'urn:agora:flags' })
222
+ registerAuthkitWebhook(router, { secret: env.get('AUTHKIT_WEBHOOK_SECRET') }) // + csrf.exceptRoutes
223
+ ```
224
+
225
+ `registerOidcClient` already announces `POST /auth/logout` as the consoles' sign-out (skipped
226
+ when authkit-server runs in the app), the provider installs org labels (`orgLabels`), and
227
+ `identity.actor` carries the impersonating admin (`act`). Resources are `urn:agora:<product>`;
228
+ never hard-code `/token` or `/oauth/token`.
229
+
230
+ Source: `src/tenant_verifier.ts`, `src/webhook_receiver.ts`, `src/identity_bridge.ts`,
231
+ `src/org_labels.ts`; docs `topologies.mdx`.
232
+
209
233
  ## Common mistakes
210
234
 
211
235
  ### HIGH — Session middleware running after `authkit_context_middleware`