@adonis-agora/authkit-client 1.0.0-rc.3 → 1.0.0-rc.5

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 CHANGED
@@ -1,5 +1,26 @@
1
1
  # @adonis-agora/authkit-client
2
2
 
3
+ ## 1.0.0-rc.5
4
+
5
+ ### Minor Changes
6
+
7
+ - [#471](https://github.com/DavideCarvalho/adonis-agora-authkit/pull/471) [`66ccc36`](https://github.com/DavideCarvalho/adonis-agora-authkit/commit/66ccc365dbbd7d9d5bf22dba7aad196aff9f5d4b) Thanks [@DavideCarvalho](https://github.com/DavideCarvalho)! - Account console extensions from another app. An app that signs in through an authkit IdP can now add pages to the IdP's account console:
8
+
9
+ - authkit-client binds the same `authkit.accountExtensions` registry, so packages register their pages the same way in both setups. With `accountConsole: { remote: true }` the app serves them at `/.well-known/agora-account-extensions` (mounted by `registerOidcClient`, or `registerAccountExtensionsEndpoint(router)`), only to requests the IdP signed for its client id with the scope `authkit:account-extensions`. `accountConsole.subject` maps the IdP account to the app's user.
10
+ - authkit-server: `accountConsole.remote: [{ clientId, url }]` lists those apps; their pages show in the console next to the in-process ones, with the same sign-in, sudo, confirmation, rate limit and audit rules.
11
+ - The contract types and validation now live in authkit-core (still exported from `@adonis-agora/authkit-server/account-extension`).
12
+
13
+ ### Patch Changes
14
+
15
+ - Updated dependencies [[`66ccc36`](https://github.com/DavideCarvalho/adonis-agora-authkit/commit/66ccc365dbbd7d9d5bf22dba7aad196aff9f5d4b)]:
16
+ - @adonis-agora/authkit-core@1.0.0-rc.1
17
+
18
+ ## 1.0.0-rc.4
19
+
20
+ ### Minor Changes
21
+
22
+ - [#465](https://github.com/DavideCarvalho/adonis-agora-authkit/pull/465) [`6bd9575`](https://github.com/DavideCarvalho/adonis-agora-authkit/commit/6bd95759752c0fac14a061875abebb38c0e238b7) Thanks [@DavideCarvalho](https://github.com/DavideCarvalho)! - The active organization is published as the request's tenant in `@adonis-agora/context`: `Context.tenant()` returns `{ id, kind: 'organization', source: 'authkit', slug, role }` (and `tenantId` as before). New client option `tenancy`: `eager` resolves the session at request start (default on when `config/context.ts` enables tenancy and the resolver is `jwt`), `personal` makes the user the tenant when there is no active organization (off by default), and `resolve` maps the session to your own (optionally nested) tenants. A tenant the app sets itself with `Context.setTenant` is never overwritten. authkit-server ships `authkitTenantLabels()`, a label resolver for `config/context.ts` → `tenancy.label` that shows organization names in the consoles, and account stores gain the optional `findOrgsByIds` (implemented by the Lucid store).
23
+
3
24
  ## 1.0.0-rc.3
4
25
 
5
26
  ### Patch Changes
package/build/index.d.ts CHANGED
@@ -2,12 +2,14 @@ export type { Identity } from '@adonis-agora/authkit-core';
2
2
  export { configure } from './commands/configure.js';
3
3
  export type { AuthkitClientLogger, AuthkitClientManagerDeps, } from './providers/authkit_client_provider.js';
4
4
  export { AuthkitClientManager } from './providers/authkit_client_provider.js';
5
+ export type { AccountExtensionsAnswer, AccountExtensionsVerifier, ServeAccountExtensionsInput, } from './src/account_extensions.js';
6
+ export { ACCOUNT_EXTENSIONS_BINDING, ClientAccountExtensionRegistry, serveAccountExtensions, verifyAccountExtensionsToken, } from './src/account_extensions.js';
5
7
  export { Authenticator } from './src/authenticator.js';
6
8
  export type { AuthorizationRequest, CompleteAuthorizationParams, CompletedAuthorization, CreateAuthorizationRequestParams, OidcCallbackErrorCode, PendingAuthorization, } from './src/authorization.js';
7
9
  export { completeAuthorization, createAuthorizationRequest, OidcCallbackError, } from './src/authorization.js';
8
10
  export type { SessionIndex, SessionIndexEntry, ValidatedLogoutToken, ValidateLogoutTokenOptions, } from './src/backchannel_logout.js';
9
11
  export { BACKCHANNEL_LOGOUT_EVENT, InMemorySessionIndex, InvalidLogoutTokenError, validateLogoutToken, } from './src/backchannel_logout.js';
10
- export type { BackchannelLogoutCallback, BackchannelLogoutInput, ClientConfigInput, ResolvedClientConfig, } from './src/define_config.js';
12
+ export type { BackchannelLogoutCallback, BackchannelLogoutInput, ClientAccountConsoleConfig, ClientConfigInput, ResolvedClientConfig, } from './src/define_config.js';
11
13
  export { defineConfig, resolvers } from './src/define_config.js';
12
14
  export type { DiscoverOptions, OidcEndpoints } from './src/discovery.js';
13
15
  export { conventionEndpoints, discoverEndpoints, OidcDiscoveryError } from './src/discovery.js';
@@ -30,10 +32,11 @@ export { default as AuthkitContextMiddleware } from './src/middleware/authkit_co
30
32
  export { default as BackchannelRevocationMiddleware } from './src/middleware/backchannel_revocation_middleware.js';
31
33
  export type { RequireOrgMiddlewareOptions } from './src/middleware/require_org_middleware.js';
32
34
  export { default as RequireOrgMiddleware } from './src/middleware/require_org_middleware.js';
35
+ export type { ClientTenancyOptions, ContextTenant, ContextTenantLink, TenantInput, } from './src/observability/context_bridge.js';
33
36
  export type { AuthorizeParams, EndSessionParams, ExchangeParams, RefreshParams, } from './src/oidc_login.js';
34
37
  export { buildAuthorizeUrl, buildEndSessionUrl, exchangeCode, exchangeToken, generatePkce, refreshTokens, } from './src/oidc_login.js';
35
38
  export type { PostLoginRedirects, RegisterOidcClientOptions, } from './src/register_oidc_client.js';
36
- export { registerOidcClient } from './src/register_oidc_client.js';
39
+ export { registerAccountExtensionsEndpoint, registerOidcClient, } from './src/register_oidc_client.js';
37
40
  export type { LucidRevocationStoreOptions, RevocationStore, } from './src/revocation/revocation_store.js';
38
41
  export { DEFAULT_REVOCATION_TABLE, lucidRevocationStore, } from './src/revocation/revocation_store.js';
39
42
  export { AuthkitTenantToken, type AuthkitTenantTokenFn, type AuthkitTenantTokenOptions, authkitTenantToken, TenantTokenError, } from './src/tenant_token.js';
package/build/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  export { configure } from './commands/configure.js';
2
2
  export { AuthkitClientManager } from './providers/authkit_client_provider.js';
3
+ export { ACCOUNT_EXTENSIONS_BINDING, ClientAccountExtensionRegistry, serveAccountExtensions, verifyAccountExtensionsToken, } from './src/account_extensions.js';
3
4
  export { Authenticator } from './src/authenticator.js';
4
5
  export { completeAuthorization, createAuthorizationRequest, OidcCallbackError, } from './src/authorization.js';
5
6
  export { BACKCHANNEL_LOGOUT_EVENT, InMemorySessionIndex, InvalidLogoutTokenError, validateLogoutToken, } from './src/backchannel_logout.js';
@@ -17,7 +18,7 @@ export { default as AuthkitContextMiddleware } from './src/middleware/authkit_co
17
18
  export { default as BackchannelRevocationMiddleware } from './src/middleware/backchannel_revocation_middleware.js';
18
19
  export { default as RequireOrgMiddleware } from './src/middleware/require_org_middleware.js';
19
20
  export { buildAuthorizeUrl, buildEndSessionUrl, exchangeCode, exchangeToken, generatePkce, refreshTokens, } from './src/oidc_login.js';
20
- export { registerOidcClient } from './src/register_oidc_client.js';
21
+ export { registerAccountExtensionsEndpoint, registerOidcClient, } from './src/register_oidc_client.js';
21
22
  export { DEFAULT_REVOCATION_TABLE, lucidRevocationStore, } from './src/revocation/revocation_store.js';
22
23
  export { AuthkitTenantToken, authkitTenantToken, TenantTokenError, } from './src/tenant_token.js';
23
24
  export { createUserinfoResolver, identityToUser, } from './src/user_resolvers.js';
@@ -97,6 +97,12 @@ export declare class AuthkitClientManager {
97
97
  * Sempre seta `Cache-Control: no-store` (exigência do spec).
98
98
  */
99
99
  handleBackchannelLogout(ctx: HttpContext): Promise<unknown>;
100
+ /**
101
+ * Answers the IdP's account console (`GET`/`POST /.well-known/agora-account-extensions`): the
102
+ * manifest of this app's account extensions, or one call to them. 404 unless
103
+ * `accountConsole.remote` is on. Mounted by {@link registerOidcClient}.
104
+ */
105
+ handleAccountExtensions(ctx: HttpContext): Promise<unknown>;
100
106
  /**
101
107
  * Impersona `requestedSubject` via token-exchange (RFC 8693): guarda o token set
102
108
  * atual e o substitui pelo do alvo. Encapsula o malabarismo de session keys que o
@@ -162,6 +168,13 @@ export declare class AuthkitClientManager {
162
168
  * sobrevive à sessão que a parqueou.
163
169
  */
164
170
  startSession(ctx: HttpContext, tokenSet: TokenSet): void;
171
+ /**
172
+ * Whether `authkit_context_middleware` resolves the session at request start, so
173
+ * the tenant is in `@adonis-agora/context` before any handler runs. `tenancy.eager`
174
+ * when set; otherwise `true` only when context tenancy is enabled and the resolver
175
+ * is `resolvers.jwt()` (local verify, no network).
176
+ */
177
+ eagerTenancy(): boolean;
165
178
  /**
166
179
  * The request's {@link Authenticator}. `options.resolver` replaces the configured
167
180
  * resolver for this authenticator only (e.g. a guard for bearer-token API routes).
@@ -5,12 +5,14 @@ import { randomUUID } from 'node:crypto';
5
5
  import { configProvider } from '@adonisjs/core';
6
6
  import { RuntimeException } from '@adonisjs/core/exceptions';
7
7
  import { decodeJwt } from 'jose';
8
+ import { ACCOUNT_EXTENSIONS_BINDING, ClientAccountExtensionRegistry, serveAccountExtensions, } from '../src/account_extensions.js';
8
9
  import { Authenticator } from '../src/authenticator.js';
9
10
  import { assertCallbackBelongs, completeAuthorization, createAuthorizationRequest, } from '../src/authorization.js';
10
11
  import { validateLogoutToken } from '../src/backchannel_logout.js';
11
12
  import { conventionEndpoints, discoverEndpoints, mergeEndpoints, } from '../src/discovery.js';
12
13
  import { validateIdToken, } from '../src/id_token.js';
13
14
  import { getJwks } from '../src/jwks.js';
15
+ import { contextTenancyEnabled } from '../src/observability/context_bridge.js';
14
16
  import { exchangeToken, refreshTokens } from '../src/oidc_login.js';
15
17
  /** Margem (ms) antes do `expiresAt` em que o access token é renovado proativamente. */
16
18
  const REFRESH_SKEW_MS = 60_000;
@@ -322,6 +324,44 @@ export class AuthkitClientManager {
322
324
  });
323
325
  return {};
324
326
  }
327
+ /**
328
+ * Answers the IdP's account console (`GET`/`POST /.well-known/agora-account-extensions`): the
329
+ * manifest of this app's account extensions, or one call to them. 404 unless
330
+ * `accountConsole.remote` is on. Mounted by {@link registerOidcClient}.
331
+ */
332
+ async handleAccountExtensions(ctx) {
333
+ const { request, response } = ctx;
334
+ response.header('Cache-Control', 'no-store');
335
+ const settings = this.config.accountConsole;
336
+ if (!settings?.remote)
337
+ return response.status(404).send({ error: { code: 'not_found' } });
338
+ let idp;
339
+ try {
340
+ idp = await this.#idp();
341
+ }
342
+ catch (error) {
343
+ this.#logger.warn({ err: error, issuer: this.config.issuer }, 'authkit-client: account extensions called while discovery is failing');
344
+ return response.status(503).send({ error: { code: 'temporarily_unavailable' } });
345
+ }
346
+ const resolver = ctx.containerResolver;
347
+ const registry = (await resolver.make(ACCOUNT_EXTENSIONS_BINDING));
348
+ const method = request.method();
349
+ const raw = method === 'POST' ? request.raw() : null;
350
+ const answer = await serveAccountExtensions({
351
+ method,
352
+ authorization: request.header('authorization'),
353
+ rawBody: typeof raw === 'string' ? raw : null,
354
+ extensions: typeof registry.extensions === 'function' ? registry.extensions() : [],
355
+ contract: registry.contract,
356
+ verifier: { issuer: idp.endpoints.issuer, clientId: this.config.clientId, keys: idp.keys },
357
+ config: settings,
358
+ make: (binding) => resolver.make(binding),
359
+ log: (message) => this.#logger.warn({}, message),
360
+ });
361
+ for (const [name, value] of Object.entries(answer.headers ?? {}))
362
+ response.header(name, value);
363
+ return response.status(answer.status).send(answer.body);
364
+ }
325
365
  /** Chave de sessão onde o token set ORIGINAL fica guardado durante a impersonação. */
326
366
  get #impersonatorKey() {
327
367
  return `${this.config.sessionKey}_impersonator`;
@@ -457,6 +497,18 @@ export class AuthkitClientManager {
457
497
  session.forget(this.#impersonatorKey);
458
498
  session.put(this.config.sessionKey, tokenSet);
459
499
  }
500
+ /**
501
+ * Whether `authkit_context_middleware` resolves the session at request start, so
502
+ * the tenant is in `@adonis-agora/context` before any handler runs. `tenancy.eager`
503
+ * when set; otherwise `true` only when context tenancy is enabled and the resolver
504
+ * is `resolvers.jwt()` (local verify, no network).
505
+ */
506
+ eagerTenancy() {
507
+ const eager = this.config.tenancy?.eager;
508
+ if (typeof eager === 'boolean')
509
+ return eager;
510
+ return this.config.resolverFactory?.kind === 'jwt' && contextTenancyEnabled();
511
+ }
460
512
  /**
461
513
  * The request's {@link Authenticator}. `options.resolver` replaces the configured
462
514
  * resolver for this authenticator only (e.g. a guard for bearer-token API routes).
@@ -467,6 +519,7 @@ export class AuthkitClientManager {
467
519
  return new Authenticator(ctx, {
468
520
  resolver,
469
521
  resolveUser: this.config.resolveUser,
522
+ tenancy: this.config.tenancy,
470
523
  getAccessToken: () => {
471
524
  const tokenSet = ctx.session?.get(sessionKey);
472
525
  return tokenSet?.accessToken;
@@ -497,6 +550,14 @@ export default class AuthkitClientProvider {
497
550
  // bound by class, would silently get the real one. Not cached here, so a `swap` of the class
498
551
  // also reaches the string key, even after its first use.
499
552
  this.app.container.bind('authkit.client', (resolver) => resolver.make(AuthkitClientManager));
553
+ // Account console extensions register here from their `boot()`, exactly as next to
554
+ // authkit-server. When authkit-server is in this app too, its registry is the one bound.
555
+ if (!this.app.container.hasBinding(ACCOUNT_EXTENSIONS_BINDING)) {
556
+ const registry = new ClientAccountExtensionRegistry({
557
+ production: () => this.app.inProduction === true,
558
+ });
559
+ this.app.container.singleton(ACCOUNT_EXTENSIONS_BINDING, async () => registry);
560
+ }
500
561
  }
501
562
  }
502
563
  /** `sub` of a stored id_token, or undefined when there is none / it is unreadable. */
@@ -0,0 +1,49 @@
1
+ import { type AccountExtension, BaseAccountExtensionRegistry } from '@adonis-agora/authkit-core';
2
+ import { type JWTPayload, type JWTVerifyGetKey } from 'jose';
3
+ import type { ClientAccountConsoleConfig } from './define_config.js';
4
+ /** The container binding contributors register into (same name as authkit-server's). */
5
+ export declare const ACCOUNT_EXTENSIONS_BINDING = "authkit.accountExtensions";
6
+ /**
7
+ * The account extensions registry of an app whose IdP runs elsewhere. Contributors register
8
+ * exactly as they would next to authkit-server; this app then serves the pages to the IdP.
9
+ */
10
+ export declare class ClientAccountExtensionRegistry extends BaseAccountExtensionRegistry {
11
+ }
12
+ /** What verifying a remote-extension token needs. */
13
+ export interface AccountExtensionsVerifier {
14
+ issuer: string;
15
+ clientId: string;
16
+ keys: JWTVerifyGetKey;
17
+ }
18
+ /** A refusal: `status` plus the JSON body to send. */
19
+ export interface AccountExtensionsAnswer {
20
+ status: number;
21
+ body: unknown;
22
+ headers?: Record<string, string>;
23
+ }
24
+ /** Verifies the IdP's token: signature, issuer, audience, `typ`, lifetime and scope. */
25
+ export declare function verifyAccountExtensionsToken(authorization: string | undefined, verifier: AccountExtensionsVerifier): Promise<{
26
+ ok: true;
27
+ claims: JWTPayload;
28
+ } | {
29
+ ok: false;
30
+ answer: AccountExtensionsAnswer;
31
+ }>;
32
+ export interface ServeAccountExtensionsInput {
33
+ method: string;
34
+ authorization: string | undefined;
35
+ /** The exact POST body (its hash is signed by the IdP). */
36
+ rawBody: string | null;
37
+ extensions: readonly AccountExtension[];
38
+ contract: number;
39
+ verifier: AccountExtensionsVerifier;
40
+ config: ClientAccountConsoleConfig;
41
+ make: (binding: string) => Promise<any>;
42
+ log?: (message: string) => void;
43
+ }
44
+ /**
45
+ * Answers the IdP: `GET` → the manifest, `POST` → one call (visible, load or action). Only a token
46
+ * the IdP signed for this app (audience = client id, scope `authkit:account-extensions`) gets in;
47
+ * a POST must also match the signed body hash and name the token's account.
48
+ */
49
+ export declare function serveAccountExtensions(input: ServeAccountExtensionsInput): Promise<AccountExtensionsAnswer>;
@@ -0,0 +1,105 @@
1
+ import { ACCOUNT_EXTENSIONS_MANIFEST_SUBJECT, ACCOUNT_EXTENSIONS_SCOPE, ACCOUNT_EXTENSIONS_TOKEN_MAX_TTL, ACCOUNT_EXTENSIONS_TOKEN_TYP, accountExtensionsBodyHash, BaseAccountExtensionRegistry, buildAccountExtensionsManifest, parseAccountExtensionCall, runAccountExtensionCall, } from '@adonis-agora/authkit-core';
2
+ import { jwtVerify } from 'jose';
3
+ import { DEFAULT_ALGS } from './jwks.js';
4
+ /** The container binding contributors register into (same name as authkit-server's). */
5
+ export const ACCOUNT_EXTENSIONS_BINDING = 'authkit.accountExtensions';
6
+ /**
7
+ * The account extensions registry of an app whose IdP runs elsewhere. Contributors register
8
+ * exactly as they would next to authkit-server; this app then serves the pages to the IdP.
9
+ */
10
+ export class ClientAccountExtensionRegistry extends BaseAccountExtensionRegistry {
11
+ }
12
+ const refuse = (status, code, message) => ({
13
+ status,
14
+ body: { error: { code, message } },
15
+ ...(status === 401
16
+ ? {
17
+ headers: {
18
+ 'WWW-Authenticate': `Bearer error="${code}", scope="${ACCOUNT_EXTENSIONS_SCOPE}"`,
19
+ },
20
+ }
21
+ : {}),
22
+ });
23
+ /** Verifies the IdP's token: signature, issuer, audience, `typ`, lifetime and scope. */
24
+ export async function verifyAccountExtensionsToken(authorization, verifier) {
25
+ const match = /^Bearer\s+(\S+)$/i.exec(authorization ?? '');
26
+ if (!match?.[1]) {
27
+ return { ok: false, answer: refuse(401, 'invalid_token', 'A bearer token is required.') };
28
+ }
29
+ let claims;
30
+ try {
31
+ ({ payload: claims } = await jwtVerify(match[1], verifier.keys, {
32
+ issuer: verifier.issuer,
33
+ audience: verifier.clientId,
34
+ typ: ACCOUNT_EXTENSIONS_TOKEN_TYP,
35
+ algorithms: DEFAULT_ALGS,
36
+ maxTokenAge: ACCOUNT_EXTENSIONS_TOKEN_MAX_TTL,
37
+ requiredClaims: ['exp', 'iat', 'sub'],
38
+ }));
39
+ }
40
+ catch {
41
+ return { ok: false, answer: refuse(401, 'invalid_token', 'The token is not valid.') };
42
+ }
43
+ if (claims.exp - claims.iat > ACCOUNT_EXTENSIONS_TOKEN_MAX_TTL) {
44
+ return { ok: false, answer: refuse(401, 'invalid_token', 'The token lives too long.') };
45
+ }
46
+ const scopes = typeof claims.scope === 'string' ? claims.scope.split(' ') : [];
47
+ if (!scopes.includes(ACCOUNT_EXTENSIONS_SCOPE)) {
48
+ return {
49
+ ok: false,
50
+ answer: refuse(403, 'insufficient_scope', `The token lacks "${ACCOUNT_EXTENSIONS_SCOPE}".`),
51
+ };
52
+ }
53
+ return { ok: true, claims };
54
+ }
55
+ /**
56
+ * Answers the IdP: `GET` → the manifest, `POST` → one call (visible, load or action). Only a token
57
+ * the IdP signed for this app (audience = client id, scope `authkit:account-extensions`) gets in;
58
+ * a POST must also match the signed body hash and name the token's account.
59
+ */
60
+ export async function serveAccountExtensions(input) {
61
+ const verified = await verifyAccountExtensionsToken(input.authorization, input.verifier);
62
+ if (!verified.ok)
63
+ return verified.answer;
64
+ const { claims } = verified;
65
+ if (input.method === 'GET') {
66
+ if (claims.sub !== ACCOUNT_EXTENSIONS_MANIFEST_SUBJECT) {
67
+ return refuse(403, 'invalid_token', 'This token is not for the manifest.');
68
+ }
69
+ return { status: 200, body: buildAccountExtensionsManifest(input.extensions, input.contract) };
70
+ }
71
+ if (input.method !== 'POST') {
72
+ return {
73
+ status: 405,
74
+ body: { error: { code: 'method_not_allowed', message: 'GET or POST.' } },
75
+ };
76
+ }
77
+ if (input.rawBody === null || claims.bh !== accountExtensionsBodyHash(input.rawBody)) {
78
+ return refuse(403, 'invalid_token', 'The token does not match the body.');
79
+ }
80
+ let raw;
81
+ try {
82
+ raw = JSON.parse(input.rawBody);
83
+ }
84
+ catch {
85
+ raw = null;
86
+ }
87
+ const call = parseAccountExtensionCall(raw);
88
+ if (!call) {
89
+ return { status: 400, body: { error: { code: 'invalid_request', message: 'Bad call.' } } };
90
+ }
91
+ if (call.account.id !== claims.sub) {
92
+ return refuse(403, 'invalid_token', 'The token is for another account.');
93
+ }
94
+ return runAccountExtensionCall(input.extensions, call, {
95
+ ...(input.config.subject ? { subject: input.config.subject } : {}),
96
+ make: input.make,
97
+ ...(input.config.loadTimeoutMs !== undefined
98
+ ? { loadTimeoutMs: input.config.loadTimeoutMs }
99
+ : {}),
100
+ ...(input.config.actionTimeoutMs !== undefined
101
+ ? { actionTimeoutMs: input.config.actionTimeoutMs }
102
+ : {}),
103
+ ...(input.log ? { log: input.log } : {}),
104
+ });
105
+ }
@@ -1,6 +1,7 @@
1
1
  import type { Identity, SessionResolver } from '@adonis-agora/authkit-core';
2
2
  import { type MetricsRecorder } from '@adonis-agora/authkit-core';
3
3
  import type { HttpContext } from '@adonisjs/core/http';
4
+ import { type ClientTenancyOptions } from './observability/context_bridge.js';
4
5
  /** Contexto opcional repassado a `resolveUser` (extensão backward-compatible). */
5
6
  export interface ResolveUserContext {
6
7
  accessToken?: string;
@@ -10,6 +11,8 @@ export interface AuthenticatorDeps {
10
11
  resolveUser?: (identity: Identity, context: ResolveUserContext) => Promise<unknown>;
11
12
  /** lê o access token do token set da sessão (opcional; usado por resolvers userinfo) */
12
13
  getAccessToken?: () => string | null | undefined;
14
+ /** Opções de tenancy (`config/authkit_client.ts` → `tenancy`) usadas ao publicar o tenant. */
15
+ tenancy?: ClientTenancyOptions;
13
16
  }
14
17
  /**
15
18
  * Autenticador do request. O parâmetro `TUser` é o tipo do usuário do app que `resolveUser`
@@ -36,7 +36,7 @@ export class Authenticator {
36
36
  // (best-effort, no-op sem o slot `@agora/context:set`). Apenas no caminho
37
37
  // de sucesso — não força resolução para requests anônimas.
38
38
  if (this.#identity)
39
- populateContext(this.#identity);
39
+ populateContext(this.#identity, this.deps.tenancy);
40
40
  }
41
41
  return this.#identity;
42
42
  }
@@ -1,7 +1,8 @@
1
- import type { Identity } from '@adonis-agora/authkit-core';
1
+ import type { Identity, RunAccountExtensionCallOptions } from '@adonis-agora/authkit-core';
2
2
  import type { SessionIndex } from './backchannel_logout.js';
3
3
  import type { OidcEndpoints } from './discovery.js';
4
4
  import type { ResiliencePolicy } from './http/resilient_fetch.js';
5
+ import type { ClientTenancyOptions } from './observability/context_bridge.js';
5
6
  import { type ResolverFactory, resolvers } from './resolvers/factory.js';
6
7
  import type { RevocationStore } from './revocation/revocation_store.js';
7
8
  export { resolvers };
@@ -22,6 +23,24 @@ export interface BackchannelLogoutInput {
22
23
  /** Store que persiste/consulta revogações (ex.: `lucidRevocationStore({ connection: 'auth' })`). */
23
24
  store: RevocationStore;
24
25
  }
26
+ /** `accountConsole` in `config/authkit_client.ts`: pages this app serves for the IdP's account console. */
27
+ export interface ClientAccountConsoleConfig {
28
+ /**
29
+ * Serve this app's account console extensions to the IdP over
30
+ * `/.well-known/agora-account-extensions` (the IdP lists this app in its own
31
+ * `accountConsole.remote`). Default `false`.
32
+ */
33
+ remote?: boolean;
34
+ /**
35
+ * Maps the IdP account to this app's subject (what billing, media and agents key on). Every
36
+ * extension receives it as `ctx.subject`. Default `{ type: 'User', id: account.id }`.
37
+ */
38
+ subject?: RunAccountExtensionCallOptions['subject'];
39
+ /** Budget of one page's `load()` / `visible()`, in ms. Default 5000. */
40
+ loadTimeoutMs?: number;
41
+ /** Budget of one action's `handle()`, in ms. Default 15000. */
42
+ actionTimeoutMs?: number;
43
+ }
25
44
  export interface ClientConfigInput {
26
45
  issuer: string;
27
46
  clientId: string;
@@ -76,6 +95,14 @@ export interface ClientConfigInput {
76
95
  };
77
96
  /** Per-field endpoint overrides; always win over discovery. */
78
97
  endpoints?: Partial<OidcEndpoints>;
98
+ /**
99
+ * How the session's tenant is published into `@adonis-agora/context` (the active
100
+ * organization by default). See {@link ClientTenancyOptions}: `eager`, `personal`,
101
+ * `resolve`. No effect without `@adonis-agora/context`.
102
+ */
103
+ tenancy?: ClientTenancyOptions;
104
+ /** Account console pages this app serves to the IdP. See {@link ClientAccountConsoleConfig}. */
105
+ accountConsole?: ClientAccountConsoleConfig;
79
106
  }
80
107
  export interface ResolvedClientConfig {
81
108
  issuer: string;
@@ -101,5 +128,9 @@ export interface ResolvedClientConfig {
101
128
  };
102
129
  /** Endpoint overrides. See {@link ClientConfigInput.endpoints}. */
103
130
  endpoints?: Partial<OidcEndpoints>;
131
+ /** Tenancy options. See {@link ClientConfigInput.tenancy}. */
132
+ tenancy?: ClientTenancyOptions;
133
+ /** See {@link ClientConfigInput.accountConsole}. */
134
+ accountConsole?: ClientAccountConsoleConfig;
104
135
  }
105
136
  export declare function defineConfig(config: ClientConfigInput): import("@adonisjs/core/types").ConfigProvider<ResolvedClientConfig>;
@@ -35,6 +35,8 @@ export function defineConfig(config) {
35
35
  ? config.discovery
36
36
  : {},
37
37
  endpoints: config.endpoints,
38
+ tenancy: config.tenancy ?? {},
39
+ ...(config.accountConsole ? { accountConsole: config.accountConsole } : {}),
38
40
  };
39
41
  });
40
42
  }
@@ -21,6 +21,11 @@ import type { NextFn } from '@adonisjs/core/types/http';
21
21
  *
22
22
  * Sem `@adonisjs/auth`, é ele sozinho que dá a sessão ao app: `ctx.authkit`
23
23
  * em qualquer rota, e `auth_middleware` para exigir login.
24
+ *
25
+ * With tenancy eager (`tenancy.eager`, default on when `config/context.ts` enables
26
+ * tenancy and the resolver is `jwt`), it also resolves the session here, so the
27
+ * request's tenant is in `@adonis-agora/context` before any handler, job dispatch
28
+ * or console query runs.
24
29
  */
25
30
  export default class AuthkitContextMiddleware {
26
31
  handle(ctx: HttpContext, next: NextFn): Promise<any>;
@@ -22,10 +22,27 @@ import { getAuthkit } from '../host/request_authenticator.js';
22
22
  *
23
23
  * Sem `@adonisjs/auth`, é ele sozinho que dá a sessão ao app: `ctx.authkit`
24
24
  * em qualquer rota, e `auth_middleware` para exigir login.
25
+ *
26
+ * With tenancy eager (`tenancy.eager`, default on when `config/context.ts` enables
27
+ * tenancy and the resolver is `jwt`), it also resolves the session here, so the
28
+ * request's tenant is in `@adonis-agora/context` before any handler, job dispatch
29
+ * or console query runs.
25
30
  */
26
31
  export default class AuthkitContextMiddleware {
27
32
  async handle(ctx, next) {
28
- await getAuthkit(ctx);
33
+ const authkit = await getAuthkit(ctx);
34
+ if (await eagerTenancy(ctx)) {
35
+ try {
36
+ await authkit.getIdentity();
37
+ }
38
+ catch {
39
+ // Same as lazy: the failure surfaces when the app asks for the session.
40
+ }
41
+ }
29
42
  return next();
30
43
  }
31
44
  }
45
+ async function eagerTenancy(ctx) {
46
+ const manager = (await ctx.containerResolver.make('authkit.client'));
47
+ return typeof manager.eagerTenancy === 'function' && manager.eagerTenancy();
48
+ }
@@ -1,8 +1,64 @@
1
1
  import type { Identity } from '@adonis-agora/authkit-core';
2
+ /**
3
+ * The tenant authkit publishes into `@agora/context` (structural copy of the
4
+ * context's `TenantRef`). `chain` lists the active path from the outermost
5
+ * tenant to this one (this one last) when tenants nest; absent for a flat tenant.
6
+ */
7
+ export interface ContextTenant {
8
+ id: string;
9
+ kind: 'organization' | 'user' | (string & {});
10
+ source: 'authkit' | 'app' | 'carrier' | 'legacy' | (string & {});
11
+ slug?: string;
12
+ role?: string;
13
+ chain?: ContextTenantLink[];
14
+ }
15
+ /** One level of a nested tenant chain. */
16
+ export interface ContextTenantLink {
17
+ id: string;
18
+ kind: string;
19
+ slug?: string;
20
+ role?: string;
21
+ }
22
+ /** What `tenancy.resolve` may return: one tenant, a chain (outermost first, active last), or none. */
23
+ export type TenantInput = Omit<ContextTenantLink, 'kind'> & {
24
+ kind?: string;
25
+ };
26
+ /** Client-side tenancy options (`config/authkit_client.ts` → `tenancy`). */
27
+ export interface ClientTenancyOptions {
28
+ /**
29
+ * Resolve the session (and so publish the tenant) at the start of every request
30
+ * that runs `authkit_context_middleware`, instead of on first use. Default: `true`
31
+ * when `config/context.ts` enables tenancy AND the resolver is `resolvers.jwt()`
32
+ * (local verify, cheap); `false` otherwise.
33
+ */
34
+ eager?: boolean;
35
+ /**
36
+ * With no active organization, publish the user as the tenant (`kind: 'user'`),
37
+ * so personal workspaces are tenants too. Default: `false` (orgless requests
38
+ * have no tenant).
39
+ */
40
+ personal?: boolean;
41
+ /**
42
+ * Map the session to your own tenant model (e.g. clinic → unit). Return one
43
+ * tenant, a chain (outermost first, active last), or `null` for no tenant;
44
+ * return `undefined` to keep authkit's default (the active organization).
45
+ */
46
+ resolve?: (identity: Identity) => TenantInput | TenantInput[] | null | undefined;
47
+ }
48
+ /**
49
+ * `true` when the installed `@adonis-agora/context` has tenancy enabled
50
+ * (`config/context.ts` → `tenancy.enabled`). `false` without context, or with a
51
+ * context version that predates tenancy.
52
+ */
53
+ export declare function contextTenancyEnabled(): boolean;
54
+ /** The tenant authkit would publish for `identity` under `options`. */
55
+ export declare function tenantFor(identity: Identity, options?: ClientTenancyOptions): ContextTenant | null;
2
56
  /**
3
57
  * Popula o contexto do Agora a partir de uma identidade recém-resolvida:
4
58
  * `userRef` ({ type: 'user', id: userId }), os `globalRoles` da identidade e,
5
- * quando presente nas claims, `tenantId`. Best-effort: qualquer erro é engolido
6
- * e nada acontece sem o slot.
59
+ * quando há um tenant, `tenant` (id, kind, slug, role, e `chain` quando aninhado)
60
+ * mais `tenantId`. Um tenant que o próprio app já definiu nesta request
61
+ * (`Context.setTenant`, `source: 'app'`) nunca é sobrescrito. Best-effort:
62
+ * qualquer erro é engolido e nada acontece sem o slot.
7
63
  */
8
- export declare function populateContext(identity: Identity): void;
64
+ export declare function populateContext(identity: Identity, options?: ClientTenancyOptions): void;
@@ -1,11 +1,44 @@
1
- import { deriveOrgId } from '@adonis-agora/authkit-core';
1
+ import { deriveOrgId, deriveOrgRole, deriveOrgSlug } from '@adonis-agora/authkit-core';
2
2
  /**
3
3
  * Ponte estrutural (best-effort) entre a sessão resolvida do authkit e o
4
- * contexto do Agora. Lê o slot global `@agora/context:set` de forma ESTRUTURAL —
5
- * nunca importa `@adonis-agora/context` — e degrada para no-op quando o context
6
- * não está instalado. Mesmo idioma da `diagnostics_bridge`.
4
+ * contexto do Agora. Lê os slots globais `@agora/context:set` e
5
+ * `@agora/context:accessor` de forma ESTRUTURAL — nunca importa
6
+ * `@adonis-agora/context` — e degrada para no-op quando o context não está
7
+ * instalado (ou é uma versão sem tenancy). Mesmo idioma da `diagnostics_bridge`.
7
8
  */
8
9
  const SET_SLOT = Symbol.for('@agora/context:set');
10
+ const ACCESSOR_SLOT = Symbol.for('@agora/context:accessor');
11
+ function accessor() {
12
+ const value = globalThis[ACCESSOR_SLOT];
13
+ return value && typeof value === 'object' ? value : undefined;
14
+ }
15
+ /**
16
+ * `true` when the installed `@adonis-agora/context` has tenancy enabled
17
+ * (`config/context.ts` → `tenancy.enabled`). `false` without context, or with a
18
+ * context version that predates tenancy.
19
+ */
20
+ export function contextTenancyEnabled() {
21
+ try {
22
+ const tenancy = accessor()?.tenancy;
23
+ return typeof tenancy === 'function' && tenancy()?.enabled === true;
24
+ }
25
+ catch {
26
+ return false;
27
+ }
28
+ }
29
+ /** The tenant an app set itself (`Context.setTenant`) on this request, if any. */
30
+ function appTenantAlreadySet() {
31
+ try {
32
+ const get = accessor()?.get;
33
+ if (typeof get !== 'function')
34
+ return false;
35
+ const current = get('tenant');
36
+ return !!current && current.source === 'app';
37
+ }
38
+ catch {
39
+ return false;
40
+ }
41
+ }
9
42
  /**
10
43
  * Deriva o tenant ativo da identidade. Prefere o `orgId` já resolvido (o caminho
11
44
  * normal — `buildIdentityFromClaims` o preenche); cai para as claims cruas
@@ -13,25 +46,80 @@ const SET_SLOT = Symbol.for('@agora/context:set');
13
46
  * populou. A tabela de claims aceitas vive em `@adonis-agora/authkit-core`
14
47
  * (`ORG_ID_CLAIMS`) para não divergir entre a identidade e esta ponte.
15
48
  */
16
- function deriveTenant(identity) {
17
- return identity.orgId ?? deriveOrgId(identity.raw) ?? undefined;
49
+ function organizationTenant(identity) {
50
+ const id = identity.orgId ?? deriveOrgId(identity.raw);
51
+ if (!id)
52
+ return null;
53
+ const slug = identity.orgSlug ?? deriveOrgSlug(identity.raw);
54
+ const role = identity.orgRole ?? deriveOrgRole(identity.raw);
55
+ return {
56
+ id,
57
+ kind: 'organization',
58
+ source: 'authkit',
59
+ ...(slug ? { slug } : {}),
60
+ ...(role ? { role } : {}),
61
+ };
62
+ }
63
+ function link(input, fallbackKind) {
64
+ if (!input || typeof input.id !== 'string' || input.id === '')
65
+ return null;
66
+ return {
67
+ id: input.id,
68
+ kind: typeof input.kind === 'string' && input.kind !== '' ? input.kind : fallbackKind,
69
+ ...(typeof input.slug === 'string' && input.slug ? { slug: input.slug } : {}),
70
+ ...(typeof input.role === 'string' && input.role ? { role: input.role } : {}),
71
+ };
72
+ }
73
+ function fromResolved(resolved) {
74
+ const inputs = Array.isArray(resolved) ? resolved : [resolved];
75
+ const links = inputs.map((input) => link(input, 'organization'));
76
+ if (links.length === 0 || links.some((l) => l === null))
77
+ return null;
78
+ const chain = links;
79
+ const active = chain[chain.length - 1];
80
+ return { ...active, source: 'authkit', ...(chain.length > 1 ? { chain } : {}) };
81
+ }
82
+ /** The tenant authkit would publish for `identity` under `options`. */
83
+ export function tenantFor(identity, options = {}) {
84
+ if (options.resolve) {
85
+ const resolved = options.resolve(identity);
86
+ if (resolved === null)
87
+ return null;
88
+ if (resolved !== undefined)
89
+ return fromResolved(resolved);
90
+ }
91
+ const org = organizationTenant(identity);
92
+ if (org)
93
+ return org;
94
+ if (options.personal === true && identity.userId) {
95
+ return { id: identity.userId, kind: 'user', source: 'authkit' };
96
+ }
97
+ return null;
18
98
  }
19
99
  /**
20
100
  * Popula o contexto do Agora a partir de uma identidade recém-resolvida:
21
101
  * `userRef` ({ type: 'user', id: userId }), os `globalRoles` da identidade e,
22
- * quando presente nas claims, `tenantId`. Best-effort: qualquer erro é engolido
23
- * e nada acontece sem o slot.
102
+ * quando há um tenant, `tenant` (id, kind, slug, role, e `chain` quando aninhado)
103
+ * mais `tenantId`. Um tenant que o próprio app já definiu nesta request
104
+ * (`Context.setTenant`, `source: 'app'`) nunca é sobrescrito. Best-effort:
105
+ * qualquer erro é engolido e nada acontece sem o slot.
24
106
  */
25
- export function populateContext(identity) {
107
+ export function populateContext(identity, options = {}) {
26
108
  try {
27
109
  const set = globalThis[SET_SLOT];
28
110
  if (!set)
29
111
  return;
30
- const tenant = deriveTenant(identity);
112
+ let tenant = null;
113
+ try {
114
+ tenant = appTenantAlreadySet() ? null : tenantFor(identity, options);
115
+ }
116
+ catch {
117
+ tenant = null;
118
+ }
31
119
  set({
32
120
  userRef: { type: 'user', id: identity.userId },
33
121
  globalRoles: identity.globalRoles,
34
- ...(tenant ? { tenantId: tenant } : {}),
122
+ ...(tenant ? { tenantId: tenant.id, tenant } : {}),
35
123
  });
36
124
  }
37
125
  catch {
@@ -1,4 +1,4 @@
1
- import type { Identity } from '@adonis-agora/authkit-core';
1
+ import { type Identity } from '@adonis-agora/authkit-core';
2
2
  import type { HttpContext, Router } from '@adonisjs/core/http';
3
3
  /**
4
4
  * Mapa role → rota de redirect pós-login. `byGlobalRole` checa as roles globais do
@@ -24,6 +24,11 @@ export interface RegisterOidcClientOptions {
24
24
  postLogoutRedirect?: string | ((ctx: HttpContext) => string);
25
25
  /** Registra também `POST {prefix}/backchannel-logout` (OIDC Back-Channel Logout). Default: true. */
26
26
  backchannelLogout?: boolean;
27
+ /**
28
+ * Registra também `GET`/`POST /.well-known/agora-account-extensions` (account console pages this
29
+ * app serves to the IdP; 404 until `accountConsole.remote` is on). Default: true.
30
+ */
31
+ accountExtensions?: boolean;
27
32
  /**
28
33
  * Middleware(s) aplicados à rota de login (ex.: `middleware.guest()`). Array no
29
34
  * formato aceito por `.use()` do AdonisJS.
@@ -47,3 +52,10 @@ export interface RegisterOidcClientOptions {
47
52
  * ```
48
53
  */
49
54
  export declare function registerOidcClient(router: Router, options?: RegisterOidcClientOptions): void;
55
+ /**
56
+ * Mounts `GET`/`POST /.well-known/agora-account-extensions`, where the IdP's account console
57
+ * fetches this app's account extension pages (answers 404 until `accountConsole.remote` is on).
58
+ * {@link registerOidcClient} calls it; call it yourself when you mount the client routes by hand.
59
+ * The IdP POSTs without a CSRF token: add the path to shield's `csrf.exceptRoutes`.
60
+ */
61
+ export declare function registerAccountExtensionsEndpoint(router: Router): void;
@@ -1,3 +1,4 @@
1
+ import { ACCOUNT_EXTENSIONS_WELL_KNOWN } from '@adonis-agora/authkit-core';
1
2
  import { OidcCallbackError } from './authorization.js';
2
3
  import { buildEndSessionUrl } from './oidc_login.js';
3
4
  /**
@@ -114,6 +115,22 @@ export function registerOidcClient(router, options = {}) {
114
115
  })
115
116
  .as('auth.backchannel_logout');
116
117
  }
118
+ if (options.accountExtensions !== false)
119
+ registerAccountExtensionsEndpoint(router);
120
+ }
121
+ /**
122
+ * Mounts `GET`/`POST /.well-known/agora-account-extensions`, where the IdP's account console
123
+ * fetches this app's account extension pages (answers 404 until `accountConsole.remote` is on).
124
+ * {@link registerOidcClient} calls it; call it yourself when you mount the client routes by hand.
125
+ * The IdP POSTs without a CSRF token: add the path to shield's `csrf.exceptRoutes`.
126
+ */
127
+ export function registerAccountExtensionsEndpoint(router) {
128
+ const handle = async (ctx) => {
129
+ const manager = await ctx.containerResolver.make('authkit.client');
130
+ return manager.handleAccountExtensions(ctx);
131
+ };
132
+ router.get(ACCOUNT_EXTENSIONS_WELL_KNOWN, handle).as('authkit.account_extensions');
133
+ router.post(ACCOUNT_EXTENSIONS_WELL_KNOWN, handle).as('authkit.account_extensions.call');
117
134
  }
118
135
  /** Resolve o destino pós-login a partir do mapa de redirects por role global (do token). */
119
136
  function resolveDestination(identity, redirects) {
@@ -45,6 +45,8 @@ export interface ResolverContext {
45
45
  };
46
46
  }
47
47
  export interface ResolverFactory {
48
+ /** Which built-in resolver this is (`jwt`, `opaque`, `pat`); absent for a custom one. */
49
+ readonly kind?: 'jwt' | 'opaque' | 'pat' | (string & {});
48
50
  resolver(ctx: ResolverContext): Promise<SessionResolver>;
49
51
  }
50
52
  export declare const resolvers: {
@@ -23,6 +23,7 @@ export const resolvers = {
23
23
  jwt(config = {}) {
24
24
  const tokenSource = config.tokenSource ?? 'session';
25
25
  return {
26
+ kind: 'jwt',
26
27
  async resolver(rc) {
27
28
  return new JwtResolver({
28
29
  endpoints: async () => {
@@ -41,6 +42,7 @@ export const resolvers = {
41
42
  },
42
43
  pat(config) {
43
44
  return {
45
+ kind: 'pat',
44
46
  async resolver(_rc) {
45
47
  return new PatResolver({
46
48
  introspectionUrl: config.introspectionUrl,
@@ -52,6 +54,7 @@ export const resolvers = {
52
54
  opaque(config = {}) {
53
55
  const tokenSource = config.tokenSource ?? 'session';
54
56
  return {
57
+ kind: 'opaque',
55
58
  async resolver(rc) {
56
59
  if (!rc.clientSecret) {
57
60
  throw new Error('resolvers.opaque requer um client confidencial (clientSecret) para introspection');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adonis-agora/authkit-client",
3
- "version": "1.0.0-rc.3",
3
+ "version": "1.0.0-rc.5",
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.0",
80
+ "@adonis-agora/authkit-core": "1.0.0-rc.1",
81
81
  "jose": "6.2.16"
82
82
  },
83
83
  "devDependencies": {
84
- "@adonis-agora/authkit-server": "1.0.0-rc.4",
84
+ "@adonis-agora/authkit-server": "1.0.0-rc.10",
85
85
  "@adonis-agora/resilience": "0.4.3",
86
86
  "@adonisjs/auth": "10.1.0",
87
87
  "@adonisjs/core": "7.6.0",