@adonis-agora/authkit-server 1.0.0-rc.1 → 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 (98) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/README.md +4 -5
  3. package/build/commands/clients_create.d.ts +4 -0
  4. package/build/commands/clients_create.js +97 -0
  5. package/build/commands/commands.json +14 -0
  6. package/build/index.d.ts +8 -11
  7. package/build/index.js +5 -9
  8. package/build/providers/authkit_server_provider.js +6 -8
  9. package/build/src/agents/config.d.ts +9 -13
  10. package/build/src/agents/config.js +17 -11
  11. package/build/src/agents/delegation_service.js +3 -3
  12. package/build/src/agents/delegation_store.d.ts +3 -2
  13. package/build/src/agents/delegation_store.js +6 -5
  14. package/build/src/agents/extension.d.ts +261 -0
  15. package/build/src/agents/extension.js +40 -0
  16. package/build/src/agents/middleware.js +1 -1
  17. package/build/src/agents/protocol.d.ts +4 -4
  18. package/build/src/agents/registry_store.d.ts +3 -2
  19. package/build/src/agents/registry_store.js +1 -1
  20. package/build/src/agents/runtime.d.ts +26 -7
  21. package/build/src/agents/runtime.js +110 -75
  22. package/build/src/audit/audit_sink.d.ts +1 -1
  23. package/build/src/audit/audit_sink.js +6 -1
  24. package/build/src/define_config.d.ts +16 -12
  25. package/build/src/define_config.js +11 -7
  26. package/build/src/host/access_token_verifier.js +7 -0
  27. package/build/src/host/account_api/account_api_controller.js +8 -6
  28. package/build/src/host/account_session_sign_in.d.ts +12 -0
  29. package/build/src/host/account_session_sign_in.js +19 -0
  30. package/build/src/host/admin_api/api_clients_controller.d.ts +37 -0
  31. package/build/src/host/admin_api/api_clients_controller.js +31 -1
  32. package/build/src/host/admin_api/dto.d.ts +3 -1
  33. package/build/src/host/admin_api/dto.js +2 -0
  34. package/build/src/host/admin_clients_service.d.ts +20 -0
  35. package/build/src/host/admin_clients_service.js +55 -0
  36. package/build/src/host/admin_console/console_clients_controller.d.ts +41 -0
  37. package/build/src/host/admin_console/console_clients_controller.js +34 -1
  38. package/build/src/host/admin_console/console_personal_agents_controller.d.ts +1 -6
  39. package/build/src/host/admin_console/console_personal_agents_controller.js +50 -30
  40. package/build/src/host/admin_sessions_service.js +8 -5
  41. package/build/src/host/admin_validators.d.ts +29 -0
  42. package/build/src/host/admin_validators.js +30 -0
  43. package/build/src/host/auth_host_config.d.ts +6 -2
  44. package/build/src/host/controllers/account_apps_controller.js +15 -11
  45. package/build/src/host/csrf.d.ts +3 -3
  46. package/build/src/host/csrf.js +3 -13
  47. package/build/src/host/default_mailer.d.ts +11 -6
  48. package/build/src/host/default_mailer.js +16 -12
  49. package/build/src/host/i18n.d.ts +3 -14
  50. package/build/src/host/i18n.js +15 -13
  51. package/build/src/host/register_auth_host.js +30 -61
  52. package/build/src/host/ui-dist/assets/index-u0bMxkLg.js +67 -0
  53. package/build/src/host/ui-dist/index.html +1 -1
  54. package/build/src/provider/build_provider.js +37 -6
  55. package/build/src/provider/oidc_service.d.ts +1 -1
  56. package/build/src/provider/oidc_service.js +1 -1
  57. package/build/src/schema/ensure.d.ts +14 -0
  58. package/build/src/schema/ensure.js +43 -150
  59. package/build/src/service_tenants/client_metadata.d.ts +39 -0
  60. package/build/src/service_tenants/client_metadata.js +80 -0
  61. package/build/src/service_tenants/config.d.ts +44 -0
  62. package/build/src/service_tenants/config.js +44 -0
  63. package/build/src/service_tenants/provider_hooks.d.ts +52 -0
  64. package/build/src/service_tenants/provider_hooks.js +124 -0
  65. package/build/src/service_tenants/tenant_verifier.d.ts +73 -0
  66. package/build/src/service_tenants/tenant_verifier.js +104 -0
  67. package/package.json +2 -2
  68. package/build/src/agents/poppy/client_registry.d.ts +0 -50
  69. package/build/src/agents/poppy/client_registry.js +0 -260
  70. package/build/src/agents/poppy/config.d.ts +0 -396
  71. package/build/src/agents/poppy/config.js +0 -228
  72. package/build/src/agents/poppy/dpop.d.ts +0 -61
  73. package/build/src/agents/poppy/dpop.js +0 -158
  74. package/build/src/agents/poppy/email_code_sign_in.d.ts +0 -27
  75. package/build/src/agents/poppy/email_code_sign_in.js +0 -140
  76. package/build/src/agents/poppy/errors.d.ts +0 -25
  77. package/build/src/agents/poppy/errors.js +0 -25
  78. package/build/src/agents/poppy/replay.d.ts +0 -25
  79. package/build/src/agents/poppy/replay.js +0 -106
  80. package/build/src/agents/poppy/resource.d.ts +0 -100
  81. package/build/src/agents/poppy/resource.js +0 -129
  82. package/build/src/agents/poppy/safe_fetch.d.ts +0 -48
  83. package/build/src/agents/poppy/safe_fetch.js +0 -262
  84. package/build/src/agents/poppy/service.d.ts +0 -293
  85. package/build/src/agents/poppy/service.js +0 -1458
  86. package/build/src/agents/poppy/session_jwt.d.ts +0 -30
  87. package/build/src/agents/poppy/session_jwt.js +0 -133
  88. package/build/src/agents/poppy/store.d.ts +0 -106
  89. package/build/src/agents/poppy/store.js +0 -261
  90. package/build/src/agents/poppy/web_session.d.ts +0 -39
  91. package/build/src/agents/poppy/web_session.js +0 -122
  92. package/build/src/host/controllers/poppy_consent_controller.d.ts +0 -19
  93. package/build/src/host/controllers/poppy_consent_controller.js +0 -199
  94. package/build/src/host/controllers/poppy_controller.d.ts +0 -33
  95. package/build/src/host/controllers/poppy_controller.js +0 -219
  96. package/build/src/host/poppy_session_key.d.ts +0 -9
  97. package/build/src/host/poppy_session_key.js +0 -9
  98. package/build/src/host/ui-dist/assets/index-D0EIeyKK.js +0 -67
@@ -0,0 +1,124 @@
1
+ import { errors } from 'oidc-provider';
2
+ import { serviceTenantOf } from './client_metadata.js';
3
+ import { findServiceTenantResource } from './config.js';
4
+ /**
5
+ * The provider hooks behind service tenants:
6
+ *
7
+ * - a service-tenant resource is granted only to a service-tenant client, only on the
8
+ * `client_credentials` grant, always as a JWT access token (RFC 9068);
9
+ * - a service-tenant client can only get tokens for a service-tenant resource (when
10
+ * it sends no `resource` and exactly one is declared, that one is used);
11
+ * - its tokens carry the `partition` (and `capabilities`) claims read from the client
12
+ * at issuance time, so an edit applies to the next token.
13
+ */
14
+ export function createServiceTenantHooks(deps) {
15
+ const { serviceTenants } = deps;
16
+ const memo = new WeakMap();
17
+ let adapter;
18
+ const read = async (clientId) => {
19
+ adapter ??= new deps.AdapterClass('Client');
20
+ return serviceTenantOf((await adapter.find(clientId)));
21
+ };
22
+ /** Service-tenant info of the client, memoized per request. */
23
+ const tenantOf = (ctx, clientId) => {
24
+ if (!clientId)
25
+ return Promise.resolve(null);
26
+ const key = ctx && typeof ctx === 'object' ? ctx : null;
27
+ if (!key)
28
+ return read(clientId);
29
+ let byClient = memo.get(key);
30
+ if (!byClient) {
31
+ byClient = new Map();
32
+ memo.set(key, byClient);
33
+ }
34
+ let pending = byClient.get(clientId);
35
+ if (!pending) {
36
+ pending = read(clientId);
37
+ byClient.set(clientId, pending);
38
+ }
39
+ return pending;
40
+ };
41
+ return {
42
+ enabled: serviceTenants.enabled,
43
+ /**
44
+ * `defaultResource` for a service-tenant client: the only declared resource, or
45
+ * `invalid_target` when several are declared. `undefined` for any other client.
46
+ */
47
+ async defaultResource(ctx, client) {
48
+ if (!serviceTenants.enabled)
49
+ return undefined;
50
+ if (!(await tenantOf(ctx, client?.clientId)))
51
+ return undefined;
52
+ const keys = Object.keys(serviceTenants.resources);
53
+ if (keys.length === 1)
54
+ return keys[0];
55
+ throw new errors.InvalidTarget('a service tenant must send the resource parameter');
56
+ },
57
+ /**
58
+ * `getResourceServerInfo` for service tenants. Returns the info for a
59
+ * service-tenant resource, `undefined` when neither the resource nor the client is
60
+ * a service-tenant one (the caller handles it), and throws `invalid_target` for a
61
+ * mismatch.
62
+ */
63
+ async resourceServerInfo(ctx, indicator, client, signAlg) {
64
+ if (!serviceTenants.enabled)
65
+ return undefined;
66
+ const found = findServiceTenantResource(serviceTenants, indicator);
67
+ const tenant = await tenantOf(ctx, client?.clientId);
68
+ if (found) {
69
+ const grantType = ctx?.oidc?.params?.grant_type;
70
+ if (!tenant || grantType !== 'client_credentials') {
71
+ throw new errors.InvalidTarget(`resource indicator not allowed: ${indicator}`);
72
+ }
73
+ return {
74
+ scope: found.resource.scopes.join(' '),
75
+ audience: found.resource.audience,
76
+ accessTokenFormat: 'jwt',
77
+ accessTokenTTL: found.resource.expiresIn,
78
+ jwt: { sign: { alg: signAlg } },
79
+ };
80
+ }
81
+ if (tenant) {
82
+ throw new errors.InvalidTarget(`resource indicator not allowed for a service tenant: ${indicator}`);
83
+ }
84
+ return undefined;
85
+ },
86
+ /** `extraTokenClaims` for a service-tenant token; audits the issuance. */
87
+ async extraClaims(ctx, token) {
88
+ if (!serviceTenants.enabled || token?.kind !== 'ClientCredentials')
89
+ return undefined;
90
+ const rs = token.resourceServer;
91
+ const indicator = typeof rs?.identifier === 'function' ? rs.identifier() : undefined;
92
+ if (typeof indicator !== 'string')
93
+ return undefined;
94
+ const found = findServiceTenantResource(serviceTenants, indicator);
95
+ if (!found)
96
+ return undefined;
97
+ const tenant = await tenantOf(ctx, token.clientId);
98
+ if (!tenant)
99
+ return undefined;
100
+ try {
101
+ await deps.audit
102
+ ?.record({
103
+ type: 'service_tenant.token_issued',
104
+ clientId: token.clientId,
105
+ ip: typeof ctx?.ip === 'string' ? ctx.ip : null,
106
+ metadata: {
107
+ partition: tenant.partition,
108
+ resource: found.key,
109
+ jti: token.jti,
110
+ expiresIn: found.resource.expiresIn,
111
+ },
112
+ })
113
+ .catch(() => { });
114
+ }
115
+ catch {
116
+ // Auditing is best-effort: it never fails an issuance.
117
+ }
118
+ return {
119
+ partition: tenant.partition,
120
+ ...(tenant.capabilities.length ? { capabilities: tenant.capabilities } : {}),
121
+ };
122
+ },
123
+ };
124
+ }
@@ -0,0 +1,73 @@
1
+ import type { ResolvedServiceTenantsConfig } from './config.js';
2
+ /**
3
+ * What a verified tenant token authenticates as. Same shape as `VerifiedTenant` in
4
+ * `@adonis-agora/durable` (declared here: authkit never imports durable).
5
+ */
6
+ export interface VerifiedTenant {
7
+ tenant: string;
8
+ capabilities?: string[] | undefined;
9
+ }
10
+ /** The input a control plane hands to its tenant verifier. */
11
+ export interface TenantVerifierInput {
12
+ /** The token the tenant presented. */
13
+ token?: string | undefined;
14
+ /** The tenant the request claims to be. Advisory only: the token decides. */
15
+ tenant?: string | undefined;
16
+ }
17
+ /** The issuer the verifier checks against: the local authkit server. */
18
+ export interface TenantTokenIssuer {
19
+ readonly publicJwks: {
20
+ keys: Record<string, any>[];
21
+ };
22
+ readonly config: {
23
+ issuer: string;
24
+ serviceTenants?: ResolvedServiceTenantsConfig;
25
+ };
26
+ }
27
+ export interface AuthkitTenantVerifierOptions {
28
+ /**
29
+ * Expected `aud`. Default: the audience of the only resource in
30
+ * `serviceTenants.resources` (required when several are declared).
31
+ */
32
+ audience?: string;
33
+ /** Claim holding the partition. Default: `'partition'`. */
34
+ partitionClaim?: string;
35
+ /** Claim holding the capabilities. Default: `'capabilities'`. */
36
+ capabilitiesClaim?: string;
37
+ /** Allowed clock skew in seconds for `exp`/`nbf`/`iat`. Default: 30. */
38
+ clockToleranceSec?: number;
39
+ /**
40
+ * Where the issuer comes from. Default: the `authkit.server` binding of the booted
41
+ * app. Read on every call, so a signing-key rotation applies right away.
42
+ */
43
+ issuer?: () => TenantTokenIssuer | Promise<TenantTokenIssuer>;
44
+ }
45
+ /**
46
+ * Verifies service-tenant tokens against the local authkit issuer and its JWKS (no
47
+ * network call), and returns the tenant they authenticate as, or `null`.
48
+ *
49
+ * Satisfies durable's `TenantVerifierContract` (`verify(input)`); usable as a class in
50
+ * `verifyTenant` (built by the container with the default options) or through
51
+ * {@link authkitTenantVerifier}.
52
+ */
53
+ export declare class AuthkitTenantVerifier {
54
+ #private;
55
+ constructor(options?: AuthkitTenantVerifierOptions);
56
+ verify(input: TenantVerifierInput): Promise<VerifiedTenant | null>;
57
+ }
58
+ /** A tenant verifier usable both as a function and as an object with `verify`. */
59
+ export type AuthkitTenantVerifierFn = ((input: TenantVerifierInput) => Promise<VerifiedTenant | null>) & {
60
+ verify(input: TenantVerifierInput): Promise<VerifiedTenant | null>;
61
+ };
62
+ /**
63
+ * Tenant verifier for a durable control plane hosted by the authkit app:
64
+ *
65
+ * ```ts
66
+ * // config/durable.ts
67
+ * verifyTenant: authkitTenantVerifier()
68
+ * ```
69
+ *
70
+ * The result is a function (durable's `TenantVerifier`) that also has `verify`
71
+ * (durable's `TenantVerifierContract`).
72
+ */
73
+ export declare function authkitTenantVerifier(options?: AuthkitTenantVerifierOptions): AuthkitTenantVerifierFn;
@@ -0,0 +1,104 @@
1
+ import { createLocalJWKSet, errors as joseErrors, jwtVerify } from 'jose';
2
+ import { getBootedApp } from '../../services/booted_app.js';
3
+ /** Asymmetric JWS algorithms only: never `none`, never `HS*`. */
4
+ const ALGORITHMS = [
5
+ 'RS256',
6
+ 'RS384',
7
+ 'RS512',
8
+ 'PS256',
9
+ 'PS384',
10
+ 'PS512',
11
+ 'ES256',
12
+ 'ES384',
13
+ 'ES512',
14
+ 'EdDSA',
15
+ 'Ed25519',
16
+ ];
17
+ const defaultIssuer = async () => (await getBootedApp().container.make('authkit.server'));
18
+ function audienceFor(issuer, explicit) {
19
+ if (explicit)
20
+ return explicit;
21
+ const resources = Object.values(issuer.config.serviceTenants?.resources ?? {});
22
+ if (resources.length === 1)
23
+ return resources[0].audience;
24
+ throw new Error(resources.length === 0
25
+ ? 'authkitTenantVerifier: no serviceTenants.resources declared in config/authkit.ts'
26
+ : 'authkitTenantVerifier: several serviceTenants.resources are declared; pass { audience }');
27
+ }
28
+ /**
29
+ * Verifies service-tenant tokens against the local authkit issuer and its JWKS (no
30
+ * network call), and returns the tenant they authenticate as, or `null`.
31
+ *
32
+ * Satisfies durable's `TenantVerifierContract` (`verify(input)`); usable as a class in
33
+ * `verifyTenant` (built by the container with the default options) or through
34
+ * {@link authkitTenantVerifier}.
35
+ */
36
+ export class AuthkitTenantVerifier {
37
+ #options;
38
+ #jwks;
39
+ #keySet;
40
+ constructor(options = {}) {
41
+ this.#options = options;
42
+ }
43
+ #keySetFor(jwks) {
44
+ // `publicJwks` is a new object whenever the keys change (rotation/reload).
45
+ if (jwks !== this.#jwks || !this.#keySet) {
46
+ this.#jwks = jwks;
47
+ this.#keySet = createLocalJWKSet(jwks);
48
+ }
49
+ return this.#keySet;
50
+ }
51
+ async verify(input) {
52
+ const token = input?.token;
53
+ if (typeof token !== 'string' || !token)
54
+ return null;
55
+ const issuer = await (this.#options.issuer ?? defaultIssuer)();
56
+ const audience = audienceFor(issuer, this.#options.audience);
57
+ let payload;
58
+ try {
59
+ ({ payload } = await jwtVerify(token, this.#keySetFor(issuer.publicJwks), {
60
+ issuer: issuer.config.issuer,
61
+ audience,
62
+ algorithms: ALGORITHMS,
63
+ typ: 'at+jwt',
64
+ clockTolerance: this.#options.clockToleranceSec ?? 30,
65
+ requiredClaims: ['exp', 'sub'],
66
+ }));
67
+ }
68
+ catch (error) {
69
+ if (error instanceof joseErrors.JOSEError)
70
+ return null;
71
+ throw error;
72
+ }
73
+ // A client_credentials token: no user behind it, `sub` is the client.
74
+ const clientId = payload.client_id;
75
+ if (typeof clientId !== 'string' || payload.sub !== clientId)
76
+ return null;
77
+ const partition = payload[this.#options.partitionClaim ?? 'partition'];
78
+ if (typeof partition !== 'string' || !partition)
79
+ return null;
80
+ const rawCapabilities = payload[this.#options.capabilitiesClaim ?? 'capabilities'];
81
+ if (rawCapabilities === undefined)
82
+ return { tenant: partition };
83
+ if (!Array.isArray(rawCapabilities) || rawCapabilities.some((c) => typeof c !== 'string')) {
84
+ return null;
85
+ }
86
+ return { tenant: partition, capabilities: rawCapabilities };
87
+ }
88
+ }
89
+ /**
90
+ * Tenant verifier for a durable control plane hosted by the authkit app:
91
+ *
92
+ * ```ts
93
+ * // config/durable.ts
94
+ * verifyTenant: authkitTenantVerifier()
95
+ * ```
96
+ *
97
+ * The result is a function (durable's `TenantVerifier`) that also has `verify`
98
+ * (durable's `TenantVerifierContract`).
99
+ */
100
+ export function authkitTenantVerifier(options = {}) {
101
+ const verifier = new AuthkitTenantVerifier(options);
102
+ const verify = (input) => verifier.verify(input);
103
+ return Object.assign(verify, { verify });
104
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adonis-agora/authkit-server",
3
- "version": "1.0.0-rc.1",
3
+ "version": "1.0.0-rc.3",
4
4
  "description": "AdonisJS OIDC/OAuth2 provider (Identity Provider) toolkit: ejectable auth server with sessions, rate-limiting, MFA/TOTP, audit log, federated logout and OpenTelemetry metrics.",
5
5
  "license": "MIT",
6
6
  "author": "dudousxd",
@@ -117,7 +117,7 @@
117
117
  "qrcode": "1.5.4"
118
118
  },
119
119
  "devDependencies": {
120
- "@adonis-agora/authkit-react": "1.0.0-rc.0",
120
+ "@adonis-agora/authkit-react": "1.0.0-rc.2",
121
121
  "@adonis-agora/durable": "0.59.0",
122
122
  "@adonis-agora/telescope": "0.27.1",
123
123
  "@adonisjs/ally": "6.3.2",
@@ -1,50 +0,0 @@
1
- /**
2
- * Identidade do personal agent no Poppy (§4.1): o `client_id` é uma URL HTTPS
3
- * que devolve o documento de metadata do agente (OAuth Client ID Metadata
4
- * Document). Buscamos (à prova de SSRF), validamos, guardamos em cache e
5
- * importamos as chaves públicas do `jwks_uri`.
6
- */
7
- import { type JWTVerifyGetKey } from 'jose';
8
- import type { ResolvedPoppyConfig } from './config.js';
9
- import { type JsonFetcher } from './safe_fetch.js';
10
- export interface PoppyClient {
11
- clientId: string;
12
- /** `client_name`, ou o host do `client_id`. */
13
- name: string;
14
- logoUri: string | null;
15
- jwksUri: string;
16
- redirectUris: string[];
17
- extensions: Record<string, unknown>;
18
- /** Chaves públicas do `jwks_uri`. */
19
- keys: JWTVerifyGetKey;
20
- }
21
- /**
22
- * `redirect_uri` igual a uma entrada de `redirect_uris`; numa loopback redirect URI a porta não
23
- * conta (§4.2, RFC 8252 §7.3).
24
- */
25
- export declare function redirectUriAllowed(registered: string[], redirectUri: string): boolean;
26
- /** Valida um documento de metadata e devolve os campos usados. Lança `invalid_client`. */
27
- export declare function validateClientMetadata(fetchedFrom: string, doc: unknown, options?: {
28
- allowHttp?: boolean;
29
- }): Omit<PoppyClient, 'keys'>;
30
- export interface PoppyClientRegistryOptions {
31
- /** Busca de JSON. Default: {@link safeFetchJson}. */
32
- fetchJson?: JsonFetcher;
33
- now?: () => number;
34
- }
35
- /**
36
- * Resolve e valida agentes pelo `client_id`, com cache (respeita `max-age`,
37
- * entre 1 min e 24 h; falhas ficam 1 min em cache para que o mesmo `client_id`
38
- * não vire uma busca de saída por request).
39
- */
40
- export declare class PoppyClientRegistry {
41
- #private;
42
- constructor(cfg: ResolvedPoppyConfig, options?: PoppyClientRegistryOptions);
43
- /**
44
- * O agente, ou `invalid_client`: `client_id` não é HTTPS, bloqueado, não
45
- * registrado (quando o app exige registro), metadata inválida/inalcançável.
46
- */
47
- resolve(clientId: unknown): Promise<PoppyClient>;
48
- /** Nome de exibição sem falhar (console de conta): metadata em cache/buscada, ou o host. */
49
- displayName(clientId: string): Promise<string>;
50
- }
@@ -1,260 +0,0 @@
1
- /**
2
- * Identidade do personal agent no Poppy (§4.1): o `client_id` é uma URL HTTPS
3
- * que devolve o documento de metadata do agente (OAuth Client ID Metadata
4
- * Document). Buscamos (à prova de SSRF), validamos, guardamos em cache e
5
- * importamos as chaves públicas do `jwks_uri`.
6
- */
7
- import { createLocalJWKSet } from 'jose';
8
- import { PoppyError } from './errors.js';
9
- import { insecureDevFetchJson, SafeFetchError, safeFetchJson, } from './safe_fetch.js';
10
- const MIN_TTL_MS = 60 * 1000;
11
- const MAX_TTL_MS = 24 * 3600 * 1000;
12
- const DEFAULT_TTL_MS = 3600 * 1000;
13
- const FAILURE_TTL_MS = 60 * 1000;
14
- const MAX_CACHED = 1000;
15
- /** Releitura do JWKS quando o `kid` não está no conjunto em cache — no máximo a cada 30 s. */
16
- const JWKS_REFRESH_COOLDOWN_MS = 30 * 1000;
17
- function hostOf(url) {
18
- try {
19
- return new URL(url).host.toLowerCase();
20
- }
21
- catch {
22
- return null;
23
- }
24
- }
25
- function isHttpsUrl(value, allowHttp = false) {
26
- if (typeof value !== 'string')
27
- return false;
28
- try {
29
- const url = new URL(value);
30
- const schemeOk = url.protocol === 'https:' || (allowHttp && url.protocol === 'http:');
31
- return schemeOk && !url.username && !url.password && !url.hash;
32
- }
33
- catch {
34
- return false;
35
- }
36
- }
37
- /** Loopback redirect URI (RFC 8252 §7.3): `http`, IP de loopback literal, sem fragmento. */
38
- function isLoopbackRedirect(value) {
39
- if (typeof value !== 'string')
40
- return false;
41
- try {
42
- const url = new URL(value);
43
- return (url.protocol === 'http:' &&
44
- (url.hostname === '127.0.0.1' || url.hostname === '[::1]') &&
45
- !url.username &&
46
- !url.password &&
47
- !url.hash);
48
- }
49
- catch {
50
- return false;
51
- }
52
- }
53
- /**
54
- * `redirect_uri` igual a uma entrada de `redirect_uris`; numa loopback redirect URI a porta não
55
- * conta (§4.2, RFC 8252 §7.3).
56
- */
57
- export function redirectUriAllowed(registered, redirectUri) {
58
- if (registered.includes(redirectUri))
59
- return true;
60
- if (!isLoopbackRedirect(redirectUri))
61
- return false;
62
- const withoutPort = (value) => {
63
- const url = new URL(value);
64
- url.port = '';
65
- return url.toString();
66
- };
67
- return registered.some((r) => isLoopbackRedirect(r) && withoutPort(r) === withoutPort(redirectUri));
68
- }
69
- function ttlFrom(maxAge) {
70
- if (maxAge === null)
71
- return DEFAULT_TTL_MS;
72
- return Math.min(MAX_TTL_MS, Math.max(MIN_TTL_MS, maxAge * 1000));
73
- }
74
- function setBounded(map, key, value) {
75
- map.delete(key);
76
- map.set(key, value);
77
- if (map.size > MAX_CACHED)
78
- map.delete(map.keys().next().value);
79
- }
80
- /** Valida um documento de metadata e devolve os campos usados. Lança `invalid_client`. */
81
- export function validateClientMetadata(fetchedFrom, doc, options = {}) {
82
- const allowHttp = options.allowHttp === true;
83
- const bad = (why) => new PoppyError('invalid_client', `client metadata: ${why}`);
84
- if (!doc || typeof doc !== 'object' || Array.isArray(doc))
85
- throw bad('not a JSON object');
86
- const d = doc;
87
- if (d.client_id !== fetchedFrom)
88
- throw bad('client_id must equal the URL it was fetched from');
89
- const host = hostOf(fetchedFrom);
90
- if (!isHttpsUrl(d.jwks_uri, allowHttp) || hostOf(d.jwks_uri) !== host) {
91
- throw bad('jwks_uri must be an HTTPS URL on the same domain as client_id');
92
- }
93
- const redirects = d.redirect_uris ?? [];
94
- if (!Array.isArray(redirects))
95
- throw bad('redirect_uris must be an array');
96
- for (const uri of redirects) {
97
- if (isLoopbackRedirect(uri))
98
- continue;
99
- if (!isHttpsUrl(uri, allowHttp) || hostOf(uri) !== host) {
100
- throw bad('every redirect_uri must be an HTTPS URL on the same domain as client_id, or a loopback URI');
101
- }
102
- }
103
- if (d.token_endpoint_auth_method !== 'private_key_jwt') {
104
- throw bad('token_endpoint_auth_method must be private_key_jwt');
105
- }
106
- if (d.jwks !== undefined)
107
- throw bad('keys are published at jwks_uri only');
108
- const name = typeof d.client_name === 'string' && d.client_name.trim() ? d.client_name : host;
109
- const extensions = d.extensions && typeof d.extensions === 'object' && !Array.isArray(d.extensions)
110
- ? d.extensions
111
- : {};
112
- return {
113
- clientId: fetchedFrom,
114
- name: name.slice(0, 200),
115
- logoUri: isHttpsUrl(d.logo_uri, allowHttp) ? d.logo_uri : null,
116
- jwksUri: d.jwks_uri,
117
- redirectUris: redirects,
118
- extensions,
119
- };
120
- }
121
- /** Só chaves públicas (§4.1) e pelo menos uma. */
122
- function validateJwks(doc) {
123
- const keys = doc?.keys;
124
- if (!Array.isArray(keys) || keys.length === 0) {
125
- throw new PoppyError('invalid_client', 'jwks_uri must serve a non-empty JWK set');
126
- }
127
- for (const key of keys) {
128
- if (!key || typeof key !== 'object')
129
- throw new PoppyError('invalid_client', 'invalid JWK');
130
- const k = key;
131
- if ('d' in k || 'p' in k || 'q' in k || k.kty === 'oct') {
132
- throw new PoppyError('invalid_client', 'jwks_uri must contain only public keys');
133
- }
134
- }
135
- return { keys: keys };
136
- }
137
- /**
138
- * Resolve e valida agentes pelo `client_id`, com cache (respeita `max-age`,
139
- * entre 1 min e 24 h; falhas ficam 1 min em cache para que o mesmo `client_id`
140
- * não vire uma busca de saída por request).
141
- */
142
- export class PoppyClientRegistry {
143
- #cfg;
144
- #fetch;
145
- #now;
146
- #clients = new Map();
147
- #jwks = new Map();
148
- constructor(cfg, options = {}) {
149
- this.#cfg = cfg;
150
- this.#fetch =
151
- options.fetchJson ??
152
- (cfg.allowInsecureClientIds
153
- ? (url) => insecureDevFetchJson(url)
154
- : (url) => safeFetchJson(url));
155
- this.#now = options.now ?? Date.now;
156
- }
157
- /**
158
- * O agente, ou `invalid_client`: `client_id` não é HTTPS, bloqueado, não
159
- * registrado (quando o app exige registro), metadata inválida/inalcançável.
160
- */
161
- async resolve(clientId) {
162
- if (!isHttpsUrl(clientId, this.#cfg.allowInsecureClientIds)) {
163
- throw new PoppyError('invalid_client', 'client_id must be an HTTPS URL');
164
- }
165
- if (await this.#cfg.clients.isBlocked(clientId)) {
166
- throw new PoppyError('invalid_client', 'client_id is blocked');
167
- }
168
- if (this.#cfg.clients.requireRegistration &&
169
- !(await this.#cfg.clients.isRegistered(clientId))) {
170
- throw new PoppyError('invalid_client', 'client_id is not registered');
171
- }
172
- const meta = await this.#metadata(clientId);
173
- return { ...meta, keys: this.#keySet(meta.jwksUri) };
174
- }
175
- /** Nome de exibição sem falhar (console de conta): metadata em cache/buscada, ou o host. */
176
- async displayName(clientId) {
177
- try {
178
- return (await this.#metadata(clientId)).name;
179
- }
180
- catch {
181
- return hostOf(clientId) ?? clientId;
182
- }
183
- }
184
- #metadata(clientId) {
185
- const now = this.#now();
186
- const cached = this.#clients.get(clientId);
187
- if (cached && cached.expiresAt > now)
188
- return cached.value;
189
- const entry = {
190
- value: Promise.resolve(null),
191
- expiresAt: now + FAILURE_TTL_MS,
192
- };
193
- entry.value = (async () => {
194
- let fetched;
195
- try {
196
- fetched = await this.#fetch(clientId);
197
- }
198
- catch (error) {
199
- const why = error instanceof SafeFetchError ? error.message : 'unreachable';
200
- throw new PoppyError('invalid_client', `client metadata: ${why}`);
201
- }
202
- const meta = validateClientMetadata(clientId, fetched.body, {
203
- allowHttp: this.#cfg.allowInsecureClientIds,
204
- });
205
- entry.expiresAt = this.#now() + ttlFrom(fetched.maxAge);
206
- return meta;
207
- })();
208
- entry.value.catch(() => {
209
- entry.expiresAt = this.#now() + FAILURE_TTL_MS;
210
- });
211
- setBounded(this.#clients, clientId, entry);
212
- return entry.value;
213
- }
214
- #fetchJwks(jwksUri, force) {
215
- const now = this.#now();
216
- const cached = this.#jwks.get(jwksUri);
217
- if (cached && cached.expiresAt > now && !force)
218
- return cached.value;
219
- if (cached && force && now - cached.fetchedAt < JWKS_REFRESH_COOLDOWN_MS)
220
- return cached.value;
221
- const entry = {
222
- value: Promise.resolve({ keys: [] }),
223
- expiresAt: now + FAILURE_TTL_MS,
224
- fetchedAt: now,
225
- };
226
- entry.value = (async () => {
227
- let fetched;
228
- try {
229
- fetched = await this.#fetch(jwksUri);
230
- }
231
- catch (error) {
232
- const why = error instanceof SafeFetchError ? error.message : 'unreachable';
233
- throw new PoppyError('invalid_client', `jwks_uri: ${why}`);
234
- }
235
- const jwks = validateJwks(fetched.body);
236
- entry.expiresAt = this.#now() + ttlFrom(fetched.maxAge);
237
- return jwks;
238
- })();
239
- entry.value.catch(() => {
240
- entry.expiresAt = this.#now() + FAILURE_TTL_MS;
241
- });
242
- setBounded(this.#jwks, jwksUri, entry);
243
- return entry.value;
244
- }
245
- /** `JWTVerifyGetKey` que relê o JWKS uma vez quando o `kid` é desconhecido (rotação). */
246
- #keySet(jwksUri) {
247
- return async (header, token) => {
248
- const jwks = await this.#fetchJwks(jwksUri, false);
249
- try {
250
- return await createLocalJWKSet(jwks)(header, token);
251
- }
252
- catch (error) {
253
- const refreshed = await this.#fetchJwks(jwksUri, true);
254
- if (refreshed === jwks)
255
- throw error;
256
- return createLocalJWKSet(refreshed)(header, token);
257
- }
258
- };
259
- }
260
- }