@adonis-agora/authkit-core 0.10.1 → 1.0.0-rc.1
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 +231 -0
- package/build/index.d.ts +6 -1
- package/build/index.js +3 -0
- package/build/src/account_extensions/contract.d.ts +324 -0
- package/build/src/account_extensions/contract.js +37 -0
- package/build/src/account_extensions/remote.d.ts +137 -0
- package/build/src/account_extensions/remote.js +286 -0
- package/build/src/account_extensions/validate.d.ts +30 -0
- package/build/src/account_extensions/validate.js +161 -0
- package/build/src/types/server_config.d.ts +0 -34
- package/package.json +6 -5
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
# @adonis-agora/authkit-core
|
|
2
|
+
|
|
3
|
+
## 1.0.0-rc.1
|
|
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
|
+
## 1.0.0-rc.0
|
|
14
|
+
|
|
15
|
+
### Major Changes
|
|
16
|
+
|
|
17
|
+
- [#436](https://github.com/DavideCarvalho/adonis-agora-authkit/pull/436) [`c942fe3`](https://github.com/DavideCarvalho/adonis-agora-authkit/commit/c942fe30483346217c9c89112e13db0d4a65c647) Thanks [@DavideCarvalho](https://github.com/DavideCarvalho)! - AuthKit 1.0. See the [Migrating to 1.0](https://github.com/DavideCarvalho/adonis-agora-authkit/blob/main/apps/docs/content/docs/migrating-to-1-0.mdx) guide.
|
|
18
|
+
|
|
19
|
+
Breaking changes (`@adonis-agora/authkit-server`):
|
|
20
|
+
|
|
21
|
+
- `withMfa` is removed. MFA state lives in the lib-owned `auth_mfa` table, so drop the mixin from your `AuthUser` model's `compose(...)`.
|
|
22
|
+
- `markSudo` is no longer exported. Grant sudo with `completeSudo(sudoContextFrom(ctx), methodId)`, which also writes the `sudo.confirmed` audit event, remembers the method and redirects to the validated `return_to`.
|
|
23
|
+
- The email-change confirmation link is sent through `mail.onEmailChangeConfirm` only. Without it, AuthKit sends its built-in email; `onEmailVerification` no longer receives email-change confirmations.
|
|
24
|
+
- The `clients` option of `defineConfig` is removed. Create clients in the admin console, through the Admin API or with `node ace authkit:clients:create` (new `--client-secret` flag keeps an existing secret). `buildImpersonationPanel(cfg, targetId, clients)` now requires `clients`, and the impersonation curl always shows the `<CLIENT_SECRET>` placeholder.
|
|
25
|
+
- `inertiaRenderer({ prefix, views })`: `views` is required. Screens not listed render with the built-in Edge view. `AuthkitScreen` is now the exact union of the screens AuthKit renders (adds `throttled`, `agents/consent`, `agents/done`; no arbitrary strings), and every screen's props are exported: `AuthkitScreenProps['<view>']` plus named types (`LoginProps`, `SessionExpiredProps`, …). The controllers are checked against them. Hosts declare screens of their own by augmenting `AuthkitScreenProps` (`declare module '@adonis-agora/authkit-server'`).
|
|
26
|
+
- The Admin API client body reads `grantTypes` only; the `grants` alias is gone (responses still return `grants`).
|
|
27
|
+
- `lucidAccountStore` always mounts password history over the model's connection (`auth_password_history`).
|
|
28
|
+
- `/account/orgs/json`, `/account/orgs/invitations/json` and `/account/orgs/:id/json` are removed; use `/account/api/orgs/*`.
|
|
29
|
+
- `GET /account/api/apps` entries gain `name` and `logoUrl`.
|
|
30
|
+
|
|
31
|
+
- The admin console reads its config from a `<script type="application/json" id="authkit-admin-config">` data block (CSP-safe) instead of an inline `window.__AUTHKIT__` script; the dev fallback is gone.
|
|
32
|
+
- The admin client DTO returns `grantTypes` (was `grants`), plus `clientName` and `logoUri`, which the console and the Admin API can now set (`null` removes them, as it does `backchannelLogoutUri`). `GET {admin}/api/clients/:id` exists.
|
|
33
|
+
- Anonymous JSON callers of the admin and account JSON APIs get `401` (and `403` without an admin role) instead of a redirect to the login page.
|
|
34
|
+
- Fixes: the Redis adapter's `list()` honours the connection `keyPrefix` (sessions, apps and admin lists were empty); a password login posted without a password is an invalid-credentials answer, not a 500.
|
|
35
|
+
|
|
36
|
+
Breaking changes (`@adonis-agora/authkit-core`):
|
|
37
|
+
|
|
38
|
+
- `ClientConfig` and `ResolvedAuthServerConfig.clients` are removed.
|
|
39
|
+
|
|
40
|
+
Breaking changes (`@adonis-agora/authkit-client`):
|
|
41
|
+
|
|
42
|
+
- `@adonisjs/auth` is a required peer. `ctx.auth` is `@adonisjs/auth`, backed by the AuthKit session through `authkitClientGuard`; `ctx.authkit` (from `authkit_context_middleware`) carries the AuthKit-only data (identity, `hasGlobalRole`, active org, `toSharedProps`).
|
|
43
|
+
- `authkit_middleware`, `auth_middleware` and `silent_auth_middleware` (and their subpaths) are removed: `ctx.auth` comes from `@adonisjs/auth`, and `middleware.auth()` / `silentAuth` are the `#middleware/auth_middleware` / `#middleware/silent_auth_middleware` files `node ace configure` publishes. `require_org_middleware` stays. `@adonis-agora/authkit-client/types` no longer declares `HttpContext.auth`; type `ctx.authkit.getUser()` through the `AuthkitUserType` interface.
|
|
44
|
+
- `node ace configure @adonis-agora/authkit-client` installs `@adonisjs/auth` when missing (`--no-install` prints the command), writes `config/auth.ts` with `authkitClientGuard` + `sessionUserProvider` (`--model`, default `#models/user`; an existing file is left alone and the guard to add is printed), registers `@adonisjs/auth/auth_provider`, `initialize_auth_middleware` + `authkit_context_middleware`, and publishes the named `auth` / `silentAuth` middleware.
|
|
45
|
+
- `authkitClientGuard` takes its user from `resolveUser` when one is configured (so `lucidMirror` provisions the row on first login, and what it loads is on `ctx.auth.user`, the same instance as `ctx.authkit.getUser()`), with the provider as the fallback.
|
|
46
|
+
- New `authkitIdentityProvider()`: a user provider with no database; `ctx.auth.user` is the AuthKit `Identity`.
|
|
47
|
+
- New `authkitClientGuard({ provider, resolver })` option: a per-guard resolver for API routes (bearer JWT, opaque, PAT).
|
|
48
|
+
- `resolvers.jwt({ tokenSource: 'bearer' })` accepts `Authorization: DPoP <jwt>` with a valid `DPoP` proof for `cnf.jkt`-bound tokens, and refuses DPoP-bound tokens sent as `Bearer`.
|
|
49
|
+
- `UnauthorizedAccessConstructor` is no longer exported, and `AuthkitClientGuard` no longer takes an exception class; it always throws `@adonisjs/auth`'s `E_UNAUTHORIZED_ACCESS`.
|
|
50
|
+
|
|
51
|
+
Breaking changes (`@adonis-agora/authkit-sdk`):
|
|
52
|
+
|
|
53
|
+
- `clients.*` return `grantTypes` (was `grants`), plus `clientName` and `logoUri`.
|
|
54
|
+
|
|
55
|
+
Breaking changes (`@adonis-agora/authkit-react`):
|
|
56
|
+
|
|
57
|
+
- `createAuthkitClient()` no longer reads `window.__AUTHKIT__`: pass `baseUrl` for `client.admin.*` and `csrfToken`.
|
|
58
|
+
- Admin response types match the server: `users.remove` / `clients.remove` → `{ ok, deleted }`, `users.resetPassword` → `{ ok, email }`, `users.disable` / `enable` → the user, `SettingListResult.locked`; `AdminClient.grants` → `grantTypes` (+ `clientName`, `logoUri`); `UpdateUserInput` is `{ globalRoles? }`, `CreateUserInput` takes no `null`.
|
|
59
|
+
- New: `client.account.agents.list()` / `revoke()`, `useAccountAgentsQueryOptions`, `useRevokeAccountAgentMutationOptions`.
|
|
60
|
+
- Peers: `react` / `react-dom` `^19.0.0`, `@inertiajs/react` `^2.0.0 || ^3.0.0`.
|
|
61
|
+
|
|
62
|
+
- `useProfile`, `useSessions`, `useAuthorizedApps`, `useOrganizations`, `useOrganization`, `useOrgInvitations`, `useSwitchOrganization` and their types are removed, with the `profile`, `sessions`, `apps`, `orgs` and `orgInvitations` keys of `endpoints`. Use the query options (`useMeQueryOptions`, `useAccountSessionsQueryOptions`, `useAppsQueryOptions`, `useAccountOrgsQueryOptions`, …).
|
|
63
|
+
- `UserProfile`, `AuthorizedApps`, `OrganizationSwitcher` and `OrganizationProfile` need `QueryClientProvider` and `AuthkitClientProvider`.
|
|
64
|
+
|
|
65
|
+
All packages:
|
|
66
|
+
|
|
67
|
+
- `@adonis-agora/*` peer ranges accept 1.x and its release candidates (`<2.0.0 || >=1.0.0-rc.0 <2.0.0`): `durable`, `media`, `telescope` (authkit-server), `resilience` (authkit-client), `authkit-server` (authkit-sdk).
|
|
68
|
+
- Node.js `>=24` is required (tested on 24 and 26).
|
|
69
|
+
|
|
70
|
+
## 0.10.1
|
|
71
|
+
|
|
72
|
+
### Patch Changes
|
|
73
|
+
|
|
74
|
+
- [#361](https://github.com/DavideCarvalho/adonis-agora-authkit/pull/361) [`1b9c90c`](https://github.com/DavideCarvalho/adonis-agora-authkit/commit/1b9c90ced4356d2c754e850e307954455ddeeb3a) Thanks [@DavideCarvalho](https://github.com/DavideCarvalho)! - Declare `"sideEffects": false` so bundlers can tree-shake the package in client bundles. It has no module-level side effects.
|
|
75
|
+
|
|
76
|
+
## 0.10.0
|
|
77
|
+
|
|
78
|
+
### Minor Changes
|
|
79
|
+
|
|
80
|
+
- [#269](https://github.com/DavideCarvalho/adonis-agora-authkit/pull/269) [`b0ca480`](https://github.com/DavideCarvalho/adonis-agora-authkit/commit/b0ca4802419aba93a9f913775f697cc4075cff88) Thanks [@DavideCarvalho](https://github.com/DavideCarvalho)! - Drop Node 20, which reached end-of-life on 2026-04-30, and Node 22, which AdonisJS 7 does not support (it requires Node >=24): `engines.node` is now `>=24`. The supported Node lines are 24 and 26, and CI runs the test suite on each of them.
|
|
81
|
+
|
|
82
|
+
## 0.9.0
|
|
83
|
+
|
|
84
|
+
### Minor Changes
|
|
85
|
+
|
|
86
|
+
- [#233](https://github.com/DavideCarvalho/adonis-agora-authkit/pull/233) [`94644e6`](https://github.com/DavideCarvalho/adonis-agora-authkit/commit/94644e6b1d8d10e0fd3c4b57706cce46b70412cb) Thanks [@DavideCarvalho](https://github.com/DavideCarvalho)! - `applicationType?: 'web' | 'native'` nos tipos de client
|
|
87
|
+
|
|
88
|
+
`ClientConfig` (core) e `AdminClient`/`CreateClientInput` (react) ganham o campo opcional
|
|
89
|
+
`applicationType`, para os clientes nativos (RFC 8252) do `@adonis-agora/authkit-server`.
|
|
90
|
+
|
|
91
|
+
## 0.8.0
|
|
92
|
+
|
|
93
|
+
### Minor Changes
|
|
94
|
+
|
|
95
|
+
- 813a321: Multi-tenancy: `orgId` de primeira classe no evento, `orgId`/`orgSlug`/`orgRole` na `Identity`, e o middleware `requireOrg`
|
|
96
|
+
|
|
97
|
+
Três buracos no caminho de multi-tenancy por organização, achados ao seguir o
|
|
98
|
+
fluxo inteiro (org criada → claim → contexto → RBAC escopado):
|
|
99
|
+
|
|
100
|
+
**1. O barramento de diagnostics não dizia QUAL org.** `redactAuditEventForDiagnostics`
|
|
101
|
+
dropa `metadata` inteiro por LGPD (é campo livre e carrega PII — o e-mail do
|
|
102
|
+
convidado, por exemplo). Só que o `orgId` dos eventos de organização vivia
|
|
103
|
+
justamente ali. Resultado: um assinante de `agora:authkit:organization.created`
|
|
104
|
+
— o provisioning do `@adonis-agora/authz`, entre outros — recebia o tipo do
|
|
105
|
+
evento e nada mais, o que torna o hook inútil na prática. Além disso o
|
|
106
|
+
`organization.created` self-service auditava só `{ slug }`, sem id nenhum.
|
|
107
|
+
|
|
108
|
+
`orgId` agora é campo de primeira classe do `AuditEvent`, preenchido nos 15
|
|
109
|
+
call-sites de eventos de organização (`organization.deactivated` não tem org, é
|
|
110
|
+
a limpeza), preservado na projeção de diagnostics junto dos demais ids opacos
|
|
111
|
+
(`accountId`/`actorId`/`clientId`) e incluído no corpo do webhook. A redação de
|
|
112
|
+
PII continua intacta: `email`, `ip` e `metadata` seguem fora do barramento.
|
|
113
|
+
|
|
114
|
+
**2. A `Identity` não expunha a org.** O tenant ativo só existia em
|
|
115
|
+
`identity.raw.org_id`, sem tipo, e cada app reescrevia a derivação. Agora
|
|
116
|
+
`identity.orgId`, `identity.orgSlug` e `identity.orgRole` são campos da
|
|
117
|
+
`Identity`, preenchidos por `buildIdentityFromClaims` (portanto nos três
|
|
118
|
+
resolvers: jwt, pat e opaque). A tabela de claims aceitas — que já existia
|
|
119
|
+
duplicada dentro da ponte de contexto — virou `ORG_ID_CLAIMS` +
|
|
120
|
+
`deriveOrgId`/`deriveOrgSlug`/`deriveOrgRole` no `authkit-core`, e a ponte
|
|
121
|
+
passou a consumi-la. Os aliases de IdPs de terceiros (`tid`,
|
|
122
|
+
`organization_id`, `tenant_id`, `active_organization_id`) continuam valendo,
|
|
123
|
+
agora num lugar só.
|
|
124
|
+
|
|
125
|
+
**3. Não havia como exigir org ativa.** `RequireOrgMiddleware`
|
|
126
|
+
(`@adonis-agora/authkit-client/require_org_middleware`) barra request
|
|
127
|
+
autenticada mas sem tenant. Isso importa porque "logado" e "dentro de um
|
|
128
|
+
tenant" são coisas distintas: sem a barreira, a request chega no controller com
|
|
129
|
+
tenant indefinido e a checagem escopada do authz cai no escopo GLOBAL — o pior
|
|
130
|
+
lugar para se cair por acidente. Fail-closed, com `mode: 'api'` (403
|
|
131
|
+
`no_active_organization` em vez de redirect) e `oneOf` para restringir a um
|
|
132
|
+
conjunto conhecido de orgs.
|
|
133
|
+
|
|
134
|
+
## 0.7.1
|
|
135
|
+
|
|
136
|
+
### Patch Changes
|
|
137
|
+
|
|
138
|
+
- 1f15367: Point `homepage` and `bugs.url` at the repository's current name.
|
|
139
|
+
|
|
140
|
+
The repository was renamed to `adonis-agora-authkit`; `repository.url` followed,
|
|
141
|
+
`homepage` and `bugs.url` did not. Every package therefore shipped a manifest
|
|
142
|
+
that disagreed with itself, and the "Repository"/"Homepage" links on npm leaned
|
|
143
|
+
on GitHub's rename redirect — which lasts only until someone claims the old
|
|
144
|
+
name. All three fields now name the same repository.
|
|
145
|
+
|
|
146
|
+
## 0.7.0
|
|
147
|
+
|
|
148
|
+
### Minor Changes
|
|
149
|
+
|
|
150
|
+
- 0542665: Re-scope to @adonis-agora/authkit-\* (join the Agora ecosystem)
|
|
151
|
+
|
|
152
|
+
## 0.6.0
|
|
153
|
+
|
|
154
|
+
### Minor Changes
|
|
155
|
+
|
|
156
|
+
- 93eaf69: feat: cofre do keystore JWKS no HashiCorp Vault (KV v2). Novo driver
|
|
157
|
+
`{ driver: 'hashicorp-vault', endpoint, path, token?, mount?, field? }` — usa a API
|
|
158
|
+
HTTP do Vault (sem SDK), então mora em core como file/drive/lucid/redis. Encryption
|
|
159
|
+
at-rest fica OFF por default (o Vault tem cifra/ACL próprios; ligável p/ envelope).
|
|
160
|
+
- e2582b8: feat: cofres do keystore JWKS em Lucid e Redis. Novos drivers `jwks.store`:
|
|
161
|
+
`{ driver: 'lucid' }` (tabela dedicada `authkit_keystore`, auto-criada) e
|
|
162
|
+
`{ driver: 'redis' }` (uma key). Diferente de `file`, ambos são COMPARTILHADOS entre
|
|
163
|
+
instâncias — o melhor default para multi-instância + hot-reload (o poll lê um `head`
|
|
164
|
+
barato). Encryption at-rest (APP_KEY) ON por default nos dois. Warning no boot quando
|
|
165
|
+
`redis` é usado (exige persistência RDB/AOF). `resolveKeystoreVault` agora recebe um
|
|
166
|
+
contexto com acesso ao container (mudança de assinatura interna).
|
|
167
|
+
|
|
168
|
+
## 0.5.0
|
|
169
|
+
|
|
170
|
+
### Minor Changes
|
|
171
|
+
|
|
172
|
+
- df4b41f: feat: keystore JWKS managed com cofre pluggável + encryption at-rest (Fatia A+B)
|
|
173
|
+
|
|
174
|
+
O keystore managed deixa de ser fs-síncrono-num-path e passa por uma abstração de
|
|
175
|
+
cofre (`KeystoreVault`): `file` (default) e `drive` (`@adonisjs/drive`, bucket), com
|
|
176
|
+
contrato para cofres custom. O keystore PRIVADO agora é encriptado em repouso por
|
|
177
|
+
default (APP_KEY) para file/drive via um envelope versionado; decrypt falho lança
|
|
178
|
+
(nunca regenera em silêncio). O boot e o comando `authkit:keys:rotate` usam o mesmo
|
|
179
|
+
stack (defaults de encryption idênticos). Novidades: aviso no boot quando
|
|
180
|
+
`jwks: 'auto'` cai no fallback de disco, e idade da chave de assinatura no
|
|
181
|
+
`authkit:doctor`. Config: `jwks.store` aceita `{ driver: 'file' | 'drive' | ... }`
|
|
182
|
+
além de string, e novo `jwks.encrypt`.
|
|
183
|
+
|
|
184
|
+
Nota (0.x): sem migração de keystore legado — um `tmp/authkit_jwks.json` plaintext
|
|
185
|
+
pré-existente deve ser apagado uma vez (regenera encriptado).
|
|
186
|
+
|
|
187
|
+
## 0.4.0
|
|
188
|
+
|
|
189
|
+
### Minor Changes
|
|
190
|
+
|
|
191
|
+
- Breaking cleanup (0.x, no external consumers): every deprecation shim is gone.
|
|
192
|
+
- Policy now lives ONLY in runtime settings (DB) with library defaults — removed from config: static `clients`, lockout policy fields (`store` stays), rate-limit buckets (`enabled`/`store` stay), `notifications`, trusted-devices `enabled`/`days`, `admin.impersonation`, organizations `roles`/`allowSelfCreate`/`invitationTtlHours`, and `password.policy`/`password.checkPwned` store options (`legacyVerifier`/`pepper`/`pwnedTimeoutMs` stay — they are code/infra).
|
|
193
|
+
- Removed commands `authkit:clients:import` and the legacy `authkit:rotate-keys` alias. New `authkit:clients:create` creates OIDC clients programmatically through the configured storage (confidential secret printed once; `--public`, repeatable `--redirect-uri`/`--grant`, `--json`).
|
|
194
|
+
- Removed the no-op `passthroughParsed` option from `jsonColumn` and the `checkLegacyPolicyConfig` doctor check.
|
|
195
|
+
|
|
196
|
+
## 0.3.1
|
|
197
|
+
|
|
198
|
+
### Patch Changes
|
|
199
|
+
|
|
200
|
+
- Runtime-first administration:
|
|
201
|
+
- **Three new runtime toggles** (auth_settings-backed, with admin console cards showing effective state): `registration` (open/close self-service signup without affecting org invites or admin-created users; static fallback `registration.enabled`), `require_verified_email` (overrides `login.requireVerifiedEmail` across password/magic-link/passkey flows), and `maintenance_mode` (`{ enabled, message? }` — blocks login/signup/forgot for non-admins with a maintenance page while admin accounts keep logging in; userinfo/introspection/existing sessions keep working; the Admin API is never blocked, providing a guaranteed escape hatch). Audit events `maintenance.enabled`/`maintenance.disabled`.
|
|
202
|
+
- **Clients are now managed at runtime** (admin console + Admin REST API are the canonical path): the static `clients` config field is optional and deprecated (boot warning, doctor warning, console banner). New `authkit:clients:import` ace command (`--dry-run`) migrates config clients to the adapter preserving secrets and skipping existing ones. Booting with zero configured clients is fully supported. New doctor check warns when clients live in a volatile adapter. Backchannel logout URI/session-required are now editable via console and API.
|
|
203
|
+
|
|
204
|
+
## 0.3.0
|
|
205
|
+
|
|
206
|
+
### Minor Changes
|
|
207
|
+
|
|
208
|
+
- Round 7 — production pack + multi-tenancy:
|
|
209
|
+
- **Organizations (multi-tenancy)**: optional capability-probed tables (`auth_organizations`, `auth_organization_members`, `auth_organization_invitations`), per-org roles (owner/admin/member), email invitations with hashed tokens, active-org session cookie with `org_id`/`org_slug`/`org_role` claims in id_token/userinfo/JWT access tokens, `/account/orgs` page, admin console section, Admin REST API (`/api/authkit/v1/organizations`), SDK `organizations.*` namespace (remote + embedded), React `useOrganizations`/`useOrganization`/`useSwitchOrganization`/`useOrgInvitations` hooks plus `<OrganizationSwitcher />` and `<OrganizationProfile />` components.
|
|
210
|
+
- **LGPD/GDPR compliance**: self-service and admin account deletion with full cascade (sessions, grants, PATs, passkeys, identities, MFA, avatar) and audit anonymization (stable pseudonym), data export endpoint (`GET /account/security/export`), `login.requireVerifiedEmail` gate across password/magic-link/passkey flows, SDK `users.delete()`.
|
|
211
|
+
- **User migration & password hygiene**: transparent lazy password rehash on login, `password.legacyVerifier` hook for foreign hash formats, `authkit:users:import` ace command (JSON/NDJSON, `--dry-run`), configurable `password.policy`, HaveIBeenPwned k-anonymity breach check (fail-safe), React `usePasswordStrength` + `<PasswordStrengthMeter />`.
|
|
212
|
+
- **JWT access tokens (RFC 9068)**: `accessTokens: { format: 'jwt' }` with per-resource overrides, `verifyJwtAccessToken` local validation in the client package, `authkit:keys:rotate` command with grace-period JWKS rotation.
|
|
213
|
+
- **Bot protection**: pluggable vendor-agnostic `botProtection` config (Turnstile/hCaptcha-ready, fail-safe) on login/signup/reset.
|
|
214
|
+
- **New-device login notification**: `notifications.newDeviceEmail` + `mail.onNewDeviceLogin` hook driven by the trusted-device signal.
|
|
215
|
+
- **Console polish**: session context (user-agent/OS/IP/geo via pluggable `resolveGeo`), RFC 8693 impersonation panel (`admin.impersonation`, default off), dashboard with MAU/daily sign-ins, `GET /api/authkit/v1/stats` + SDK `stats()`.
|
|
216
|
+
|
|
217
|
+
## 0.2.0
|
|
218
|
+
|
|
219
|
+
### Minor Changes
|
|
220
|
+
|
|
221
|
+
- 1872a30: DX & ops infra:
|
|
222
|
+
- New package `@adonis-agora/authkit-testing` — test helpers for host apps:
|
|
223
|
+
`createTestIdentity`, `mintTestIdToken` + `serveJwks`/`testJwks`/`jwksFromKey`
|
|
224
|
+
(real RS256 tokens validated by a local JWKS), `fakeAuthenticator`, and a
|
|
225
|
+
capability-aware `fakeAccountStore`.
|
|
226
|
+
- `node ace authkit:doctor` — validates host config and prints ✅/⚠️/❌ findings
|
|
227
|
+
(issuer/mountPath, clients, accountStore capabilities, session, shield, ally,
|
|
228
|
+
rate-limit, admin, webauthn, jwks). Non-zero exit on errors.
|
|
229
|
+
- `node ace authkit:rotate-keys` — rotates managed JWKS signing keys via a new
|
|
230
|
+
file-backed keystore (`jwks: { source: 'managed', store }`), keeping the last
|
|
231
|
+
N public keys so pre-rotation tokens still verify.
|
package/build/index.d.ts
CHANGED
|
@@ -1,7 +1,12 @@
|
|
|
1
|
+
export type { AccountAction, AccountActionField, AccountActionResult, AccountActiveOrg, AccountBlockKind, AccountExtension, AccountExtensionContext, AccountExtensionOverride, AccountExtensionRegistry, AccountPage, AccountPageNav, AccountPageView, AccountSubject, AccountTenantLevel, AccountTenantRef, ActionRef, Block, ComponentScript, ListItem, Text, Value, } from './src/account_extensions/contract.js';
|
|
2
|
+
export { ACCOUNT_BLOCK_KINDS, ACCOUNT_EXTENSION_CONTRACT, ACCOUNT_EXTENSIONS_BINDING, } from './src/account_extensions/contract.js';
|
|
3
|
+
export type { AccountExtensionsManifest, RemoteAccount, RemoteActionManifest, RemoteCall, RemoteCallResponse, RemoteExtensionManifest, RemotePageManifest, RunAccountExtensionCallOptions, } from './src/account_extensions/remote.js';
|
|
4
|
+
export { ACCOUNT_EXTENSIONS_MANIFEST_SUBJECT, ACCOUNT_EXTENSIONS_SCOPE, ACCOUNT_EXTENSIONS_TOKEN_MAX_TTL, ACCOUNT_EXTENSIONS_TOKEN_TYP, ACCOUNT_EXTENSIONS_WELL_KNOWN, ACCOUNT_EXTENSIONS_WIRE_VERSION, accountExtensionsBodyHash, buildAccountExtensionsManifest, parseAccountExtensionCall, runAccountExtensionCall, } from './src/account_extensions/remote.js';
|
|
5
|
+
export { ACCOUNT_RESERVED_SEGMENTS, ACCOUNT_SEGMENT, AccountExtensionError, BaseAccountExtensionRegistry, failAccountExtension, validateAccountExtension, } from './src/account_extensions/validate.js';
|
|
1
6
|
export type { AuthkitMetricName } from './src/metrics.js';
|
|
2
7
|
export { AUTHKIT_METRICS } from './src/metrics.js';
|
|
3
8
|
export type { MetricsRecorder, MetricsSnapshot } from './src/metrics_recorder.js';
|
|
4
9
|
export { InMemorySnapshot, NoopRecorder } from './src/metrics_recorder.js';
|
|
5
10
|
export type { Identity, SessionResolver } from './src/types/identity.js';
|
|
6
11
|
export { deriveOrgId, deriveOrgRole, deriveOrgSlug, ORG_ID_CLAIMS, } from './src/types/identity.js';
|
|
7
|
-
export type { AccessTokenFormat, AccessTokenResourceConfig, AccessTokensConfig,
|
|
12
|
+
export type { AccessTokenFormat, AccessTokenResourceConfig, AccessTokensConfig, JwksConfig, KeystoreStoreConfig, ObservabilityConfig, ResolvedAuthServerConfig, TtlConfig, } from './src/types/server_config.js';
|
package/build/index.js
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
export { ACCOUNT_BLOCK_KINDS, ACCOUNT_EXTENSION_CONTRACT, ACCOUNT_EXTENSIONS_BINDING, } from './src/account_extensions/contract.js';
|
|
2
|
+
export { ACCOUNT_EXTENSIONS_MANIFEST_SUBJECT, ACCOUNT_EXTENSIONS_SCOPE, ACCOUNT_EXTENSIONS_TOKEN_MAX_TTL, ACCOUNT_EXTENSIONS_TOKEN_TYP, ACCOUNT_EXTENSIONS_WELL_KNOWN, ACCOUNT_EXTENSIONS_WIRE_VERSION, accountExtensionsBodyHash, buildAccountExtensionsManifest, parseAccountExtensionCall, runAccountExtensionCall, } from './src/account_extensions/remote.js';
|
|
3
|
+
export { ACCOUNT_RESERVED_SEGMENTS, ACCOUNT_SEGMENT, AccountExtensionError, BaseAccountExtensionRegistry, failAccountExtension, validateAccountExtension, } from './src/account_extensions/validate.js';
|
|
1
4
|
export { AUTHKIT_METRICS } from './src/metrics.js';
|
|
2
5
|
export { InMemorySnapshot, NoopRecorder } from './src/metrics_recorder.js';
|
|
3
6
|
export { deriveOrgId, deriveOrgRole, deriveOrgSlug, ORG_ID_CLAIMS, } from './src/types/identity.js';
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
/** Account console extensions: the contract every authkit package and contributor shares. */
|
|
2
|
+
/**
|
|
3
|
+
* Account console extensions — the surface another package (or the app) uses to add pages to the
|
|
4
|
+
* signed-in user's own console (`/account/*`).
|
|
5
|
+
*
|
|
6
|
+
* **Stable for all of 1.x.** A 1.x release only adds optional members and new block kinds. This
|
|
7
|
+
* module holds types and constants only, so a contributor can `import type` it from an internal
|
|
8
|
+
* module without a runtime dependency on authkit.
|
|
9
|
+
*
|
|
10
|
+
* A contributor registers itself from its provider's `boot()`:
|
|
11
|
+
*
|
|
12
|
+
* ```ts
|
|
13
|
+
* if (this.app.container.hasBinding('authkit.accountExtensions')) {
|
|
14
|
+
* const registry = await this.app.container.make('authkit.accountExtensions')
|
|
15
|
+
* if (registry.contract >= 1) registry.register(myAccountExtension)
|
|
16
|
+
* }
|
|
17
|
+
* ```
|
|
18
|
+
*
|
|
19
|
+
* Everything a page returns ({@link AccountPageView}, {@link AccountActionResult}) is plain JSON
|
|
20
|
+
* (dates may be `Date` or ISO strings), so the same page can also be served from another app.
|
|
21
|
+
*/
|
|
22
|
+
/** The container binding the registry is bound at. */
|
|
23
|
+
export declare const ACCOUNT_EXTENSIONS_BINDING = "authkit.accountExtensions";
|
|
24
|
+
/** The contract major this authkit implements. */
|
|
25
|
+
export declare const ACCOUNT_EXTENSION_CONTRACT = 1;
|
|
26
|
+
/** Block kinds this authkit renders. A contributor degrades when one it needs is missing. */
|
|
27
|
+
export declare const ACCOUNT_BLOCK_KINDS: readonly ['notice', 'keyValue', 'list', 'table', 'meter', 'actions', 'link', 'toolPreview', 'component'];
|
|
28
|
+
/** One of {@link ACCOUNT_BLOCK_KINDS}. */
|
|
29
|
+
export type AccountBlockKind = (typeof ACCOUNT_BLOCK_KINDS)[number];
|
|
30
|
+
/** A message key (looked up in the merged catalog) or literal text. Always escaped. */
|
|
31
|
+
export type Text = string | {
|
|
32
|
+
key: string;
|
|
33
|
+
params?: Record<string, string | number>;
|
|
34
|
+
};
|
|
35
|
+
/** The active organization (signed cookie), when organizations are on. */
|
|
36
|
+
export interface AccountActiveOrg {
|
|
37
|
+
id: string;
|
|
38
|
+
slug: string;
|
|
39
|
+
role: string;
|
|
40
|
+
}
|
|
41
|
+
/** One level of a tenant chain (outermost first). */
|
|
42
|
+
export interface AccountTenantLevel {
|
|
43
|
+
id: string;
|
|
44
|
+
kind: string;
|
|
45
|
+
slug?: string;
|
|
46
|
+
role?: string;
|
|
47
|
+
}
|
|
48
|
+
/** The tenant of the request, in the shape `@adonis-agora/context` uses. */
|
|
49
|
+
export interface AccountTenantRef {
|
|
50
|
+
id: string;
|
|
51
|
+
kind: 'organization' | 'user' | (string & {});
|
|
52
|
+
source: 'authkit' | 'app' | 'carrier' | 'legacy' | (string & {});
|
|
53
|
+
slug?: string;
|
|
54
|
+
role?: string;
|
|
55
|
+
/** The nested chain, outermost first, ending with this tenant. */
|
|
56
|
+
chain?: readonly AccountTenantLevel[];
|
|
57
|
+
}
|
|
58
|
+
/** Who the account is in the app: what billing, media or agents key their rows on. */
|
|
59
|
+
export interface AccountSubject {
|
|
60
|
+
type: string;
|
|
61
|
+
id: string;
|
|
62
|
+
}
|
|
63
|
+
/** What every `visible`, `load` and action `handle` receives. */
|
|
64
|
+
export interface AccountExtensionContext {
|
|
65
|
+
account: {
|
|
66
|
+
id: string;
|
|
67
|
+
email: string | null;
|
|
68
|
+
name: string | null;
|
|
69
|
+
roles: readonly string[];
|
|
70
|
+
};
|
|
71
|
+
/** The active org (signed cookie, membership re-checked), when organizations are on. */
|
|
72
|
+
activeOrg: AccountActiveOrg | null;
|
|
73
|
+
/** The tenant of the request: the active organization, when there is one. */
|
|
74
|
+
tenant?: AccountTenantRef | null;
|
|
75
|
+
/**
|
|
76
|
+
* The account mapped to the app's subject by `accountConsole.subject` in `config/authkit.ts`
|
|
77
|
+
* (default `{ type: 'User', id: account.id }`). A lib may still map it its own way.
|
|
78
|
+
*/
|
|
79
|
+
subject: AccountSubject;
|
|
80
|
+
/** The admin acting as this account, when an impersonation is active. Sensitive actions refuse it. */
|
|
81
|
+
impersonatedBy: string | null;
|
|
82
|
+
/** The console locale (`'en'`, `'pt-BR'`…). */
|
|
83
|
+
locale: string;
|
|
84
|
+
/** Validated query: `cursor` and `tab` only. */
|
|
85
|
+
query: {
|
|
86
|
+
cursor?: string;
|
|
87
|
+
tab?: string;
|
|
88
|
+
};
|
|
89
|
+
/** Same-origin path of a console page (`url('payments.invoices', { cursor })`). */
|
|
90
|
+
url(pageId: string, query?: Record<string, string>): string;
|
|
91
|
+
/** Resolves a container binding. */
|
|
92
|
+
make(binding: string): Promise<any>;
|
|
93
|
+
now(): Date;
|
|
94
|
+
}
|
|
95
|
+
/** A formatted value. Money is in minor units; dates are `Date` or ISO strings. */
|
|
96
|
+
export type Value = {
|
|
97
|
+
type: 'text';
|
|
98
|
+
text: Text;
|
|
99
|
+
} | {
|
|
100
|
+
type: 'money';
|
|
101
|
+
amount: number;
|
|
102
|
+
currency: string;
|
|
103
|
+
} | {
|
|
104
|
+
type: 'date';
|
|
105
|
+
at: Date | string;
|
|
106
|
+
style?: 'date' | 'datetime' | 'relative';
|
|
107
|
+
} | {
|
|
108
|
+
type: 'badge';
|
|
109
|
+
text: Text;
|
|
110
|
+
tone?: 'neutral' | 'success' | 'warning' | 'danger';
|
|
111
|
+
} | {
|
|
112
|
+
type: 'number';
|
|
113
|
+
value: number;
|
|
114
|
+
unit?: Text;
|
|
115
|
+
};
|
|
116
|
+
/** Points at `page.actions[action]`; `target` is an opaque id the handler MUST re-authorize. */
|
|
117
|
+
export interface ActionRef {
|
|
118
|
+
action: string;
|
|
119
|
+
target?: string;
|
|
120
|
+
}
|
|
121
|
+
/** An item of a `list` block. */
|
|
122
|
+
export interface ListItem {
|
|
123
|
+
title: Text;
|
|
124
|
+
subtitle?: Text;
|
|
125
|
+
meta?: Value[];
|
|
126
|
+
badge?: Value;
|
|
127
|
+
/** Same-origin path, or an https URL with `external: true`. */
|
|
128
|
+
href?: string;
|
|
129
|
+
external?: boolean;
|
|
130
|
+
actions?: ActionRef[];
|
|
131
|
+
}
|
|
132
|
+
/** A script a `component` block loads. */
|
|
133
|
+
export interface ComponentScript {
|
|
134
|
+
/** Same-origin path (`/payments/assets/card.js`) or an https URL on one of `scriptOrigins`. */
|
|
135
|
+
src: string;
|
|
136
|
+
/** Subresource integrity (`sha384-…`). */
|
|
137
|
+
integrity?: string;
|
|
138
|
+
/** Load as an ES module. */
|
|
139
|
+
module?: boolean;
|
|
140
|
+
}
|
|
141
|
+
/** One block of a page. Unknown kinds render as a notice. */
|
|
142
|
+
export type Block = {
|
|
143
|
+
kind: 'notice';
|
|
144
|
+
tone: 'info' | 'success' | 'warning' | 'danger';
|
|
145
|
+
text: Text;
|
|
146
|
+
} | {
|
|
147
|
+
kind: 'keyValue';
|
|
148
|
+
title?: Text;
|
|
149
|
+
rows: ReadonlyArray<{
|
|
150
|
+
label: Text;
|
|
151
|
+
value: Value;
|
|
152
|
+
}>;
|
|
153
|
+
actions?: ActionRef[];
|
|
154
|
+
} | {
|
|
155
|
+
kind: 'list';
|
|
156
|
+
title?: Text;
|
|
157
|
+
empty: Text;
|
|
158
|
+
items: ReadonlyArray<ListItem>;
|
|
159
|
+
nextCursor?: string | null;
|
|
160
|
+
} | {
|
|
161
|
+
kind: 'table';
|
|
162
|
+
title?: Text;
|
|
163
|
+
empty: Text;
|
|
164
|
+
columns: ReadonlyArray<{
|
|
165
|
+
id: string;
|
|
166
|
+
label: Text;
|
|
167
|
+
}>;
|
|
168
|
+
rows: ReadonlyArray<{
|
|
169
|
+
cells: Record<string, Value>;
|
|
170
|
+
actions?: ActionRef[];
|
|
171
|
+
}>;
|
|
172
|
+
nextCursor?: string | null;
|
|
173
|
+
} | {
|
|
174
|
+
kind: 'meter';
|
|
175
|
+
label: Text;
|
|
176
|
+
used: number;
|
|
177
|
+
limit: number | null;
|
|
178
|
+
unit?: Text;
|
|
179
|
+
resetsAt?: Date | string;
|
|
180
|
+
} | {
|
|
181
|
+
kind: 'actions';
|
|
182
|
+
items: ActionRef[];
|
|
183
|
+
} | {
|
|
184
|
+
kind: 'link';
|
|
185
|
+
label: Text;
|
|
186
|
+
href: string;
|
|
187
|
+
external?: boolean;
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* A structured preview of something waiting for a decision (an agent's tool call): its title,
|
|
191
|
+
* the arguments as rows, and an optional body shown verbatim (escaped).
|
|
192
|
+
*/
|
|
193
|
+
| {
|
|
194
|
+
kind: 'toolPreview';
|
|
195
|
+
title: Text;
|
|
196
|
+
tool?: string;
|
|
197
|
+
subtitle?: Text;
|
|
198
|
+
args?: ReadonlyArray<{
|
|
199
|
+
label: Text;
|
|
200
|
+
value: Value;
|
|
201
|
+
}>;
|
|
202
|
+
body?: {
|
|
203
|
+
format: 'json' | 'text';
|
|
204
|
+
content: string;
|
|
205
|
+
};
|
|
206
|
+
actions?: ActionRef[];
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* A provider's own UI (card fields of Stripe Elements, Asaas, Efí, PayPal…). authkit renders
|
|
210
|
+
* the mount point, the props as a JSON data block and the scripts with the request's CSP nonce.
|
|
211
|
+
* The script mounts into `[data-block="component"][data-provider="<provider>"]`, reads its props
|
|
212
|
+
* from the `<script type="application/json">` the mount point names in `data-props`, and submits
|
|
213
|
+
* the mount point's `<form>` (the `submit` action) with the fields it adds. Until it mounts (or
|
|
214
|
+
* when scripts are blocked) the `fallback` link shows.
|
|
215
|
+
*/
|
|
216
|
+
| {
|
|
217
|
+
kind: 'component';
|
|
218
|
+
provider: string;
|
|
219
|
+
scripts: ReadonlyArray<ComponentScript>;
|
|
220
|
+
props?: Record<string, unknown>;
|
|
221
|
+
submit?: ActionRef;
|
|
222
|
+
fallback?: {
|
|
223
|
+
label: Text;
|
|
224
|
+
href: string;
|
|
225
|
+
external?: boolean;
|
|
226
|
+
};
|
|
227
|
+
};
|
|
228
|
+
/** What `load` returns. */
|
|
229
|
+
export interface AccountPageView {
|
|
230
|
+
blocks: readonly Block[];
|
|
231
|
+
/** Tabs inside one page (`query.tab`). */
|
|
232
|
+
tabs?: ReadonlyArray<{
|
|
233
|
+
id: string;
|
|
234
|
+
label: Text;
|
|
235
|
+
}>;
|
|
236
|
+
}
|
|
237
|
+
/** A field of an action's form. Values arrive validated, as strings. */
|
|
238
|
+
export interface AccountActionField {
|
|
239
|
+
name: string;
|
|
240
|
+
label: Text;
|
|
241
|
+
kind: 'text' | 'select' | 'textarea';
|
|
242
|
+
options?: ReadonlyArray<{
|
|
243
|
+
value: string;
|
|
244
|
+
label: Text;
|
|
245
|
+
}>;
|
|
246
|
+
required?: boolean;
|
|
247
|
+
maxLength?: number;
|
|
248
|
+
}
|
|
249
|
+
/** What an action returns. */
|
|
250
|
+
export type AccountActionResult = {
|
|
251
|
+
ok: true;
|
|
252
|
+
flash?: Text;
|
|
253
|
+
redirectTo?: {
|
|
254
|
+
page: string;
|
|
255
|
+
} | {
|
|
256
|
+
external: string;
|
|
257
|
+
};
|
|
258
|
+
} | {
|
|
259
|
+
ok: false;
|
|
260
|
+
error: Text;
|
|
261
|
+
status?: 400 | 403 | 404 | 409;
|
|
262
|
+
};
|
|
263
|
+
/** An action a page offers. */
|
|
264
|
+
export interface AccountAction {
|
|
265
|
+
label: Text;
|
|
266
|
+
tone?: 'default' | 'danger';
|
|
267
|
+
/** Confirmation text, shown on a second step page (no inline JS). */
|
|
268
|
+
confirm?: Text;
|
|
269
|
+
/** Needs recent re-authentication: authkit sends the user through `/account/confirm` first. */
|
|
270
|
+
sudo?: boolean;
|
|
271
|
+
/** Simple form fields, shown on the confirmation step. */
|
|
272
|
+
fields?: ReadonlyArray<AccountActionField>;
|
|
273
|
+
handle(ctx: AccountExtensionContext, input: {
|
|
274
|
+
target: string | null;
|
|
275
|
+
fields: Record<string, string>;
|
|
276
|
+
}): Promise<AccountActionResult>;
|
|
277
|
+
}
|
|
278
|
+
/** Where a page sits in the console's navigation. */
|
|
279
|
+
export interface AccountPageNav {
|
|
280
|
+
label?: Text;
|
|
281
|
+
order?: number;
|
|
282
|
+
group?: 'account' | 'billing' | 'ai' | 'content';
|
|
283
|
+
}
|
|
284
|
+
/** One page of an extension. */
|
|
285
|
+
export interface AccountPage {
|
|
286
|
+
/** Stable id `<extension>.<page>` (`'payments.subscription'`). */
|
|
287
|
+
id: string;
|
|
288
|
+
/** URL segment under the account prefix (`'billing'` → `/account/billing`). The host can rename it. */
|
|
289
|
+
segment: string;
|
|
290
|
+
title: Text;
|
|
291
|
+
/** `false` hides it from the navigation (still reachable by URL and `url()`). */
|
|
292
|
+
nav?: AccountPageNav | false;
|
|
293
|
+
/** Show this page (and its nav entry) for this account? Default: yes. Runs once per request. */
|
|
294
|
+
visible?(ctx: AccountExtensionContext): boolean | Promise<boolean>;
|
|
295
|
+
/** Builds the page on every GET. */
|
|
296
|
+
load(ctx: AccountExtensionContext): Promise<AccountPageView>;
|
|
297
|
+
actions?: Readonly<Record<string, AccountAction>>;
|
|
298
|
+
}
|
|
299
|
+
/** An account console extension. */
|
|
300
|
+
export interface AccountExtension {
|
|
301
|
+
/** 2–16 of `a-z`, `0-9`, `-`; unique; not a built-in screen. */
|
|
302
|
+
readonly id: string;
|
|
303
|
+
/** The contract major it was written against. authkit skips a higher one. */
|
|
304
|
+
readonly contract: number;
|
|
305
|
+
/** `locale → key → text`; keys start with `<id>.`. The host's `i18n.messages` still win. */
|
|
306
|
+
messages?: Readonly<Record<string, Readonly<Record<string, string>>>>;
|
|
307
|
+
/** https origins its `component` blocks may load scripts from (`'https://js.stripe.com'`). */
|
|
308
|
+
scriptOrigins?: readonly string[];
|
|
309
|
+
pages: readonly AccountPage[];
|
|
310
|
+
}
|
|
311
|
+
/** The registry bound at {@link ACCOUNT_EXTENSIONS_BINDING}. */
|
|
312
|
+
export interface AccountExtensionRegistry {
|
|
313
|
+
readonly contract: number;
|
|
314
|
+
/** Block kinds this authkit renders. */
|
|
315
|
+
readonly blockKinds: readonly string[];
|
|
316
|
+
register(extension: AccountExtension): void;
|
|
317
|
+
}
|
|
318
|
+
/** Host controls for one extension (`accountConsole.extensions` in `config/authkit.ts`). */
|
|
319
|
+
export type AccountExtensionOverride = false | {
|
|
320
|
+
pages?: Record<string, false | {
|
|
321
|
+
segment?: string;
|
|
322
|
+
order?: number;
|
|
323
|
+
}>;
|
|
324
|
+
};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/** Account console extensions: the contract every authkit package and contributor shares. */
|
|
2
|
+
/**
|
|
3
|
+
* Account console extensions — the surface another package (or the app) uses to add pages to the
|
|
4
|
+
* signed-in user's own console (`/account/*`).
|
|
5
|
+
*
|
|
6
|
+
* **Stable for all of 1.x.** A 1.x release only adds optional members and new block kinds. This
|
|
7
|
+
* module holds types and constants only, so a contributor can `import type` it from an internal
|
|
8
|
+
* module without a runtime dependency on authkit.
|
|
9
|
+
*
|
|
10
|
+
* A contributor registers itself from its provider's `boot()`:
|
|
11
|
+
*
|
|
12
|
+
* ```ts
|
|
13
|
+
* if (this.app.container.hasBinding('authkit.accountExtensions')) {
|
|
14
|
+
* const registry = await this.app.container.make('authkit.accountExtensions')
|
|
15
|
+
* if (registry.contract >= 1) registry.register(myAccountExtension)
|
|
16
|
+
* }
|
|
17
|
+
* ```
|
|
18
|
+
*
|
|
19
|
+
* Everything a page returns ({@link AccountPageView}, {@link AccountActionResult}) is plain JSON
|
|
20
|
+
* (dates may be `Date` or ISO strings), so the same page can also be served from another app.
|
|
21
|
+
*/
|
|
22
|
+
/** The container binding the registry is bound at. */
|
|
23
|
+
export const ACCOUNT_EXTENSIONS_BINDING = 'authkit.accountExtensions';
|
|
24
|
+
/** The contract major this authkit implements. */
|
|
25
|
+
export const ACCOUNT_EXTENSION_CONTRACT = 1;
|
|
26
|
+
/** Block kinds this authkit renders. A contributor degrades when one it needs is missing. */
|
|
27
|
+
export const ACCOUNT_BLOCK_KINDS = [
|
|
28
|
+
'notice',
|
|
29
|
+
'keyValue',
|
|
30
|
+
'list',
|
|
31
|
+
'table',
|
|
32
|
+
'meter',
|
|
33
|
+
'actions',
|
|
34
|
+
'link',
|
|
35
|
+
'toolPreview',
|
|
36
|
+
'component',
|
|
37
|
+
];
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import type { AccountActionField, AccountActionResult, AccountActiveOrg, AccountExtension, AccountPageNav, AccountPageView, AccountSubject, AccountTenantRef, Text } from './contract.js';
|
|
2
|
+
/**
|
|
3
|
+
* Remote account extensions: the wire between an authkit IdP (which renders the account console)
|
|
4
|
+
* and an app that signs in through it (which owns the extensions: billing, files, agents…).
|
|
5
|
+
*
|
|
6
|
+
* The app serves `GET {WELL_KNOWN}` (the manifest) and `POST {WELL_KNOWN}` (one call: visible,
|
|
7
|
+
* load or action). The IdP calls both with a short-lived JWT it signs with its own keys:
|
|
8
|
+
* `typ` {@link ACCOUNT_EXTENSIONS_TOKEN_TYP}, `aud` = the app's client id, `scope`
|
|
9
|
+
* {@link ACCOUNT_EXTENSIONS_SCOPE}, `sub` = the account (or {@link ACCOUNT_EXTENSIONS_MANIFEST_SUBJECT}
|
|
10
|
+
* for the manifest) and `bh` = {@link accountExtensionsBodyHash} of the POST body.
|
|
11
|
+
*/
|
|
12
|
+
/** Wire version of the manifest and calls. */
|
|
13
|
+
export declare const ACCOUNT_EXTENSIONS_WIRE_VERSION = 1;
|
|
14
|
+
/** The scope every remote-extension token carries. */
|
|
15
|
+
export declare const ACCOUNT_EXTENSIONS_SCOPE = "authkit:account-extensions";
|
|
16
|
+
/** The JWT `typ` of a remote-extension token (never accepted as an access token elsewhere). */
|
|
17
|
+
export declare const ACCOUNT_EXTENSIONS_TOKEN_TYP = "agora-account-ext+jwt";
|
|
18
|
+
/** Path the app serves the manifest (GET) and the calls (POST) at. */
|
|
19
|
+
export declare const ACCOUNT_EXTENSIONS_WELL_KNOWN = "/.well-known/agora-account-extensions";
|
|
20
|
+
/** `sub` of the token that fetches the manifest (no account involved). */
|
|
21
|
+
export declare const ACCOUNT_EXTENSIONS_MANIFEST_SUBJECT = "authkit:account-console";
|
|
22
|
+
/** Longest lifetime (seconds) a remote-extension token may have. */
|
|
23
|
+
export declare const ACCOUNT_EXTENSIONS_TOKEN_MAX_TTL = 300;
|
|
24
|
+
/** One action, as the manifest describes it (the handler stays in the app). */
|
|
25
|
+
export interface RemoteActionManifest {
|
|
26
|
+
label: Text;
|
|
27
|
+
tone: 'default' | 'danger';
|
|
28
|
+
confirm: Text | null;
|
|
29
|
+
sudo: boolean;
|
|
30
|
+
fields: AccountActionField[];
|
|
31
|
+
}
|
|
32
|
+
/** One page, as the manifest describes it. */
|
|
33
|
+
export interface RemotePageManifest {
|
|
34
|
+
id: string;
|
|
35
|
+
segment: string;
|
|
36
|
+
title: Text;
|
|
37
|
+
nav: AccountPageNav | false;
|
|
38
|
+
/** The page has a `visible()`: the IdP asks before showing it. */
|
|
39
|
+
conditional: boolean;
|
|
40
|
+
actions: Record<string, RemoteActionManifest>;
|
|
41
|
+
}
|
|
42
|
+
/** One extension, as the manifest describes it. */
|
|
43
|
+
export interface RemoteExtensionManifest {
|
|
44
|
+
id: string;
|
|
45
|
+
contract: number;
|
|
46
|
+
messages: Record<string, Record<string, string>>;
|
|
47
|
+
scriptOrigins: string[];
|
|
48
|
+
pages: RemotePageManifest[];
|
|
49
|
+
}
|
|
50
|
+
/** What `GET {WELL_KNOWN}` answers. */
|
|
51
|
+
export interface AccountExtensionsManifest {
|
|
52
|
+
version: 1;
|
|
53
|
+
contract: number;
|
|
54
|
+
extensions: RemoteExtensionManifest[];
|
|
55
|
+
}
|
|
56
|
+
/** The signed-in account, as the IdP sends it. */
|
|
57
|
+
export interface RemoteAccount {
|
|
58
|
+
id: string;
|
|
59
|
+
email: string | null;
|
|
60
|
+
name: string | null;
|
|
61
|
+
roles: string[];
|
|
62
|
+
}
|
|
63
|
+
interface RemoteCallBase {
|
|
64
|
+
account: RemoteAccount;
|
|
65
|
+
activeOrg: AccountActiveOrg | null;
|
|
66
|
+
tenant: AccountTenantRef | null;
|
|
67
|
+
impersonatedBy: string | null;
|
|
68
|
+
locale: string;
|
|
69
|
+
/** IdP paths of this app's pages (`pageId → /account/billing`), for `ctx.url()`. */
|
|
70
|
+
urls: Record<string, string>;
|
|
71
|
+
}
|
|
72
|
+
/** The body of `POST {WELL_KNOWN}`. */
|
|
73
|
+
export type RemoteCall = (RemoteCallBase & {
|
|
74
|
+
op: 'visible';
|
|
75
|
+
pageIds: string[];
|
|
76
|
+
}) | (RemoteCallBase & {
|
|
77
|
+
op: 'load';
|
|
78
|
+
pageId: string;
|
|
79
|
+
query: {
|
|
80
|
+
cursor?: string;
|
|
81
|
+
tab?: string;
|
|
82
|
+
};
|
|
83
|
+
}) | (RemoteCallBase & {
|
|
84
|
+
op: 'action';
|
|
85
|
+
pageId: string;
|
|
86
|
+
action: string;
|
|
87
|
+
query: {
|
|
88
|
+
cursor?: string;
|
|
89
|
+
tab?: string;
|
|
90
|
+
};
|
|
91
|
+
input: {
|
|
92
|
+
target: string | null;
|
|
93
|
+
fields: Record<string, string>;
|
|
94
|
+
};
|
|
95
|
+
});
|
|
96
|
+
/** What a call answers (status 200), or an `error` with another status. */
|
|
97
|
+
export type RemoteCallResponse = {
|
|
98
|
+
visible: string[];
|
|
99
|
+
} | {
|
|
100
|
+
view: AccountPageView;
|
|
101
|
+
} | {
|
|
102
|
+
result: AccountActionResult;
|
|
103
|
+
} | {
|
|
104
|
+
error: {
|
|
105
|
+
code: string;
|
|
106
|
+
message: string;
|
|
107
|
+
};
|
|
108
|
+
};
|
|
109
|
+
/** `bh` claim: base64url SHA-256 of the exact POST body. */
|
|
110
|
+
export declare function accountExtensionsBodyHash(body: string): string;
|
|
111
|
+
/** The manifest of these extensions. */
|
|
112
|
+
export declare function buildAccountExtensionsManifest(extensions: readonly AccountExtension[], contract: number): AccountExtensionsManifest;
|
|
113
|
+
/** Validates a call body; `null` when it is malformed. */
|
|
114
|
+
export declare function parseAccountExtensionCall(raw: unknown): RemoteCall | null;
|
|
115
|
+
export interface RunAccountExtensionCallOptions {
|
|
116
|
+
/** Maps the account to the app's subject. Default `{ type: 'User', id: account.id }`. */
|
|
117
|
+
subject?: (account: RemoteAccount, context: {
|
|
118
|
+
activeOrg: AccountActiveOrg | null;
|
|
119
|
+
tenant: AccountTenantRef | null;
|
|
120
|
+
}) => AccountSubject | Promise<AccountSubject>;
|
|
121
|
+
/** Resolves a container binding for the extensions. */
|
|
122
|
+
make: (binding: string) => Promise<any>;
|
|
123
|
+
loadTimeoutMs?: number;
|
|
124
|
+
actionTimeoutMs?: number;
|
|
125
|
+
now?: () => Date;
|
|
126
|
+
/** Where a throwing extension is reported (never sent to the IdP). */
|
|
127
|
+
log?: (message: string) => void;
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Runs one call against the app's extensions with the same rules as in the IdP's own process:
|
|
131
|
+
* time budgets, declared action fields only, and a failure that stays inside the page.
|
|
132
|
+
*/
|
|
133
|
+
export declare function runAccountExtensionCall(extensions: readonly AccountExtension[], call: RemoteCall, options: RunAccountExtensionCallOptions): Promise<{
|
|
134
|
+
status: number;
|
|
135
|
+
body: RemoteCallResponse;
|
|
136
|
+
}>;
|
|
137
|
+
export {};
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
/**
|
|
3
|
+
* Remote account extensions: the wire between an authkit IdP (which renders the account console)
|
|
4
|
+
* and an app that signs in through it (which owns the extensions: billing, files, agents…).
|
|
5
|
+
*
|
|
6
|
+
* The app serves `GET {WELL_KNOWN}` (the manifest) and `POST {WELL_KNOWN}` (one call: visible,
|
|
7
|
+
* load or action). The IdP calls both with a short-lived JWT it signs with its own keys:
|
|
8
|
+
* `typ` {@link ACCOUNT_EXTENSIONS_TOKEN_TYP}, `aud` = the app's client id, `scope`
|
|
9
|
+
* {@link ACCOUNT_EXTENSIONS_SCOPE}, `sub` = the account (or {@link ACCOUNT_EXTENSIONS_MANIFEST_SUBJECT}
|
|
10
|
+
* for the manifest) and `bh` = {@link accountExtensionsBodyHash} of the POST body.
|
|
11
|
+
*/
|
|
12
|
+
/** Wire version of the manifest and calls. */
|
|
13
|
+
export const ACCOUNT_EXTENSIONS_WIRE_VERSION = 1;
|
|
14
|
+
/** The scope every remote-extension token carries. */
|
|
15
|
+
export const ACCOUNT_EXTENSIONS_SCOPE = 'authkit:account-extensions';
|
|
16
|
+
/** The JWT `typ` of a remote-extension token (never accepted as an access token elsewhere). */
|
|
17
|
+
export const ACCOUNT_EXTENSIONS_TOKEN_TYP = 'agora-account-ext+jwt';
|
|
18
|
+
/** Path the app serves the manifest (GET) and the calls (POST) at. */
|
|
19
|
+
export const ACCOUNT_EXTENSIONS_WELL_KNOWN = '/.well-known/agora-account-extensions';
|
|
20
|
+
/** `sub` of the token that fetches the manifest (no account involved). */
|
|
21
|
+
export const ACCOUNT_EXTENSIONS_MANIFEST_SUBJECT = 'authkit:account-console';
|
|
22
|
+
/** Longest lifetime (seconds) a remote-extension token may have. */
|
|
23
|
+
export const ACCOUNT_EXTENSIONS_TOKEN_MAX_TTL = 300;
|
|
24
|
+
/** `bh` claim: base64url SHA-256 of the exact POST body. */
|
|
25
|
+
export function accountExtensionsBodyHash(body) {
|
|
26
|
+
return createHash('sha256').update(body).digest('base64url');
|
|
27
|
+
}
|
|
28
|
+
/** The manifest of these extensions. */
|
|
29
|
+
export function buildAccountExtensionsManifest(extensions, contract) {
|
|
30
|
+
return {
|
|
31
|
+
version: ACCOUNT_EXTENSIONS_WIRE_VERSION,
|
|
32
|
+
contract,
|
|
33
|
+
extensions: extensions.map((extension) => ({
|
|
34
|
+
id: extension.id,
|
|
35
|
+
contract: extension.contract,
|
|
36
|
+
messages: Object.fromEntries(Object.entries(extension.messages ?? {}).map(([locale, catalog]) => [
|
|
37
|
+
locale,
|
|
38
|
+
{ ...catalog },
|
|
39
|
+
])),
|
|
40
|
+
scriptOrigins: [...(extension.scriptOrigins ?? [])],
|
|
41
|
+
pages: extension.pages.map((page) => ({
|
|
42
|
+
id: page.id,
|
|
43
|
+
segment: page.segment,
|
|
44
|
+
title: page.title,
|
|
45
|
+
nav: page.nav === false ? false : { ...(page.nav ?? {}) },
|
|
46
|
+
conditional: typeof page.visible === 'function',
|
|
47
|
+
actions: Object.fromEntries(Object.entries(page.actions ?? {}).map(([name, action]) => [
|
|
48
|
+
name,
|
|
49
|
+
{
|
|
50
|
+
label: action.label,
|
|
51
|
+
tone: action.tone === 'danger' ? 'danger' : 'default',
|
|
52
|
+
confirm: action.confirm ?? null,
|
|
53
|
+
sudo: action.sudo === true,
|
|
54
|
+
fields: (action.fields ?? []).map((field) => ({ ...field })),
|
|
55
|
+
},
|
|
56
|
+
])),
|
|
57
|
+
})),
|
|
58
|
+
})),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
// ── parsing a call ────────────────────────────────────────────────────────────────────────────────
|
|
62
|
+
const isObject = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
63
|
+
const str = (v, max = 512) => typeof v === 'string' && v.length > 0 && v.length <= max ? v : null;
|
|
64
|
+
const strOrNull = (v, max = 512) => (v === null ? null : str(v, max));
|
|
65
|
+
function parseQuery(raw) {
|
|
66
|
+
const out = {};
|
|
67
|
+
if (!isObject(raw))
|
|
68
|
+
return out;
|
|
69
|
+
const cursor = str(raw.cursor);
|
|
70
|
+
if (cursor)
|
|
71
|
+
out.cursor = cursor;
|
|
72
|
+
const tab = raw.tab;
|
|
73
|
+
if (typeof tab === 'string' && /^[a-zA-Z0-9_-]{1,64}$/.test(tab))
|
|
74
|
+
out.tab = tab;
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
77
|
+
function parseOrg(raw) {
|
|
78
|
+
if (!isObject(raw))
|
|
79
|
+
return null;
|
|
80
|
+
const id = str(raw.id);
|
|
81
|
+
if (!id)
|
|
82
|
+
return null;
|
|
83
|
+
return { id, slug: String(raw.slug ?? ''), role: String(raw.role ?? '') };
|
|
84
|
+
}
|
|
85
|
+
function parseTenant(raw) {
|
|
86
|
+
if (!isObject(raw))
|
|
87
|
+
return null;
|
|
88
|
+
const id = str(raw.id);
|
|
89
|
+
const kind = str(raw.kind, 64);
|
|
90
|
+
if (!id || !kind)
|
|
91
|
+
return null;
|
|
92
|
+
const tenant = { id, kind, source: str(raw.source, 64) ?? 'authkit' };
|
|
93
|
+
const slug = str(raw.slug);
|
|
94
|
+
if (slug)
|
|
95
|
+
tenant.slug = slug;
|
|
96
|
+
const role = str(raw.role, 128);
|
|
97
|
+
if (role)
|
|
98
|
+
tenant.role = role;
|
|
99
|
+
if (Array.isArray(raw.chain)) {
|
|
100
|
+
tenant.chain = raw.chain.filter(isObject).flatMap((level) => {
|
|
101
|
+
const levelId = str(level.id);
|
|
102
|
+
const levelKind = str(level.kind, 64);
|
|
103
|
+
if (!levelId || !levelKind)
|
|
104
|
+
return [];
|
|
105
|
+
const out = { id: levelId, kind: levelKind };
|
|
106
|
+
const levelSlug = str(level.slug);
|
|
107
|
+
if (levelSlug)
|
|
108
|
+
out.slug = levelSlug;
|
|
109
|
+
const levelRole = str(level.role, 128);
|
|
110
|
+
if (levelRole)
|
|
111
|
+
out.role = levelRole;
|
|
112
|
+
return [out];
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
return tenant;
|
|
116
|
+
}
|
|
117
|
+
/** Validates a call body; `null` when it is malformed. */
|
|
118
|
+
export function parseAccountExtensionCall(raw) {
|
|
119
|
+
if (!isObject(raw) || !isObject(raw.account))
|
|
120
|
+
return null;
|
|
121
|
+
const accountId = str(raw.account.id);
|
|
122
|
+
if (!accountId)
|
|
123
|
+
return null;
|
|
124
|
+
const roles = Array.isArray(raw.account.roles)
|
|
125
|
+
? raw.account.roles.filter((r) => typeof r === 'string')
|
|
126
|
+
: [];
|
|
127
|
+
const urls = {};
|
|
128
|
+
if (isObject(raw.urls)) {
|
|
129
|
+
for (const [pageId, path] of Object.entries(raw.urls)) {
|
|
130
|
+
if (typeof path === 'string' && path.startsWith('/') && !path.startsWith('//')) {
|
|
131
|
+
urls[pageId] = path;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
const base = {
|
|
136
|
+
account: {
|
|
137
|
+
id: accountId,
|
|
138
|
+
email: strOrNull(raw.account.email ?? null),
|
|
139
|
+
name: strOrNull(raw.account.name ?? null),
|
|
140
|
+
roles,
|
|
141
|
+
},
|
|
142
|
+
activeOrg: parseOrg(raw.activeOrg),
|
|
143
|
+
tenant: parseTenant(raw.tenant),
|
|
144
|
+
impersonatedBy: strOrNull(raw.impersonatedBy ?? null),
|
|
145
|
+
locale: str(raw.locale, 35) ?? 'en',
|
|
146
|
+
urls,
|
|
147
|
+
};
|
|
148
|
+
if (raw.op === 'visible') {
|
|
149
|
+
const pageIds = Array.isArray(raw.pageIds)
|
|
150
|
+
? raw.pageIds.filter((id) => typeof id === 'string').slice(0, 100)
|
|
151
|
+
: [];
|
|
152
|
+
return { ...base, op: 'visible', pageIds };
|
|
153
|
+
}
|
|
154
|
+
const pageId = str(raw.pageId, 128);
|
|
155
|
+
if (!pageId)
|
|
156
|
+
return null;
|
|
157
|
+
if (raw.op === 'load')
|
|
158
|
+
return { ...base, op: 'load', pageId, query: parseQuery(raw.query) };
|
|
159
|
+
if (raw.op === 'action') {
|
|
160
|
+
const action = str(raw.action, 64);
|
|
161
|
+
if (!action || !isObject(raw.input))
|
|
162
|
+
return null;
|
|
163
|
+
const fields = {};
|
|
164
|
+
if (isObject(raw.input.fields)) {
|
|
165
|
+
for (const [name, value] of Object.entries(raw.input.fields)) {
|
|
166
|
+
if (typeof value === 'string' && value.length <= 10_000)
|
|
167
|
+
fields[name] = value;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return {
|
|
171
|
+
...base,
|
|
172
|
+
op: 'action',
|
|
173
|
+
pageId,
|
|
174
|
+
action,
|
|
175
|
+
query: parseQuery(raw.query),
|
|
176
|
+
input: { target: strOrNull(raw.input.target ?? null), fields },
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
async function within(work, ms, what) {
|
|
182
|
+
let timer;
|
|
183
|
+
try {
|
|
184
|
+
return await Promise.race([
|
|
185
|
+
work,
|
|
186
|
+
new Promise((_, reject) => {
|
|
187
|
+
timer = setTimeout(() => reject(new Error(`${what} timed out after ${ms} ms`)), ms);
|
|
188
|
+
}),
|
|
189
|
+
]);
|
|
190
|
+
}
|
|
191
|
+
finally {
|
|
192
|
+
if (timer !== undefined)
|
|
193
|
+
clearTimeout(timer);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
const err = (status, code, message) => ({
|
|
197
|
+
status,
|
|
198
|
+
body: { error: { code, message } },
|
|
199
|
+
});
|
|
200
|
+
/**
|
|
201
|
+
* Runs one call against the app's extensions with the same rules as in the IdP's own process:
|
|
202
|
+
* time budgets, declared action fields only, and a failure that stays inside the page.
|
|
203
|
+
*/
|
|
204
|
+
export async function runAccountExtensionCall(extensions, call, options) {
|
|
205
|
+
const log = options.log ?? ((message) => console.error(message));
|
|
206
|
+
const subject = options.subject
|
|
207
|
+
? await options.subject(call.account, { activeOrg: call.activeOrg, tenant: call.tenant })
|
|
208
|
+
: { type: 'User', id: call.account.id };
|
|
209
|
+
const base = {
|
|
210
|
+
account: call.account,
|
|
211
|
+
activeOrg: call.activeOrg,
|
|
212
|
+
tenant: call.tenant,
|
|
213
|
+
subject,
|
|
214
|
+
impersonatedBy: call.impersonatedBy,
|
|
215
|
+
locale: call.locale,
|
|
216
|
+
url(pageId, query) {
|
|
217
|
+
const path = call.urls[pageId] ?? '/';
|
|
218
|
+
const qs = query
|
|
219
|
+
? new URLSearchParams(Object.entries(query).filter(([, v]) => v !== '')).toString()
|
|
220
|
+
: '';
|
|
221
|
+
return qs ? `${path}?${qs}` : path;
|
|
222
|
+
},
|
|
223
|
+
make: options.make,
|
|
224
|
+
now: options.now ?? (() => new Date()),
|
|
225
|
+
};
|
|
226
|
+
const pages = new Map(extensions.flatMap((e) => e.pages.map((p) => [p.id, p])));
|
|
227
|
+
if (call.op === 'visible') {
|
|
228
|
+
const visible = [];
|
|
229
|
+
await Promise.all(call.pageIds.map(async (pageId) => {
|
|
230
|
+
const page = pages.get(pageId);
|
|
231
|
+
if (!page)
|
|
232
|
+
return;
|
|
233
|
+
if (typeof page.visible !== 'function') {
|
|
234
|
+
visible.push(pageId);
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
try {
|
|
238
|
+
const shown = await within(Promise.resolve(page.visible({ ...base, query: {} })), options.loadTimeoutMs ?? 5000, 'visible()');
|
|
239
|
+
if (shown !== false)
|
|
240
|
+
visible.push(pageId);
|
|
241
|
+
}
|
|
242
|
+
catch (error) {
|
|
243
|
+
log(`authkit: account extension page "${pageId}" visible() failed: ${String(error)}`);
|
|
244
|
+
}
|
|
245
|
+
}));
|
|
246
|
+
return { status: 200, body: { visible: visible.sort() } };
|
|
247
|
+
}
|
|
248
|
+
const page = pages.get(call.pageId);
|
|
249
|
+
if (!page)
|
|
250
|
+
return err(404, 'not_found', 'No such page.');
|
|
251
|
+
const ctx = { ...base, query: call.query };
|
|
252
|
+
if (call.op === 'load') {
|
|
253
|
+
try {
|
|
254
|
+
const view = await within(page.load(ctx), options.loadTimeoutMs ?? 5000, 'load()');
|
|
255
|
+
if (!view || typeof view !== 'object' || !Array.isArray(view.blocks)) {
|
|
256
|
+
throw new Error('load() must return { blocks: [...] }');
|
|
257
|
+
}
|
|
258
|
+
return { status: 200, body: { view } };
|
|
259
|
+
}
|
|
260
|
+
catch (error) {
|
|
261
|
+
log(`authkit: account extension page "${call.pageId}" load() failed: ${String(error)}`);
|
|
262
|
+
return err(502, 'load_failed', 'The page could not be loaded.');
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
const actions = page.actions ?? {};
|
|
266
|
+
const action = Object.hasOwn(actions, call.action) ? actions[call.action] : undefined;
|
|
267
|
+
if (!action)
|
|
268
|
+
return err(404, 'not_found', 'No such action.');
|
|
269
|
+
const fields = {};
|
|
270
|
+
for (const field of action.fields ?? []) {
|
|
271
|
+
const value = call.input.fields[field.name];
|
|
272
|
+
if (typeof value === 'string')
|
|
273
|
+
fields[field.name] = value;
|
|
274
|
+
}
|
|
275
|
+
try {
|
|
276
|
+
const result = await within(action.handle(ctx, { target: call.input.target, fields }), options.actionTimeoutMs ?? 15_000, 'handle()');
|
|
277
|
+
if (!result || typeof result !== 'object' || typeof result.ok !== 'boolean') {
|
|
278
|
+
throw new Error('handle() must return { ok: true | false, … }');
|
|
279
|
+
}
|
|
280
|
+
return { status: 200, body: { result } };
|
|
281
|
+
}
|
|
282
|
+
catch (error) {
|
|
283
|
+
log(`authkit: account extension action "${call.pageId}.${call.action}" failed: ${String(error)}`);
|
|
284
|
+
return err(502, 'action_failed', 'The action failed.');
|
|
285
|
+
}
|
|
286
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { type AccountExtension, type AccountExtensionRegistry } from './contract.js';
|
|
2
|
+
export declare const ACCOUNT_SEGMENT: RegExp;
|
|
3
|
+
/** Segments authkit itself uses under the account prefix, whatever the host renames. */
|
|
4
|
+
export declare const ACCOUNT_RESERVED_SEGMENTS: Set<string>;
|
|
5
|
+
export declare class AccountExtensionError extends Error {
|
|
6
|
+
}
|
|
7
|
+
export declare function failAccountExtension(id: string, message: string): never;
|
|
8
|
+
/** Validates an extension's own shape (no knowledge of the other extensions). */
|
|
9
|
+
export declare function validateAccountExtension(extension: AccountExtension): void;
|
|
10
|
+
/**
|
|
11
|
+
* The registry an authkit package binds at `authkit.accountExtensions`: validates each extension
|
|
12
|
+
* and keeps them in registration order. Throws on an invalid one outside production; in
|
|
13
|
+
* production it is skipped and logged, so one bad contributor cannot take the console down.
|
|
14
|
+
*/
|
|
15
|
+
export declare class BaseAccountExtensionRegistry implements AccountExtensionRegistry {
|
|
16
|
+
readonly contract = 1;
|
|
17
|
+
readonly blockKinds: readonly string[];
|
|
18
|
+
protected readonly registered: AccountExtension[];
|
|
19
|
+
protected readonly isProduction: () => boolean;
|
|
20
|
+
protected readonly log: (message: string) => void;
|
|
21
|
+
constructor(options?: {
|
|
22
|
+
production?: () => boolean;
|
|
23
|
+
log?: (message: string) => void;
|
|
24
|
+
});
|
|
25
|
+
/** Hook for subclasses: throw to refuse a registration (e.g. after a freeze). */
|
|
26
|
+
protected beforeRegister(_extension: AccountExtension): void;
|
|
27
|
+
register(extension: AccountExtension): void;
|
|
28
|
+
/** The registered extensions, in registration order. */
|
|
29
|
+
extensions(): readonly AccountExtension[];
|
|
30
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { ACCOUNT_BLOCK_KINDS, ACCOUNT_EXTENSION_CONTRACT, } from './contract.js';
|
|
2
|
+
const EXTENSION_ID = /^[a-z0-9-]{2,16}$/;
|
|
3
|
+
const PAGE_LOCAL_ID = /^[a-z0-9][a-z0-9-]{0,31}$/;
|
|
4
|
+
export const ACCOUNT_SEGMENT = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
|
5
|
+
const ACTION_NAME = /^[a-zA-Z][a-zA-Z0-9_-]{0,63}$/;
|
|
6
|
+
const FIELD_NAME = /^[a-zA-Z][a-zA-Z0-9_]{0,63}$/;
|
|
7
|
+
/** Segments authkit itself uses under the account prefix, whatever the host renames. */
|
|
8
|
+
export const ACCOUNT_RESERVED_SEGMENTS = new Set([
|
|
9
|
+
'api',
|
|
10
|
+
'login',
|
|
11
|
+
'logout',
|
|
12
|
+
'security',
|
|
13
|
+
'mfa',
|
|
14
|
+
'confirm',
|
|
15
|
+
'tokens',
|
|
16
|
+
'apps',
|
|
17
|
+
'orgs',
|
|
18
|
+
'email',
|
|
19
|
+
'x',
|
|
20
|
+
'assets',
|
|
21
|
+
]);
|
|
22
|
+
/** Built-in screen keys (an extension id may not shadow one). */
|
|
23
|
+
const BUILTIN_IDS = new Set([
|
|
24
|
+
'login',
|
|
25
|
+
'logout',
|
|
26
|
+
'security',
|
|
27
|
+
'mfa',
|
|
28
|
+
'confirm',
|
|
29
|
+
'tokens',
|
|
30
|
+
'apps',
|
|
31
|
+
'orgs',
|
|
32
|
+
'account',
|
|
33
|
+
'authkit',
|
|
34
|
+
]);
|
|
35
|
+
export class AccountExtensionError extends Error {
|
|
36
|
+
}
|
|
37
|
+
export function failAccountExtension(id, message) {
|
|
38
|
+
throw new AccountExtensionError(`authkit: account extension "${id}" — ${message}`);
|
|
39
|
+
}
|
|
40
|
+
function validateAction(id, pageId, name, action) {
|
|
41
|
+
if (!ACTION_NAME.test(name))
|
|
42
|
+
failAccountExtension(id, `page "${pageId}" has an invalid action name "${name}".`);
|
|
43
|
+
if (typeof action?.handle !== 'function') {
|
|
44
|
+
failAccountExtension(id, `action "${pageId}.${name}" has no handle().`);
|
|
45
|
+
}
|
|
46
|
+
for (const field of action.fields ?? []) {
|
|
47
|
+
if (!FIELD_NAME.test(field?.name ?? '')) {
|
|
48
|
+
failAccountExtension(id, `action "${pageId}.${name}" has an invalid field name "${String(field?.name)}".`);
|
|
49
|
+
}
|
|
50
|
+
if (!['text', 'select', 'textarea'].includes(field.kind)) {
|
|
51
|
+
failAccountExtension(id, `field "${field.name}" of "${pageId}.${name}" has an unknown kind.`);
|
|
52
|
+
}
|
|
53
|
+
if (field.kind === 'select' && !(field.options && field.options.length > 0)) {
|
|
54
|
+
failAccountExtension(id, `select field "${field.name}" of "${pageId}.${name}" needs options.`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/** Validates an extension's own shape (no knowledge of the other extensions). */
|
|
59
|
+
export function validateAccountExtension(extension) {
|
|
60
|
+
const id = String(extension?.id);
|
|
61
|
+
if (!EXTENSION_ID.test(id) || BUILTIN_IDS.has(id)) {
|
|
62
|
+
failAccountExtension(id, 'invalid id (2–16 of a-z, 0-9, -; not a built-in screen).');
|
|
63
|
+
}
|
|
64
|
+
if (typeof extension.contract !== 'number' || !Number.isInteger(extension.contract)) {
|
|
65
|
+
failAccountExtension(id, 'contract must be the major it was written against (1).');
|
|
66
|
+
}
|
|
67
|
+
if (extension.contract > ACCOUNT_EXTENSION_CONTRACT) {
|
|
68
|
+
failAccountExtension(id, `written against contract ${extension.contract}; this authkit implements ${ACCOUNT_EXTENSION_CONTRACT}. Upgrade @adonis-agora/authkit.`);
|
|
69
|
+
}
|
|
70
|
+
for (const [locale, catalog] of Object.entries(extension.messages ?? {})) {
|
|
71
|
+
for (const key of Object.keys(catalog ?? {})) {
|
|
72
|
+
if (!key.startsWith(`${id}.`)) {
|
|
73
|
+
failAccountExtension(id, `message key "${key}" (${locale}) must start with "${id}.".`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
for (const origin of extension.scriptOrigins ?? []) {
|
|
78
|
+
let parsed = null;
|
|
79
|
+
try {
|
|
80
|
+
parsed = new URL(origin);
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
parsed = null;
|
|
84
|
+
}
|
|
85
|
+
if (!parsed || parsed.protocol !== 'https:' || parsed.origin !== origin) {
|
|
86
|
+
failAccountExtension(id, `scriptOrigins entry "${origin}" must be an https origin (no path).`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
if (!Array.isArray(extension.pages) || extension.pages.length === 0) {
|
|
90
|
+
failAccountExtension(id, 'needs at least one page.');
|
|
91
|
+
}
|
|
92
|
+
const pageIds = new Set();
|
|
93
|
+
const segments = new Set();
|
|
94
|
+
for (const page of extension.pages) {
|
|
95
|
+
const pageId = String(page?.id);
|
|
96
|
+
const [prefix, local, ...rest] = pageId.split('.');
|
|
97
|
+
if (prefix !== id || !local || rest.length > 0 || !PAGE_LOCAL_ID.test(local)) {
|
|
98
|
+
failAccountExtension(id, `page id "${pageId}" must be "${id}.<page>" (a-z, 0-9, -).`);
|
|
99
|
+
}
|
|
100
|
+
if (pageIds.has(pageId))
|
|
101
|
+
failAccountExtension(id, `page "${pageId}" is listed twice.`);
|
|
102
|
+
pageIds.add(pageId);
|
|
103
|
+
if (!ACCOUNT_SEGMENT.test(String(page.segment)) ||
|
|
104
|
+
ACCOUNT_RESERVED_SEGMENTS.has(page.segment)) {
|
|
105
|
+
failAccountExtension(id, `page "${pageId}" has an invalid or reserved segment "${String(page.segment)}".`);
|
|
106
|
+
}
|
|
107
|
+
if (segments.has(page.segment))
|
|
108
|
+
failAccountExtension(id, `segment "${page.segment}" is used twice.`);
|
|
109
|
+
segments.add(page.segment);
|
|
110
|
+
if (typeof page.load !== 'function')
|
|
111
|
+
failAccountExtension(id, `page "${pageId}" has no load().`);
|
|
112
|
+
for (const [name, action] of Object.entries(page.actions ?? {})) {
|
|
113
|
+
validateAction(id, pageId, name, action);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* The registry an authkit package binds at `authkit.accountExtensions`: validates each extension
|
|
119
|
+
* and keeps them in registration order. Throws on an invalid one outside production; in
|
|
120
|
+
* production it is skipped and logged, so one bad contributor cannot take the console down.
|
|
121
|
+
*/
|
|
122
|
+
export class BaseAccountExtensionRegistry {
|
|
123
|
+
contract = ACCOUNT_EXTENSION_CONTRACT;
|
|
124
|
+
blockKinds = ACCOUNT_BLOCK_KINDS;
|
|
125
|
+
registered = [];
|
|
126
|
+
isProduction;
|
|
127
|
+
log;
|
|
128
|
+
constructor(options = {}) {
|
|
129
|
+
this.isProduction = options.production ?? (() => false);
|
|
130
|
+
this.log = options.log ?? ((message) => console.error(message));
|
|
131
|
+
}
|
|
132
|
+
/** Hook for subclasses: throw to refuse a registration (e.g. after a freeze). */
|
|
133
|
+
beforeRegister(_extension) { }
|
|
134
|
+
register(extension) {
|
|
135
|
+
this.beforeRegister(extension);
|
|
136
|
+
try {
|
|
137
|
+
validateAccountExtension(extension);
|
|
138
|
+
if (this.registered.some((e) => e.id === extension.id)) {
|
|
139
|
+
failAccountExtension(extension.id, 'is registered twice.');
|
|
140
|
+
}
|
|
141
|
+
for (const other of this.registered) {
|
|
142
|
+
for (const page of extension.pages) {
|
|
143
|
+
if (other.pages.some((p) => p.segment === page.segment)) {
|
|
144
|
+
failAccountExtension(extension.id, `segment "${page.segment}" is already used by "${other.id}".`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
catch (error) {
|
|
150
|
+
if (!this.isProduction())
|
|
151
|
+
throw error;
|
|
152
|
+
this.log(`${error.message} The extension is skipped.`);
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
this.registered.push(extension);
|
|
156
|
+
}
|
|
157
|
+
/** The registered extensions, in registration order. */
|
|
158
|
+
extensions() {
|
|
159
|
+
return this.registered;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
@@ -1,36 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Client OIDC persistido no adapter/DB, gerenciável via console admin ou Admin API
|
|
3
|
-
* em runtime. Usado internamente pela lib para representar clients em memória.
|
|
4
|
-
*/
|
|
5
|
-
export interface ClientConfig {
|
|
6
|
-
clientId: string;
|
|
7
|
-
clientSecret?: string;
|
|
8
|
-
redirectUris: string[];
|
|
9
|
-
postLogoutRedirectUris?: string[];
|
|
10
|
-
grants?: string[];
|
|
11
|
-
/** 'none' para public clients (SPA com PKCE) */
|
|
12
|
-
tokenEndpointAuthMethod?: 'client_secret_basic' | 'client_secret_post' | 'none';
|
|
13
|
-
/**
|
|
14
|
-
* Tipo de aplicação (OIDC Registration §2, `application_type`). Default `'web'`.
|
|
15
|
-
*
|
|
16
|
-
* `'native'` (RFC 8252 — OAuth 2.0 for Native Apps) libera os redirects de app
|
|
17
|
-
* mobile/desktop: esquema privado (`com.example.app:/callback`), URL https
|
|
18
|
-
* "claimed" (universal/app links) e loopback `http://127.0.0.1` com qualquer
|
|
19
|
-
* porta. Client nativo é SEMPRE público (sem `clientSecret`,
|
|
20
|
-
* `tokenEndpointAuthMethod: 'none'`) — PKCE já é obrigatório no issuer.
|
|
21
|
-
*/
|
|
22
|
-
applicationType?: 'web' | 'native';
|
|
23
|
-
/**
|
|
24
|
-
* Endpoint do RP que recebe o POST de OIDC Back-Channel Logout. Quando definido,
|
|
25
|
-
* o IdP envia um `logout_token` para esta URI ao encerrar a sessão/grant do usuário.
|
|
26
|
-
*/
|
|
27
|
-
backchannelLogoutUri?: string;
|
|
28
|
-
/**
|
|
29
|
-
* Exige que o `logout_token` inclua a claim `sid` (session id). Mapeado para
|
|
30
|
-
* `backchannel_logout_session_required` do oidc-provider. Default: false.
|
|
31
|
-
*/
|
|
32
|
-
backchannelLogoutSessionRequired?: boolean;
|
|
33
|
-
}
|
|
34
1
|
export interface TtlConfig {
|
|
35
2
|
accessToken?: string | number;
|
|
36
3
|
refreshToken?: string | number;
|
|
@@ -146,7 +113,6 @@ export interface AccessTokensConfig {
|
|
|
146
113
|
/** Forma normalizada do config do server (pós-`defineConfig`). */
|
|
147
114
|
export interface ResolvedAuthServerConfig {
|
|
148
115
|
issuer: string;
|
|
149
|
-
clients: ClientConfig[];
|
|
150
116
|
ttl: Required<TtlConfig>;
|
|
151
117
|
jwks: JwksConfig;
|
|
152
118
|
globalRolesClaim: string;
|
package/package.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adonis-agora/authkit-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "1.0.0-rc.1",
|
|
4
4
|
"description": "Shared core types and utilities for AuthKit — the AdonisJS OIDC/OAuth2 server and client toolkits.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "dudousxd",
|
|
7
7
|
"repository": {
|
|
8
8
|
"type": "git",
|
|
9
|
-
"url": "https://github.com/DavideCarvalho/adonis-agora-authkit.git",
|
|
9
|
+
"url": "git+https://github.com/DavideCarvalho/adonis-agora-authkit.git",
|
|
10
10
|
"directory": "packages/authkit-core"
|
|
11
11
|
},
|
|
12
12
|
"homepage": "https://davidecarvalho.github.io/agora/docs/authkit",
|
|
@@ -28,7 +28,8 @@
|
|
|
28
28
|
"main": "./build/index.js",
|
|
29
29
|
"types": "./build/index.d.ts",
|
|
30
30
|
"files": [
|
|
31
|
-
"build"
|
|
31
|
+
"build",
|
|
32
|
+
"CHANGELOG.md"
|
|
32
33
|
],
|
|
33
34
|
"exports": {
|
|
34
35
|
".": "./build/index.js",
|
|
@@ -43,8 +44,8 @@
|
|
|
43
44
|
"@adonisjs/tsconfig": "2.0.0",
|
|
44
45
|
"@japa/assert": "4.2.0",
|
|
45
46
|
"@japa/runner": "5.4.0",
|
|
46
|
-
"@poppinss/ts-exec": "1.5.
|
|
47
|
-
"@types/node": "26.6.
|
|
47
|
+
"@poppinss/ts-exec": "1.5.2",
|
|
48
|
+
"@types/node": "26.6.6",
|
|
48
49
|
"typescript": "7.0.2"
|
|
49
50
|
},
|
|
50
51
|
"scripts": {
|