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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # @adonis-agora/authkit-client
2
2
 
3
+ ## 1.0.0-rc.4
4
+
5
+ ### Minor Changes
6
+
7
+ - [#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).
8
+
3
9
  ## 1.0.0-rc.3
4
10
 
5
11
  ### Patch Changes
package/build/index.d.ts CHANGED
@@ -30,6 +30,7 @@ export { default as AuthkitContextMiddleware } from './src/middleware/authkit_co
30
30
  export { default as BackchannelRevocationMiddleware } from './src/middleware/backchannel_revocation_middleware.js';
31
31
  export type { RequireOrgMiddlewareOptions } from './src/middleware/require_org_middleware.js';
32
32
  export { default as RequireOrgMiddleware } from './src/middleware/require_org_middleware.js';
33
+ export type { ClientTenancyOptions, ContextTenant, ContextTenantLink, TenantInput, } from './src/observability/context_bridge.js';
33
34
  export type { AuthorizeParams, EndSessionParams, ExchangeParams, RefreshParams, } from './src/oidc_login.js';
34
35
  export { buildAuthorizeUrl, buildEndSessionUrl, exchangeCode, exchangeToken, generatePkce, refreshTokens, } from './src/oidc_login.js';
35
36
  export type { PostLoginRedirects, RegisterOidcClientOptions, } from './src/register_oidc_client.js';
@@ -162,6 +162,13 @@ export declare class AuthkitClientManager {
162
162
  * sobrevive à sessão que a parqueou.
163
163
  */
164
164
  startSession(ctx: HttpContext, tokenSet: TokenSet): void;
165
+ /**
166
+ * Whether `authkit_context_middleware` resolves the session at request start, so
167
+ * the tenant is in `@adonis-agora/context` before any handler runs. `tenancy.eager`
168
+ * when set; otherwise `true` only when context tenancy is enabled and the resolver
169
+ * is `resolvers.jwt()` (local verify, no network).
170
+ */
171
+ eagerTenancy(): boolean;
165
172
  /**
166
173
  * The request's {@link Authenticator}. `options.resolver` replaces the configured
167
174
  * resolver for this authenticator only (e.g. a guard for bearer-token API routes).
@@ -11,6 +11,7 @@ import { validateLogoutToken } from '../src/backchannel_logout.js';
11
11
  import { conventionEndpoints, discoverEndpoints, mergeEndpoints, } from '../src/discovery.js';
12
12
  import { validateIdToken, } from '../src/id_token.js';
13
13
  import { getJwks } from '../src/jwks.js';
14
+ import { contextTenancyEnabled } from '../src/observability/context_bridge.js';
14
15
  import { exchangeToken, refreshTokens } from '../src/oidc_login.js';
15
16
  /** Margem (ms) antes do `expiresAt` em que o access token é renovado proativamente. */
16
17
  const REFRESH_SKEW_MS = 60_000;
@@ -457,6 +458,18 @@ export class AuthkitClientManager {
457
458
  session.forget(this.#impersonatorKey);
458
459
  session.put(this.config.sessionKey, tokenSet);
459
460
  }
461
+ /**
462
+ * Whether `authkit_context_middleware` resolves the session at request start, so
463
+ * the tenant is in `@adonis-agora/context` before any handler runs. `tenancy.eager`
464
+ * when set; otherwise `true` only when context tenancy is enabled and the resolver
465
+ * is `resolvers.jwt()` (local verify, no network).
466
+ */
467
+ eagerTenancy() {
468
+ const eager = this.config.tenancy?.eager;
469
+ if (typeof eager === 'boolean')
470
+ return eager;
471
+ return this.config.resolverFactory?.kind === 'jwt' && contextTenancyEnabled();
472
+ }
460
473
  /**
461
474
  * The request's {@link Authenticator}. `options.resolver` replaces the configured
462
475
  * resolver for this authenticator only (e.g. a guard for bearer-token API routes).
@@ -467,6 +480,7 @@ export class AuthkitClientManager {
467
480
  return new Authenticator(ctx, {
468
481
  resolver,
469
482
  resolveUser: this.config.resolveUser,
483
+ tenancy: this.config.tenancy,
470
484
  getAccessToken: () => {
471
485
  const tokenSet = ctx.session?.get(sessionKey);
472
486
  return tokenSet?.accessToken;
@@ -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
  }
@@ -2,6 +2,7 @@ import type { Identity } 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 };
@@ -76,6 +77,12 @@ export interface ClientConfigInput {
76
77
  };
77
78
  /** Per-field endpoint overrides; always win over discovery. */
78
79
  endpoints?: Partial<OidcEndpoints>;
80
+ /**
81
+ * How the session's tenant is published into `@adonis-agora/context` (the active
82
+ * organization by default). See {@link ClientTenancyOptions}: `eager`, `personal`,
83
+ * `resolve`. No effect without `@adonis-agora/context`.
84
+ */
85
+ tenancy?: ClientTenancyOptions;
79
86
  }
80
87
  export interface ResolvedClientConfig {
81
88
  issuer: string;
@@ -101,5 +108,7 @@ export interface ResolvedClientConfig {
101
108
  };
102
109
  /** Endpoint overrides. See {@link ClientConfigInput.endpoints}. */
103
110
  endpoints?: Partial<OidcEndpoints>;
111
+ /** Tenancy options. See {@link ClientConfigInput.tenancy}. */
112
+ tenancy?: ClientTenancyOptions;
104
113
  }
105
114
  export declare function defineConfig(config: ClientConfigInput): import("@adonisjs/core/types").ConfigProvider<ResolvedClientConfig>;
@@ -35,6 +35,7 @@ export function defineConfig(config) {
35
35
  ? config.discovery
36
36
  : {},
37
37
  endpoints: config.endpoints,
38
+ tenancy: config.tenancy ?? {},
38
39
  };
39
40
  });
40
41
  }
@@ -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 {
@@ -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.4",
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",
@@ -81,7 +81,7 @@
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.7",
85
85
  "@adonis-agora/resilience": "0.4.3",
86
86
  "@adonisjs/auth": "10.1.0",
87
87
  "@adonisjs/core": "7.6.0",