@adonis-agora/authkit-server 1.0.0-rc.2 → 1.0.0-rc.3

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.
Files changed (35) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/build/commands/clients_create.d.ts +4 -0
  3. package/build/commands/clients_create.js +97 -0
  4. package/build/commands/commands.json +14 -0
  5. package/build/index.d.ts +3 -0
  6. package/build/index.js +1 -0
  7. package/build/src/audit/audit_sink.d.ts +1 -1
  8. package/build/src/audit/audit_sink.js +5 -0
  9. package/build/src/define_config.d.ts +16 -0
  10. package/build/src/define_config.js +2 -0
  11. package/build/src/host/access_token_verifier.js +7 -0
  12. package/build/src/host/admin_api/api_clients_controller.d.ts +37 -0
  13. package/build/src/host/admin_api/api_clients_controller.js +31 -1
  14. package/build/src/host/admin_api/dto.d.ts +3 -1
  15. package/build/src/host/admin_api/dto.js +2 -0
  16. package/build/src/host/admin_clients_service.d.ts +20 -0
  17. package/build/src/host/admin_clients_service.js +55 -0
  18. package/build/src/host/admin_console/console_clients_controller.d.ts +41 -0
  19. package/build/src/host/admin_console/console_clients_controller.js +34 -1
  20. package/build/src/host/admin_validators.d.ts +29 -0
  21. package/build/src/host/admin_validators.js +30 -0
  22. package/build/src/host/register_auth_host.js +4 -0
  23. package/build/src/host/ui-dist/assets/index-u0bMxkLg.js +67 -0
  24. package/build/src/host/ui-dist/index.html +1 -1
  25. package/build/src/provider/build_provider.js +37 -6
  26. package/build/src/service_tenants/client_metadata.d.ts +39 -0
  27. package/build/src/service_tenants/client_metadata.js +80 -0
  28. package/build/src/service_tenants/config.d.ts +44 -0
  29. package/build/src/service_tenants/config.js +44 -0
  30. package/build/src/service_tenants/provider_hooks.d.ts +52 -0
  31. package/build/src/service_tenants/provider_hooks.js +124 -0
  32. package/build/src/service_tenants/tenant_verifier.d.ts +73 -0
  33. package/build/src/service_tenants/tenant_verifier.js +104 -0
  34. package/package.json +2 -2
  35. package/build/src/host/ui-dist/assets/index-BqyJYRVX.js +0 -67
package/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  # @adonis-agora/authkit-server
2
2
 
3
+ ## 1.0.0-rc.3
4
+
5
+ ### Minor Changes
6
+
7
+ - [#460](https://github.com/DavideCarvalho/adonis-agora-authkit/pull/460) [`1858ee8`](https://github.com/DavideCarvalho/adonis-agora-authkit/commit/1858ee837bb4edcf0578f86d224693ac9a86a495) Thanks [@DavideCarvalho](https://github.com/DavideCarvalho)! - Service tenants: a client can act as one partition of another service, such as a durable control plane.
8
+
9
+ - `serviceTenants: { resources: { 'urn:agora:durable': { expiresIn: 600 } } }` in `config/authkit.ts` declares the resources (RFC 8707) that issue service-tenant tokens: JWT access tokens (RFC 9068) with `aud` = the resource and the client's `partition` and `capabilities` as claims.
10
+ - A service-tenant client is confidential, uses only `client_credentials` and has no redirect URIs. Create and edit it from the console (**Service tenant** kind), the Admin API and the SDKs (`serviceTenant: { partition, capabilities? }`), or `node ace authkit:clients:create --partition=acme --capability=reports`.
11
+ - Any client can now be disabled and enabled again (console, `POST /clients/:id/disable|enable`, SDK `clients.disable/enable`): the token endpoint answers `invalid_client` from the next request on.
12
+ - `authkitTenantVerifier()` (authkit-server) verifies those tokens against the local issuer and keys and returns `{ tenant, capabilities }`; `authkitTenantToken({ issuer, clientId, clientSecret, resource })` (authkit-client) fetches, caches and refreshes them on the tenant side. Both fit durable's `verifyTenant` and `tenant.token` as a function or a class, without depending on durable.
13
+ - New audit events: `client.disabled`, `client.enabled`, `service_tenant.token_issued`.
14
+ - Access-token verification (`oidcBearerGuard`, in-process and remote) now refuses a token whose `sub` is a client (a `client_credentials` token), so a service-tenant token never authenticates a user.
15
+
3
16
  ## 1.0.0-rc.2
4
17
 
5
18
  ### Major Changes
@@ -11,8 +11,10 @@ import type { CommandOptions } from '@adonisjs/core/types/ace';
11
11
  * node ace authkit:clients:create --client-id=my-api --redirect-uri=https://api/cb --redirect-uri=https://api/cb2 --grant=client_credentials
12
12
  * node ace authkit:clients:create --redirect-uri=https://app/cb --json
13
13
  * node ace authkit:clients:create --client-id=my-mobile --native --redirect-uri=com.example.app:/oauth
14
+ * node ace authkit:clients:create --client-id=worker-acme --partition=acme --capability=reports
14
15
  */
15
16
  export default class AuthkitClientsCreate extends BaseCommand {
17
+ #private;
16
18
  static commandName: string;
17
19
  static description: string;
18
20
  static help: string[];
@@ -25,6 +27,8 @@ export default class AuthkitClientsCreate extends BaseCommand {
25
27
  native?: boolean;
26
28
  backchannelLogoutUri?: string;
27
29
  clientSecret?: string;
30
+ partition?: string;
31
+ capability?: string[];
28
32
  json?: boolean;
29
33
  run(): Promise<void>;
30
34
  }
@@ -16,6 +16,7 @@ import { BaseCommand, flags } from '@adonisjs/core/ace';
16
16
  * node ace authkit:clients:create --client-id=my-api --redirect-uri=https://api/cb --redirect-uri=https://api/cb2 --grant=client_credentials
17
17
  * node ace authkit:clients:create --redirect-uri=https://app/cb --json
18
18
  * node ace authkit:clients:create --client-id=my-mobile --native --redirect-uri=com.example.app:/oauth
19
+ * node ace authkit:clients:create --client-id=worker-acme --partition=acme --capability=reports
19
20
  */
20
21
  export default class AuthkitClientsCreate extends BaseCommand {
21
22
  static commandName = 'authkit:clients:create';
@@ -41,9 +42,18 @@ export default class AuthkitClientsCreate extends BaseCommand {
41
42
  ' node ace authkit:clients:create --redirect-uri=https://app/cb --backchannel-logout-uri=https://app/bc',
42
43
  ' node ace authkit:clients:create --client-id=my-app --redirect-uri=https://a/cb --redirect-uri=https://b/cb --json',
43
44
  ' node ace authkit:clients:create --client-id=my-mobile --native --redirect-uri=com.example.app:/oauth',
45
+ '',
46
+ 'Service tenant (a process that acts as one partition, e.g. a durable tenant pod):',
47
+ 'use --partition (and --capability, repeatable). Implies a confidential client with',
48
+ 'only the client_credentials grant and no redirect URIs. Needs serviceTenants.resources',
49
+ 'in config/authkit.ts.',
50
+ ' node ace authkit:clients:create --client-id=worker-acme --partition=acme --capability=reports',
44
51
  ];
45
52
  static options = { startApp: true };
46
53
  async run() {
54
+ if (this.partition !== undefined || this.capability?.length) {
55
+ return this.#createServiceTenant();
56
+ }
47
57
  const redirectUris = this.redirectUri ?? [];
48
58
  if (redirectUris.length === 0) {
49
59
  this.logger.logError('❌ --redirect-uri é obrigatório. Passe ao menos uma URI de callback.');
@@ -120,6 +130,83 @@ export default class AuthkitClientsCreate extends BaseCommand {
120
130
  this.logger.info('');
121
131
  }
122
132
  }
133
+ /** `--partition`: a service tenant (client_credentials only, `partition` claim). */
134
+ async #createServiceTenant() {
135
+ if (!this.partition) {
136
+ this.logger.logError('❌ --capability needs --partition.');
137
+ this.exitCode = 1;
138
+ return;
139
+ }
140
+ const conflicting = [
141
+ this.redirectUri?.length && '--redirect-uri',
142
+ this.postLogoutUri?.length && '--post-logout-uri',
143
+ this.public && '--public',
144
+ this.native && '--native',
145
+ this.backchannelLogoutUri && '--backchannel-logout-uri',
146
+ this.grant?.some((g) => g !== 'client_credentials') && '--grant',
147
+ ].filter(Boolean);
148
+ if (conflicting.length) {
149
+ this.logger.logError(`❌ ${conflicting.join(', ')} cannot be used with --partition (a service tenant only uses client_credentials).`);
150
+ this.exitCode = 1;
151
+ return;
152
+ }
153
+ const service = await this.app.container.make('authkit.server');
154
+ const { AdminClientsService } = await import('../src/host/admin_clients_service.js');
155
+ const { ClientMetadataError } = await import('../src/host/client_metadata.js');
156
+ const svc = new AdminClientsService(service);
157
+ const input = {
158
+ clientId: this.clientId,
159
+ applicationType: 'web',
160
+ redirectUris: [],
161
+ postLogoutRedirectUris: [],
162
+ grantTypes: ['client_credentials'],
163
+ tokenEndpointAuthMethod: 'client_secret_basic',
164
+ serviceTenant: { partition: this.partition, capabilities: this.capability ?? [] },
165
+ };
166
+ let created;
167
+ try {
168
+ created = this.clientSecret
169
+ ? await svc.createWithSecret(input, this.clientSecret)
170
+ : await svc.create(input);
171
+ }
172
+ catch (err) {
173
+ if (err instanceof ClientMetadataError) {
174
+ this.logger.logError(`❌ ${err.message}`);
175
+ this.exitCode = 1;
176
+ return;
177
+ }
178
+ throw err;
179
+ }
180
+ const tenant = (await svc.find(created.clientId))?.serviceTenant;
181
+ await service.config.audit
182
+ ?.record({
183
+ type: 'client.created',
184
+ clientId: created.clientId,
185
+ ip: null,
186
+ metadata: { actor: 'cli', partition: tenant?.partition },
187
+ })
188
+ .catch(() => { });
189
+ if (this.json) {
190
+ this.logger.info(JSON.stringify({
191
+ clientId: created.clientId,
192
+ clientSecret: created.clientSecret,
193
+ grantTypes: ['client_credentials'],
194
+ serviceTenant: tenant,
195
+ resources: Object.keys(service.config.serviceTenants.resources),
196
+ }, null, 2));
197
+ return;
198
+ }
199
+ this.logger.success(`Service tenant created: ${created.clientId}`);
200
+ this.logger.info(` partition: ${tenant?.partition}`);
201
+ if (tenant?.capabilities.length) {
202
+ this.logger.info(` capabilities: ${tenant.capabilities.join(', ')}`);
203
+ }
204
+ this.logger.info(` resources: ${Object.keys(service.config.serviceTenants.resources).join(', ')}`);
205
+ this.logger.info('');
206
+ this.logger.success('CLIENT SECRET (shown ONCE - store it now):');
207
+ this.logger.success(` ${created.clientSecret}`);
208
+ this.logger.info('');
209
+ }
123
210
  }
124
211
  __decorate([
125
212
  flags.string({ description: 'client_id desejado. Omitir gera um UUID aleatório.' })
@@ -155,6 +242,16 @@ __decorate([
155
242
  description: 'Usa este client_secret em vez de gerar um (client confidencial). Útil para manter o secret que os RPs já usam.',
156
243
  })
157
244
  ], AuthkitClientsCreate.prototype, "clientSecret", void 0);
245
+ __decorate([
246
+ flags.string({
247
+ description: 'Service tenant: the partition its tokens act as (`partition` claim). Implies client_credentials only, no redirect URIs.',
248
+ })
249
+ ], AuthkitClientsCreate.prototype, "partition", void 0);
250
+ __decorate([
251
+ flags.array({
252
+ description: 'Service tenant: capability granted in its tokens (repeatable). Needs --partition.',
253
+ })
254
+ ], AuthkitClientsCreate.prototype, "capability", void 0);
158
255
  __decorate([
159
256
  flags.boolean({
160
257
  description: 'Output em JSON machine-readable (inclui clientId e clientSecret).',
@@ -130,6 +130,20 @@
130
130
  "type": "string",
131
131
  "description": "Usa este client_secret em vez de gerar um (client confidencial)."
132
132
  },
133
+ {
134
+ "name": "partition",
135
+ "flagName": "partition",
136
+ "required": false,
137
+ "type": "string",
138
+ "description": "Service tenant: the partition its tokens act as (`partition` claim). Implies client_credentials only, no redirect URIs."
139
+ },
140
+ {
141
+ "name": "capability",
142
+ "flagName": "capability",
143
+ "required": false,
144
+ "type": "array",
145
+ "description": "Service tenant: capability granted in its tokens (repeatable). Needs --partition."
146
+ },
133
147
  {
134
148
  "name": "json",
135
149
  "flagName": "json",
package/build/index.d.ts CHANGED
@@ -158,4 +158,7 @@ export { checkClientRegistration, classifyRedirect, type RedirectUriPolicy, type
158
158
  export { registerOidcRoutes } from './src/register_routes.js';
159
159
  export type { EnsureSchemaOptions, EnsureSchemaReport } from './src/schema/ensure.js';
160
160
  export { ensureAuthkitSchema, ensureAuthkitTables } from './src/schema/ensure.js';
161
+ export type { ServiceTenantInfo } from './src/service_tenants/client_metadata.js';
162
+ export type { ResolvedServiceTenantResource, ResolvedServiceTenantsConfig, ServiceTenantResourceInput, ServiceTenantsConfigInput, } from './src/service_tenants/config.js';
163
+ export { AuthkitTenantVerifier, type AuthkitTenantVerifierFn, type AuthkitTenantVerifierOptions, authkitTenantVerifier, type TenantTokenIssuer, type TenantVerifierInput, type VerifiedTenant, } from './src/service_tenants/tenant_verifier.js';
161
164
  export { stubsRoot } from './stubs/main.js';
package/build/index.js CHANGED
@@ -136,4 +136,5 @@ export { OidcService } from './src/provider/oidc_service.js';
136
136
  export { checkClientRegistration, classifyRedirect, RegistrationPolicyError, } from './src/provider/registration_policy.js';
137
137
  export { registerOidcRoutes } from './src/register_routes.js';
138
138
  export { ensureAuthkitSchema, ensureAuthkitTables } from './src/schema/ensure.js';
139
+ export { AuthkitTenantVerifier, authkitTenantVerifier, } from './src/service_tenants/tenant_verifier.js';
139
140
  export { stubsRoot } from './stubs/main.js';
@@ -8,7 +8,7 @@
8
8
  * varre os `audit.record({ type: ... })` do `src/` e falha quando um evento
9
9
  * emitido não está aqui.
10
10
  */
11
- export declare const AUDIT_EVENT_TYPES: readonly ['login.success', 'login.failure', 'signup', 'password_reset.issued', 'password_reset.consumed', 'pat.issued', 'pat.revoked', 'pat.used', 'impersonation', 'impersonation.panel_viewed', 'impersonation.stopped', 'mfa.enabled', 'mfa.disabled', 'mfa.recovery_codes_regenerated', 'account.locked', 'passkey.registered', 'passkey.removed', 'email_verification.issued', 'email_verification.consumed', 'client.created', 'client.updated', 'client.deleted', 'session.revoked_all', 'password.changed', 'password.rehashed', 'email.change_requested', 'email.changed', 'login.new_ip_notified', 'login.new_device', 'login.otp_sent', 'login.otp_verified', 'login.otp_failed', 'login.otp_invalidated', 'bot_protection.rejected', 'grant.revoked_by_user', 'user.created', 'user.password_reset_sent', 'user.disabled', 'user.enabled', 'user.deleted', 'profile.updated', 'account.deleted', 'account.exported', 'keys.rotated', 'organization.created', 'organization.updated', 'organization.deleted', 'organization.member_added', 'organization.member_removed', 'organization.member_role_changed', 'organization.member_role_updated', 'organization.switched', 'organization.deactivated', 'organization.invitation_sent', 'organization.invitation_accepted', 'organization.invitation_revoked', 'email_change.requested', 'email_change.confirmed', 'email_change.cancelled', 'security_notice.sent', 'settings.updated', 'maintenance.enabled', 'maintenance.disabled', 'trusted_device.revoked', 'password.expired_change_forced', 'otp.locked', 'otp.unlocked', 'otp.unlock_failed', 'sudo.confirmed', 'session.single_enforced', 'account.expired_login_blocked', 'account.expiration_warned', 'login.magic_link_sent', 'session.revoked', 'account.signed_out_all', 'client.secret_regenerated', 'roles_catalog.updated', 'agent.delegation_approved', 'agent.delegation_denied', 'agent.grant_revoked', 'agent.grant_revoked_by_admin', 'agent.allowed', 'agent.blocked', 'agent.registry_removed'];
11
+ export declare const AUDIT_EVENT_TYPES: readonly ['login.success', 'login.failure', 'signup', 'password_reset.issued', 'password_reset.consumed', 'pat.issued', 'pat.revoked', 'pat.used', 'impersonation', 'impersonation.panel_viewed', 'impersonation.stopped', 'mfa.enabled', 'mfa.disabled', 'mfa.recovery_codes_regenerated', 'account.locked', 'passkey.registered', 'passkey.removed', 'email_verification.issued', 'email_verification.consumed', 'client.created', 'client.updated', 'client.deleted', 'session.revoked_all', 'password.changed', 'password.rehashed', 'email.change_requested', 'email.changed', 'login.new_ip_notified', 'login.new_device', 'login.otp_sent', 'login.otp_verified', 'login.otp_failed', 'login.otp_invalidated', 'bot_protection.rejected', 'grant.revoked_by_user', 'user.created', 'user.password_reset_sent', 'user.disabled', 'user.enabled', 'user.deleted', 'profile.updated', 'account.deleted', 'account.exported', 'keys.rotated', 'organization.created', 'organization.updated', 'organization.deleted', 'organization.member_added', 'organization.member_removed', 'organization.member_role_changed', 'organization.member_role_updated', 'organization.switched', 'organization.deactivated', 'organization.invitation_sent', 'organization.invitation_accepted', 'organization.invitation_revoked', 'email_change.requested', 'email_change.confirmed', 'email_change.cancelled', 'security_notice.sent', 'settings.updated', 'maintenance.enabled', 'maintenance.disabled', 'trusted_device.revoked', 'password.expired_change_forced', 'otp.locked', 'otp.unlocked', 'otp.unlock_failed', 'sudo.confirmed', 'session.single_enforced', 'account.expired_login_blocked', 'account.expiration_warned', 'login.magic_link_sent', 'session.revoked', 'account.signed_out_all', 'client.secret_regenerated', 'client.disabled', 'client.enabled', 'service_tenant.token_issued', 'roles_catalog.updated', 'agent.delegation_approved', 'agent.delegation_denied', 'agent.grant_revoked', 'agent.grant_revoked_by_admin', 'agent.allowed', 'agent.blocked', 'agent.registry_removed'];
12
12
  /** Tipos de eventos de auditoria relevantes para segurança emitidos pelo IdP. */
13
13
  export type AuditEventType = (typeof AUDIT_EVENT_TYPES)[number];
14
14
  /**
@@ -107,6 +107,11 @@ export const AUDIT_EVENT_TYPES = [
107
107
  'session.revoked',
108
108
  'account.signed_out_all',
109
109
  'client.secret_regenerated',
110
+ // A client was disabled (no new tokens) or enabled again.
111
+ 'client.disabled',
112
+ 'client.enabled',
113
+ // A service tenant got a token (`partition`, `resource`, `jti` in the metadata).
114
+ 'service_tenant.token_issued',
110
115
  'roles_catalog.updated',
111
116
  // Personal agents (PACT §5): o usuário aprovou/negou a delegação pedida por um
112
117
  // agente, ou revogou um grant no console. `clientId` = issuer do agente.
@@ -22,6 +22,7 @@ import type { WhatsappCodeSenderBinding } from './host/whatsapp_code_sender.js';
22
22
  import { type McpOAuthConfigInput, type ResolvedMcpOAuthConfig } from './mcp/mcp_oauth.js';
23
23
  import type { PatStore } from './pat/pat_store.js';
24
24
  import { type RedirectUriPolicy, type ResolvedRedirectUriPolicy, type ValidateRegistrationHook } from './provider/registration_policy.js';
25
+ import { type ResolvedServiceTenantsConfig, type ServiceTenantsConfigInput } from './service_tenants/config.js';
25
26
  export type { AuthAccount };
26
27
  export { adapters };
27
28
  export type AuthHostRenderer = (ctx: HttpContext, view: string, props: Record<string, unknown>) => unknown;
@@ -1113,6 +1114,19 @@ export interface AuthServerConfigInput {
1113
1114
  * `resources` mapeia resource indicators (RFC 8707) para audiences/scopes/formato/TTL por API.
1114
1115
  */
1115
1116
  accessTokens?: AccessTokensConfig;
1117
+ /**
1118
+ * Service tenants: confidential `client_credentials` clients that act as a partition of
1119
+ * a resource server (e.g. a durable control plane). `resources` lists the resource
1120
+ * indicators (RFC 8707) that issue their JWT access tokens (RFC 9068), each with an
1121
+ * optional `expiresIn` (seconds, default 600), `audience` and `scopes`:
1122
+ *
1123
+ * ```ts
1124
+ * serviceTenants: { resources: { 'urn:agora:durable': { expiresIn: 600 } } }
1125
+ * ```
1126
+ *
1127
+ * Turns on the `client_credentials` grant. Default: off.
1128
+ */
1129
+ serviceTenants?: ServiceTenantsConfigInput;
1116
1130
  /**
1117
1131
  * Login OAuth de clientes MCP (Claude Code, Claude, ChatGPT, VS Code, Cursor): `true` liga o
1118
1132
  * registro dinâmico restrito aos redirects deles, o refresh token desses clientes e os
@@ -1355,6 +1369,8 @@ export interface ResolvedServerConfig {
1355
1369
  registration: ResolvedRegistrationConfig;
1356
1370
  /** Access Tokens resolvido (RFC 9068; default opaque). */
1357
1371
  accessTokens: ResolvedAccessTokensConfig;
1372
+ /** Service tenants (always present; `enabled: false` when no resource is declared). */
1373
+ serviceTenants: ResolvedServiceTenantsConfig;
1358
1374
  mcp: ResolvedMcpOAuthConfig;
1359
1375
  /** Console admin resolvido (sempre presente; default desligado). */
1360
1376
  admin: ResolvedAdminConfig;
@@ -17,6 +17,7 @@ import { loadEncryptionService } from './keys/keystore_crypto.js';
17
17
  import { KeystoreManager, resolveKeystoreVault } from './keys/keystore_manager.js';
18
18
  import { mcpClientRegistration, resolveMcpOAuth, } from './mcp/mcp_oauth.js';
19
19
  import { OPEN_REGISTRATION_REDIRECT_POLICY, resolveRedirectUriPolicy, } from './provider/registration_policy.js';
20
+ import { resolveServiceTenants, } from './service_tenants/config.js';
20
21
  export { adapters };
21
22
  const RATE_LIMIT_DEFAULTS = {
22
23
  login: { points: 10, duration: '1 min' },
@@ -475,6 +476,7 @@ export function defineConfig(config) {
475
476
  : undefined,
476
477
  registration: resolveRegistration(config.registration),
477
478
  accessTokens: resolveAccessTokens(config.issuer, config.accessTokens),
479
+ serviceTenants: resolveServiceTenants(config.serviceTenants, config.accessTokens?.resources),
478
480
  mcp: resolveMcpOAuth(config.mcp),
479
481
  admin: resolveAdmin(config.admin),
480
482
  adminApi: resolveAdminApi(config.adminApi),
@@ -41,6 +41,10 @@ function fromJwtPayload(payload) {
41
41
  if (typeof payload.sub !== 'string' || !payload.sub)
42
42
  return null;
43
43
  const clientId = payload.client_id;
44
+ // No user behind it: a `client_credentials` token (a service tenant's, for one) has
45
+ // `sub` = the client id (RFC 9068 §2.2) and never authenticates an account.
46
+ if (payload.sub === clientId)
47
+ return null;
44
48
  return {
45
49
  format: 'jwt',
46
50
  sub: payload.sub,
@@ -209,6 +213,9 @@ export function remoteAccessTokenVerifier(options) {
209
213
  return null;
210
214
  if (typeof body.sub !== 'string' || !body.sub)
211
215
  return null;
216
+ // `client_credentials` token: no account behind it.
217
+ if (body.sub === body.client_id)
218
+ return null;
212
219
  if (typeof body.exp === 'number' && body.exp <= Math.floor(Date.now() / 1000))
213
220
  return null;
214
221
  return {
@@ -6,6 +6,7 @@ import type { HttpContext } from '@adonisjs/core/http';
6
6
  * em create/regenerate. Audita create/update/delete.
7
7
  */
8
8
  export default class ApiClientsController {
9
+ #private;
9
10
  index(ctx: HttpContext): Promise<{
10
11
  data: {
11
12
  clientId: string;
@@ -19,6 +20,8 @@ export default class ApiClientsController {
19
20
  backchannelLogoutSessionRequired: boolean;
20
21
  clientName: string | null;
21
22
  logoUri: string | null;
23
+ disabled: boolean;
24
+ serviceTenant: import("../../service_tenants/client_metadata.ts").ServiceTenantInfo | null;
22
25
  }[];
23
26
  canList: boolean;
24
27
  }>;
@@ -34,6 +37,8 @@ export default class ApiClientsController {
34
37
  backchannelLogoutSessionRequired: boolean;
35
38
  clientName: string | null;
36
39
  logoUri: string | null;
40
+ disabled: boolean;
41
+ serviceTenant: import("../../service_tenants/client_metadata.ts").ServiceTenantInfo | null;
37
42
  }>;
38
43
  store(ctx: HttpContext): Promise<void | {
39
44
  clientId: string;
@@ -51,11 +56,43 @@ export default class ApiClientsController {
51
56
  backchannelLogoutSessionRequired: boolean;
52
57
  clientName: string | null;
53
58
  logoUri: string | null;
59
+ disabled: boolean;
60
+ serviceTenant: import("../../service_tenants/client_metadata.ts").ServiceTenantInfo | null;
54
61
  }>;
55
62
  regenerateSecret(ctx: HttpContext): Promise<void | {
56
63
  clientId: any;
57
64
  clientSecret: string;
58
65
  }>;
66
+ disable(ctx: HttpContext): Promise<void | {
67
+ clientId: string;
68
+ confidential: boolean;
69
+ grantTypes: string[];
70
+ redirectUris: string[];
71
+ postLogoutRedirectUris: string[];
72
+ tokenEndpointAuthMethod: string;
73
+ applicationType: import("../client_metadata.js").ApplicationType;
74
+ backchannelLogoutUri: string | null;
75
+ backchannelLogoutSessionRequired: boolean;
76
+ clientName: string | null;
77
+ logoUri: string | null;
78
+ disabled: boolean;
79
+ serviceTenant: import("../../service_tenants/client_metadata.ts").ServiceTenantInfo | null;
80
+ }>;
81
+ enable(ctx: HttpContext): Promise<void | {
82
+ clientId: string;
83
+ confidential: boolean;
84
+ grantTypes: string[];
85
+ redirectUris: string[];
86
+ postLogoutRedirectUris: string[];
87
+ tokenEndpointAuthMethod: string;
88
+ applicationType: import("../client_metadata.js").ApplicationType;
89
+ backchannelLogoutUri: string | null;
90
+ backchannelLogoutSessionRequired: boolean;
91
+ clientName: string | null;
92
+ logoUri: string | null;
93
+ disabled: boolean;
94
+ serviceTenant: import("../../service_tenants/client_metadata.ts").ServiceTenantInfo | null;
95
+ }>;
59
96
  destroy(ctx: HttpContext): Promise<{
60
97
  clientId: any;
61
98
  deleted: boolean;
@@ -85,11 +85,16 @@ export default class ApiClientsController {
85
85
  const id = ctx.request.param('id');
86
86
  try {
87
87
  const secret = await svc.regenerateSecret(id);
88
+ const tenant = (await svc.find(id))?.serviceTenant;
88
89
  await service.config.audit?.record({
89
90
  type: 'client.updated',
90
91
  clientId: id,
91
92
  ip: ctx.request.ip?.() ?? null,
92
- metadata: { actor: 'admin-api', action: 'regenerate_secret' },
93
+ metadata: {
94
+ actor: 'admin-api',
95
+ action: 'regenerate_secret',
96
+ ...(tenant ? { partition: tenant.partition } : {}),
97
+ },
93
98
  });
94
99
  return { clientId: id, clientSecret: secret };
95
100
  }
@@ -97,6 +102,31 @@ export default class ApiClientsController {
97
102
  return ctx.response.conflict(apiError('cannot_regenerate', e.message));
98
103
  }
99
104
  }
105
+ async disable(ctx) {
106
+ return this.#setDisabled(ctx, true);
107
+ }
108
+ async enable(ctx) {
109
+ return this.#setDisabled(ctx, false);
110
+ }
111
+ /** Disabled clients get `invalid_client` from the next request on. */
112
+ async #setDisabled(ctx, disabled) {
113
+ const { service, svc } = await clientsService(ctx);
114
+ const id = ctx.request.param('id');
115
+ if (!(await svc.setDisabled(id, disabled))) {
116
+ return ctx.response.notFound(apiError('not_found', 'Client não encontrado.'));
117
+ }
118
+ const client = await svc.find(id);
119
+ await service.config.audit?.record({
120
+ type: disabled ? 'client.disabled' : 'client.enabled',
121
+ clientId: id,
122
+ ip: ctx.request.ip?.() ?? null,
123
+ metadata: {
124
+ actor: 'admin-api',
125
+ ...(client?.serviceTenant ? { partition: client.serviceTenant.partition } : {}),
126
+ },
127
+ });
128
+ return clientDto(client);
129
+ }
100
130
  async destroy(ctx) {
101
131
  const { service, svc } = await clientsService(ctx);
102
132
  const id = ctx.request.param('id');
@@ -40,6 +40,8 @@ export declare function clientDto(client: AdminClient): {
40
40
  backchannelLogoutSessionRequired: boolean;
41
41
  clientName: string | null;
42
42
  logoUri: string | null;
43
+ disabled: boolean;
44
+ serviceTenant: import("../../service_tenants/client_metadata.ts").ServiceTenantInfo | null;
43
45
  };
44
46
  /** Inclui o secret (mostrado UMA vez) em create/regenerate. */
45
47
  export declare function createdClientDto(created: CreatedClient): {
@@ -67,7 +69,7 @@ export declare function grantDto(grant: AdminGrant): {
67
69
  };
68
70
  export declare function auditDto(event: StoredAuditEvent): {
69
71
  id: string;
70
- type: "account.deleted" | "account.expiration_warned" | "account.expired_login_blocked" | "account.exported" | "account.locked" | "account.signed_out_all" | "agent.allowed" | "agent.blocked" | "agent.delegation_approved" | "agent.delegation_denied" | "agent.grant_revoked" | "agent.grant_revoked_by_admin" | "agent.registry_removed" | "bot_protection.rejected" | "client.created" | "client.deleted" | "client.secret_regenerated" | "client.updated" | "email.change_requested" | "email.changed" | "email_change.cancelled" | "email_change.confirmed" | "email_change.requested" | "email_verification.consumed" | "email_verification.issued" | "grant.revoked_by_user" | "impersonation" | "impersonation.panel_viewed" | "impersonation.stopped" | "keys.rotated" | "login.failure" | "login.magic_link_sent" | "login.new_device" | "login.new_ip_notified" | "login.otp_failed" | "login.otp_invalidated" | "login.otp_sent" | "login.otp_verified" | "login.success" | "maintenance.disabled" | "maintenance.enabled" | "mfa.disabled" | "mfa.enabled" | "mfa.recovery_codes_regenerated" | "organization.created" | "organization.deactivated" | "organization.deleted" | "organization.invitation_accepted" | "organization.invitation_revoked" | "organization.invitation_sent" | "organization.member_added" | "organization.member_removed" | "organization.member_role_changed" | "organization.member_role_updated" | "organization.switched" | "organization.updated" | "otp.locked" | "otp.unlock_failed" | "otp.unlocked" | "passkey.registered" | "passkey.removed" | "password.changed" | "password.expired_change_forced" | "password.rehashed" | "password_reset.consumed" | "password_reset.issued" | "pat.issued" | "pat.revoked" | "pat.used" | "profile.updated" | "roles_catalog.updated" | "security_notice.sent" | "session.revoked" | "session.revoked_all" | "session.single_enforced" | "settings.updated" | "signup" | "sudo.confirmed" | "trusted_device.revoked" | "user.created" | "user.deleted" | "user.disabled" | "user.enabled" | "user.password_reset_sent";
72
+ type: "account.deleted" | "account.expiration_warned" | "account.expired_login_blocked" | "account.exported" | "account.locked" | "account.signed_out_all" | "agent.allowed" | "agent.blocked" | "agent.delegation_approved" | "agent.delegation_denied" | "agent.grant_revoked" | "agent.grant_revoked_by_admin" | "agent.registry_removed" | "bot_protection.rejected" | "client.created" | "client.deleted" | "client.disabled" | "client.enabled" | "client.secret_regenerated" | "client.updated" | "email.change_requested" | "email.changed" | "email_change.cancelled" | "email_change.confirmed" | "email_change.requested" | "email_verification.consumed" | "email_verification.issued" | "grant.revoked_by_user" | "impersonation" | "impersonation.panel_viewed" | "impersonation.stopped" | "keys.rotated" | "login.failure" | "login.magic_link_sent" | "login.new_device" | "login.new_ip_notified" | "login.otp_failed" | "login.otp_invalidated" | "login.otp_sent" | "login.otp_verified" | "login.success" | "maintenance.disabled" | "maintenance.enabled" | "mfa.disabled" | "mfa.enabled" | "mfa.recovery_codes_regenerated" | "organization.created" | "organization.deactivated" | "organization.deleted" | "organization.invitation_accepted" | "organization.invitation_revoked" | "organization.invitation_sent" | "organization.member_added" | "organization.member_removed" | "organization.member_role_changed" | "organization.member_role_updated" | "organization.switched" | "organization.updated" | "otp.locked" | "otp.unlock_failed" | "otp.unlocked" | "passkey.registered" | "passkey.removed" | "password.changed" | "password.expired_change_forced" | "password.rehashed" | "password_reset.consumed" | "password_reset.issued" | "pat.issued" | "pat.revoked" | "pat.used" | "profile.updated" | "roles_catalog.updated" | "security_notice.sent" | "service_tenant.token_issued" | "session.revoked" | "session.revoked_all" | "session.single_enforced" | "settings.updated" | "signup" | "sudo.confirmed" | "trusted_device.revoked" | "user.created" | "user.deleted" | "user.disabled" | "user.enabled" | "user.password_reset_sent";
71
73
  accountId: string | null;
72
74
  email: string | null;
73
75
  clientId: string | null;
@@ -32,6 +32,8 @@ export function clientDto(client) {
32
32
  backchannelLogoutSessionRequired: client.backchannelLogoutSessionRequired ?? false,
33
33
  clientName: client.clientName,
34
34
  logoUri: client.logoUri,
35
+ disabled: client.disabled,
36
+ serviceTenant: client.serviceTenant,
35
37
  };
36
38
  }
37
39
  /** Inclui o secret (mostrado UMA vez) em create/regenerate. */
@@ -1,4 +1,5 @@
1
1
  import type { OidcService } from '../provider/oidc_service.js';
2
+ import { type ServiceTenantInfo } from '../service_tenants/client_metadata.js';
2
3
  import { type ApplicationType } from './client_metadata.js';
3
4
  /** Métodos de autenticação no token endpoint suportados pelo formulário admin. */
4
5
  export type TokenEndpointAuthMethod = 'client_secret_basic' | 'client_secret_post' | 'none';
@@ -30,6 +31,15 @@ export interface ClientInput {
30
31
  * `backchannelLogoutUri` não está definido.
31
32
  */
32
33
  backchannelLogoutSessionRequired?: boolean;
34
+ /**
35
+ * Makes the client a service tenant: confidential, `client_credentials` only, no
36
+ * redirect URIs; its tokens carry `partition` and `capabilities`. On update, `null`
37
+ * turns it back into a regular client; absent keeps the current value.
38
+ */
39
+ serviceTenant?: {
40
+ partition: string;
41
+ capabilities?: string[];
42
+ } | null;
33
43
  }
34
44
  /** Client persistido, apresentado ao console admin. */
35
45
  export interface AdminClient {
@@ -49,6 +59,10 @@ export interface AdminClient {
49
59
  clientName: string | null;
50
60
  /** `logo_uri` registrado, ou `null`. */
51
61
  logoUri: string | null;
62
+ /** Disabled clients get no tokens (`invalid_client`) until enabled again. */
63
+ disabled: boolean;
64
+ /** Service-tenant settings, or `null` for a regular client. */
65
+ serviceTenant: ServiceTenantInfo | null;
52
66
  }
53
67
  /** Resultado de uma criação: o client + o secret em claro (mostrado UMA vez). */
54
68
  export interface CreatedClient {
@@ -94,6 +108,12 @@ export declare class AdminClientsService {
94
108
  * PRESERVANDO o client_secret existente. Lança se o client não existe.
95
109
  */
96
110
  update(clientId: string, input: Partial<ClientInput>): Promise<void>;
111
+ /**
112
+ * Disables or re-enables a client. A disabled client gets `invalid_client` on every
113
+ * endpoint from the next request on (tokens already issued stay valid until they
114
+ * expire). Returns `false` when the client does not exist.
115
+ */
116
+ setDisabled(clientId: string, disabled: boolean): Promise<boolean>;
97
117
  /**
98
118
  * Regenera o client_secret de um client confidencial, preservando o resto da
99
119
  * metadata. Retorna o novo secret em claro (mostrado UMA vez). Lança se o
@@ -1,4 +1,5 @@
1
1
  import { randomBytes } from 'node:crypto';
2
+ import { CLIENT_DISABLED_KEY, isClientDisabled, normalizeServiceTenant, SERVICE_TENANT_KEY, serviceTenantOf, } from '../service_tenants/client_metadata.js';
2
3
  import { assertClientMetadata, ClientMetadataError, } from './client_metadata.js';
3
4
  import { CONSOLE_SSO_CLIENT_ID } from './console_sso.js';
4
5
  /** Métodos públicos (sem segredo) — espelham os "non-secret" do oidc-provider. */
@@ -111,14 +112,37 @@ export class AdminClientsService {
111
112
  logoUri: input.logoUri === null
112
113
  ? undefined
113
114
  : (input.logoUri ?? existing.logo_uri),
115
+ serviceTenant: input.serviceTenant === undefined
116
+ ? serviceTenantOf(existing)
117
+ : input.serviceTenant,
114
118
  };
115
119
  const confidential = !PUBLIC_AUTH_METHODS.has(merged.tokenEndpointAuthMethod);
116
120
  // Mantém o secret atual se continua confidencial; se virou public, remove-o.
117
121
  const clientSecret = confidential ? (previousSecret ?? randomId()) : undefined;
118
122
  const payload = this.#buildPayload(clientId, merged, clientSecret);
123
+ if (isClientDisabled(existing))
124
+ payload[CLIENT_DISABLED_KEY] = true;
119
125
  await this.#adapter.upsert(clientId, payload, 0);
120
126
  await this.oidc.evictDynamicClientCache();
121
127
  }
128
+ /**
129
+ * Disables or re-enables a client. A disabled client gets `invalid_client` on every
130
+ * endpoint from the next request on (tokens already issued stay valid until they
131
+ * expire). Returns `false` when the client does not exist.
132
+ */
133
+ async setDisabled(clientId, disabled) {
134
+ const existing = await this.#adapter.find(clientId);
135
+ if (!existing)
136
+ return false;
137
+ const payload = { ...existing };
138
+ if (disabled)
139
+ payload[CLIENT_DISABLED_KEY] = true;
140
+ else
141
+ delete payload[CLIENT_DISABLED_KEY];
142
+ await this.#adapter.upsert(clientId, payload, 0);
143
+ await this.oidc.evictDynamicClientCache();
144
+ return true;
145
+ }
122
146
  /**
123
147
  * Regenera o client_secret de um client confidencial, preservando o resto da
124
148
  * metadata. Retorna o novo secret em claro (mostrado UMA vez). Lança se o
@@ -148,6 +172,8 @@ export class AdminClientsService {
148
172
  * `client_metadata.ts`). Lança `ClientMetadataError`.
149
173
  */
150
174
  #assertValid(input) {
175
+ if (input.serviceTenant)
176
+ this.#assertServiceTenant(input);
151
177
  assertClientMetadata({
152
178
  applicationType: input.applicationType ?? 'web',
153
179
  tokenEndpointAuthMethod: input.tokenEndpointAuthMethod,
@@ -156,6 +182,27 @@ export class AdminClientsService {
156
182
  postLogoutRedirectUris: input.postLogoutRedirectUris,
157
183
  });
158
184
  }
185
+ /** Rules of a service-tenant client (see {@link ClientInput.serviceTenant}). */
186
+ #assertServiceTenant(input) {
187
+ if (!this.oidc.config.serviceTenants?.enabled) {
188
+ throw new ClientMetadataError('service tenants are not enabled: declare serviceTenants.resources in config/authkit.ts');
189
+ }
190
+ if (PUBLIC_AUTH_METHODS.has(input.tokenEndpointAuthMethod)) {
191
+ throw new ClientMetadataError('a service tenant must be confidential (it has a secret)');
192
+ }
193
+ if ((input.applicationType ?? 'web') !== 'web') {
194
+ throw new ClientMetadataError('a service tenant must be a web (server) client');
195
+ }
196
+ if (input.grantTypes.length !== 1 || input.grantTypes[0] !== 'client_credentials') {
197
+ throw new ClientMetadataError('a service tenant can only use the client_credentials grant');
198
+ }
199
+ if (input.redirectUris.length || input.postLogoutRedirectUris.length) {
200
+ throw new ClientMetadataError('a service tenant has no redirect URIs');
201
+ }
202
+ if (input.backchannelLogoutUri) {
203
+ throw new ClientMetadataError('a service tenant has no back-channel logout URI');
204
+ }
205
+ }
159
206
  /**
160
207
  * Monta o payload na forma snake_case que o oidc-provider espera/persiste —
161
208
  * verificada contra o que o registro dinâmico (RFC 7591) grava. As chaves de
@@ -193,6 +240,12 @@ export class AdminClientsService {
193
240
  payload.backchannel_logout_session_required = input.backchannelLogoutSessionRequired;
194
241
  }
195
242
  }
243
+ if (input.serviceTenant) {
244
+ const tenant = normalizeServiceTenant(input.serviceTenant);
245
+ if (typeof tenant === 'string')
246
+ throw new ClientMetadataError(tenant);
247
+ payload[SERVICE_TENANT_KEY] = tenant;
248
+ }
196
249
  return payload;
197
250
  }
198
251
  /** Projeta um payload persistido para a forma exibida no console admin. */
@@ -209,6 +262,8 @@ export class AdminClientsService {
209
262
  applicationType: p.application_type === 'native' ? 'native' : 'web',
210
263
  clientName: typeof p.client_name === 'string' && p.client_name ? p.client_name : null,
211
264
  logoUri: typeof p.logo_uri === 'string' && p.logo_uri ? p.logo_uri : null,
265
+ disabled: isClientDisabled(p),
266
+ serviceTenant: serviceTenantOf(p),
212
267
  };
213
268
  if (p.backchannel_logout_uri) {
214
269
  result.backchannelLogoutUri = p.backchannel_logout_uri;