@adonis-agora/authkit-server 0.60.0 → 0.61.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.
@@ -77,6 +77,13 @@ export default class AuthkitServerProvider {
77
77
  // Defaults estruturais de `config.routes` (o argumento ainda vence).
78
78
  routes: typeof config.routes === 'object' ? config.routes : undefined,
79
79
  lockedRouteOptions: config.lockedRouteOptions,
80
+ // API headless — repassa pro registerAuthHost montar as rotas.
81
+ headless: config.headless
82
+ ? {
83
+ baseUrl: config.headless.baseUrl,
84
+ resolveAccountId: config.headless.resolveAccountId,
85
+ }
86
+ : undefined,
80
87
  });
81
88
  }
82
89
  }
@@ -822,6 +822,24 @@ export interface AuthServerConfigInput {
822
822
  i18n?: I18nConfig;
823
823
  /** Configuração de providers sociais. */
824
824
  social?: AuthSocialConfig;
825
+ /**
826
+ * API headless do AuthKit (Clerk-style). Quando declarada, o host-kit monta
827
+ * uma JSON API (`GET/PUT {baseUrl}/login-methods`) autenticada por um resolver
828
+ * de conta fornecido pelo HOST (`resolveAccountId`) — tipicamente a própria
829
+ * sessão do RP (`await ctx.auth.getUserOrFail()`). Assim um app usa AuthKit
830
+ * como frontend sem expor o console `/account/*`. Default: desligado (opt-in).
831
+ */
832
+ headless?: {
833
+ /** Base da JSON API headless. Default: `/api/authkit`. */
834
+ baseUrl?: string;
835
+ /**
836
+ * Resolve o id da conta (na identidade do IdP) a partir do contexto da
837
+ * request do HOST. Retorne null quando não autenticado → 401. Um `throw`
838
+ * do resolver também vira 401. O id deve ser o mesmo `sub`/`AuthAccount.id`
839
+ * usado pelo account store (no entre-textos, `auth.user.id === app.user.id`).
840
+ */
841
+ resolveAccountId: (ctx: import('@adonisjs/core/http').HttpContext) => string | null | Promise<string | null>;
842
+ };
825
843
  /** Segredo para autenticar requests de introspecção de PAT. */
826
844
  patIntrospectionSecret?: string;
827
845
  /** Rate-limiting das rotas sensíveis (anti-brute-force). Default: ligado (no-op se o limiter não estiver configurado). */
@@ -1075,6 +1093,11 @@ export interface ResolvedServerConfig {
1075
1093
  firstPartyClients?: string[];
1076
1094
  social?: AuthSocialConfig;
1077
1095
  patIntrospectionSecret?: string;
1096
+ /** API headless resolvida (Clerk-style). Presente só quando o host a declarou. */
1097
+ headless?: {
1098
+ baseUrl: string;
1099
+ resolveAccountId: (ctx: import('@adonisjs/core/http').HttpContext) => string | null | Promise<string | null>;
1100
+ };
1078
1101
  rateLimit: ResolvedRateLimitConfig;
1079
1102
  /** Bloqueio progressivo de conta resolvido (sempre presente; default ligado). */
1080
1103
  lockout: ResolvedLockoutConfig;
@@ -395,6 +395,12 @@ export function defineConfig(config) {
395
395
  passwordless: resolvePasswordless(config.passwordless),
396
396
  authMethods: resolveAuthMethodsConfig(config.authMethods),
397
397
  login: resolveLogin(config.login),
398
+ headless: config.headless
399
+ ? {
400
+ baseUrl: config.headless.baseUrl ?? '/api/authkit',
401
+ resolveAccountId: config.headless.resolveAccountId,
402
+ }
403
+ : undefined,
398
404
  registration: resolveRegistration(config.registration),
399
405
  accessTokens: resolveAccessTokens(config.issuer, config.accessTokens),
400
406
  admin: resolveAdmin(config.admin),
@@ -1,3 +1,4 @@
1
+ import type { HttpContext } from '@adonisjs/core/http';
1
2
  import type { AuthSocialConfig, ResolvedRateLimitConfig } from '../define_config.js';
2
3
  import type { PolicyRouteOption } from './config_locks.js';
3
4
  import type { AuthHostOptions } from './register_auth_host.js';
@@ -34,6 +35,14 @@ export interface AuthHostRuntimeConfig {
34
35
  * Ver `deriveLockedRouteOptions`.
35
36
  */
36
37
  lockedRouteOptions?: PolicyRouteOption[];
38
+ /**
39
+ * API headless (Clerk-style). Presente quando o host a declarou — o provider o
40
+ * stash no boot para `registerAuthHost` montar as rotas sem reler o config inteiro.
41
+ */
42
+ headless?: {
43
+ baseUrl: string;
44
+ resolveAccountId: (ctx: HttpContext) => string | null | Promise<string | null>;
45
+ };
37
46
  }
38
47
  /** Stash dos bits de routing (chamado no boot do provider). */
39
48
  export declare function setAuthHostConfig(config: AuthHostRuntimeConfig): void;
@@ -0,0 +1,31 @@
1
+ import type { HttpContext } from '@adonisjs/core/http';
2
+ /**
3
+ * API headless do AuthKit (Clerk-style).
4
+ *
5
+ * Diferente da JSON API do console (`/account/api/*`, guardada pela sessão de
6
+ * CONTA), esta API é protegida por um resolver de conta fornecido pelo HOST
7
+ * (`headless.resolveAccountId`) — tipicamente a própria sessão do app
8
+ * (`await ctx.auth.getUserOrFail()`). Assim um RP integrado usa o app como
9
+ * frontend sem expor o console `/account/*`.
10
+ */
11
+ export default class HeadlessLoginMethodsController {
12
+ #private;
13
+ /**
14
+ * GET {baseUrl}/login-methods
15
+ * Estado final de tipos de login para a conta resolvida pelo host.
16
+ */
17
+ index(ctx: HttpContext): Promise<void | import("../login_methods_state.js").LoginMethodsState>;
18
+ /**
19
+ * PUT {baseUrl}/login-methods
20
+ * Grava a preferência de tipos de login para a conta resolvida pelo host.
21
+ */
22
+ update(ctx: HttpContext): Promise<void | {
23
+ ok: boolean;
24
+ methods: {
25
+ password?: boolean;
26
+ magicLink?: boolean;
27
+ passkey?: boolean;
28
+ social?: boolean;
29
+ };
30
+ }>;
31
+ }
@@ -0,0 +1,79 @@
1
+ import { supportsLoginMethodsPreference } from '../../accounts/account_store.js';
2
+ import { loginMethodsStateForAccount, resolveGlobalAuthMethods } from '../login_methods_state.js';
3
+ import { resolveRuntimeSettingsOrNoop } from '../runtime_settings.js';
4
+ import { parseUserLoginMethodsPayload } from '../user_login_methods.js';
5
+ /** Erro JSON padrão. */
6
+ function apiErr(code, message) {
7
+ return { error: { code, message } };
8
+ }
9
+ /**
10
+ * API headless do AuthKit (Clerk-style).
11
+ *
12
+ * Diferente da JSON API do console (`/account/api/*`, guardada pela sessão de
13
+ * CONTA), esta API é protegida por um resolver de conta fornecido pelo HOST
14
+ * (`headless.resolveAccountId`) — tipicamente a própria sessão do app
15
+ * (`await ctx.auth.getUserOrFail()`). Assim um RP integrado usa o app como
16
+ * frontend sem expor o console `/account/*`.
17
+ */
18
+ export default class HeadlessLoginMethodsController {
19
+ /**
20
+ * GET {baseUrl}/login-methods
21
+ * Estado final de tipos de login para a conta resolvida pelo host.
22
+ */
23
+ async index(ctx) {
24
+ const service = await ctx.containerResolver.make('authkit.server');
25
+ const cfg = service.config;
26
+ const accountId = await this.#resolveAccount(ctx, cfg);
27
+ if (!accountId) {
28
+ return ctx.response.unauthorized(apiErr('unauthorized', 'Not authenticated.'));
29
+ }
30
+ const settings = await resolveRuntimeSettingsOrNoop(ctx);
31
+ return loginMethodsStateForAccount(accountId, cfg.accountStore, settings, cfg);
32
+ }
33
+ /**
34
+ * PUT {baseUrl}/login-methods
35
+ * Grava a preferência de tipos de login para a conta resolvida pelo host.
36
+ */
37
+ async update(ctx) {
38
+ const service = await ctx.containerResolver.make('authkit.server');
39
+ const cfg = service.config;
40
+ const accountId = await this.#resolveAccount(ctx, cfg);
41
+ if (!accountId) {
42
+ return ctx.response.unauthorized(apiErr('unauthorized', 'Not authenticated.'));
43
+ }
44
+ if (!supportsLoginMethodsPreference(cfg.accountStore)) {
45
+ return ctx.response.notFound(apiErr('not_supported', 'Login methods preference not supported.'));
46
+ }
47
+ const parsed = parseUserLoginMethodsPayload(ctx.request.body());
48
+ if (!parsed.ok) {
49
+ return ctx.response.badRequest(apiErr(parsed.error, 'Invalid login methods payload.'));
50
+ }
51
+ // All-off guard: preferência não pode zerar todos os métodos globais.
52
+ const settings = await resolveRuntimeSettingsOrNoop(ctx);
53
+ const { resolved: global } = await resolveGlobalAuthMethods(settings, cfg);
54
+ const value = { ...parsed.value };
55
+ const wouldBeAllOff = (value.password === false || !global.password) &&
56
+ (value.magicLink === false || !global.magicLink) &&
57
+ (value.passkey === false || !global.passkey) &&
58
+ (value.social === false || global.social.length === 0);
59
+ if (wouldBeAllOff) {
60
+ return ctx.response.badRequest(apiErr('all_methods_off', 'Cannot disable every login method.'));
61
+ }
62
+ await cfg.accountStore.setLoginMethods(accountId, Object.keys(value).length > 0 ? value : null);
63
+ return { ok: true, methods: value };
64
+ }
65
+ /** Delega ao resolver do host; null (ou throw de auth) => 401. */
66
+ async #resolveAccount(ctx, cfg) {
67
+ const resolve = cfg.headless?.resolveAccountId;
68
+ if (!resolve)
69
+ return null;
70
+ try {
71
+ const id = await resolve(ctx);
72
+ return id ? String(id) : null;
73
+ }
74
+ catch {
75
+ // Resolver do host lançou (ex.: auth.getUserOrFail sem sessão) → não-autorizado.
76
+ return null;
77
+ }
78
+ }
79
+ }
@@ -0,0 +1,39 @@
1
+ import type { ResolvedServerConfig } from '../define_config.js';
2
+ import type { SettingsCapability } from './runtime_settings.js';
3
+ import { type UserLoginMethods } from './user_login_methods.js';
4
+ /** Resultado compartilhado de "estado de tipos de login" entregue à UI. */
5
+ export interface LoginMethodsState {
6
+ supported: boolean;
7
+ /** Preferência crua do usuário; {} = sem preferência (herda globais). */
8
+ methods: UserLoginMethods | Record<string, never>;
9
+ /** Estado final por método (global ∩ preferência) — o que a tela de login mostra. */
10
+ available: {
11
+ password: boolean;
12
+ magicLink: boolean;
13
+ passkey: boolean;
14
+ social: string[];
15
+ forgotPassword: boolean;
16
+ } | null;
17
+ /** Métodos fora do controle do usuário (globalmente off ou pin de config). */
18
+ locked: {
19
+ password: boolean;
20
+ magicLink: boolean;
21
+ passkey: boolean;
22
+ social: boolean;
23
+ } | null;
24
+ }
25
+ /**
26
+ * Métodos globais efetivos + pins de config — resolução compartilhada entre o
27
+ * console (account API) e a API headless. Fail-safe: erro → defaults.
28
+ */
29
+ export declare function resolveGlobalAuthMethods(settings: SettingsCapability, cfg: ResolvedServerConfig): Promise<{
30
+ resolved: import("./runtime_toggles.js").ResolvedAuthMethods;
31
+ locked: (keyof import("./runtime_toggles.js").AuthMethodsConfigOverride)[];
32
+ }>;
33
+ /**
34
+ * Monta o estado final de tipos de login para uma conta, intersectando a
35
+ * preferência do usuário com os métodos globais efetivos. Reusado pelo console
36
+ * de conta (`/account/api/login-methods`) e pela API headless
37
+ * (`{baseUrl}/login-methods`) — sem duplicar regra.
38
+ */
39
+ export declare function loginMethodsStateForAccount(accountId: string, store: ResolvedServerConfig['accountStore'], settings: SettingsCapability, cfg: ResolvedServerConfig): Promise<LoginMethodsState>;
@@ -0,0 +1,52 @@
1
+ import { supportsMagicLink } from '../accounts/account_store.js';
2
+ import { supportsPasskeys } from '../accounts/account_store.js';
3
+ import { supportsLoginMethodsPreference } from '../accounts/account_store.js';
4
+ import { resolveEffectiveAuthMethods } from './runtime_toggles.js';
5
+ import { configLockedAuthMethods } from './runtime_toggles.js';
6
+ import { normalizeUserLoginMethods, resolveEffectiveUserLoginMethods, } from './user_login_methods.js';
7
+ /**
8
+ * Métodos globais efetivos + pins de config — resolução compartilhada entre o
9
+ * console (account API) e a API headless. Fail-safe: erro → defaults.
10
+ */
11
+ export async function resolveGlobalAuthMethods(settings, cfg) {
12
+ const resolved = await resolveEffectiveAuthMethods(settings, {
13
+ configuredSocialProviders: cfg.social?.providers ?? [],
14
+ magicLinkCapable: cfg.passwordless?.magicLink && supportsMagicLink(cfg.accountStore),
15
+ passkeyCapable: supportsPasskeys(cfg.accountStore),
16
+ configOverrides: cfg.authMethods,
17
+ });
18
+ return { resolved, locked: configLockedAuthMethods(cfg.authMethods) };
19
+ }
20
+ /**
21
+ * Monta o estado final de tipos de login para uma conta, intersectando a
22
+ * preferência do usuário com os métodos globais efetivos. Reusado pelo console
23
+ * de conta (`/account/api/login-methods`) e pela API headless
24
+ * (`{baseUrl}/login-methods`) — sem duplicar regra.
25
+ */
26
+ export async function loginMethodsStateForAccount(accountId, store, settings, cfg) {
27
+ if (!supportsLoginMethodsPreference(store)) {
28
+ return { supported: false, methods: {}, available: null, locked: null };
29
+ }
30
+ const pref = normalizeUserLoginMethods(await store.getLoginMethods(accountId));
31
+ const { resolved: global, locked: cfgLocked } = await resolveGlobalAuthMethods(settings, cfg);
32
+ const effective = resolveEffectiveUserLoginMethods(global, pref);
33
+ return {
34
+ supported: true,
35
+ // Preferência crua do usuário; {} = sem preferência (herda globais).
36
+ methods: pref ?? {},
37
+ available: {
38
+ password: effective.password,
39
+ magicLink: effective.magicLink,
40
+ passkey: effective.passkey,
41
+ social: effective.social,
42
+ forgotPassword: effective.forgotPassword,
43
+ },
44
+ // Métodos que o usuário NÃO pode controlar (globalmente off ou pin de config).
45
+ locked: {
46
+ password: !global.password || cfgLocked.includes('password'),
47
+ magicLink: !global.magicLink || cfgLocked.includes('magicLink'),
48
+ passkey: !global.passkey || cfgLocked.includes('passkey'),
49
+ social: global.social.length === 0,
50
+ },
51
+ };
52
+ }
@@ -201,6 +201,8 @@ const C = {
201
201
  apiKeys: () => import('./admin_api/api_keys_controller.js'),
202
202
  // Account self-service JSON API (session-authed, under /account/api/*).
203
203
  accountApi: () => import('./account_api/account_api_controller.js'),
204
+ // API headless (Clerk-style) — host-session-authed via `headless.resolveAccountId`.
205
+ headlessLoginMethods: () => import('./controllers/headless_login_methods_controller.js'),
204
206
  };
205
207
  /**
206
208
  * Monta todas as rotas do host-kit do Authorization Server numa chamada.
@@ -622,6 +624,19 @@ export function registerAuthHost(router, opts = {}) {
622
624
  router.get(`${apiBase}/orgs/:id`, [C.accountApi, 'showOrg']);
623
625
  })
624
626
  .use([accountGuard]);
627
+ // API headless (Clerk-style) — montada fora do `accountGuard` (não depende da
628
+ // sessão de CONTA), autenticada pelo resolver do host (`headless.resolveAccountId`).
629
+ // `PUT` usa CSRF igual à API do console (middleware de shield do host).
630
+ const headlessCfg = hostCfg?.headless;
631
+ if (headlessCfg) {
632
+ const hb = headlessCfg.baseUrl;
633
+ router
634
+ .get(`${hb}/login-methods`, [C.headlessLoginMethods, 'index'])
635
+ .as('authkit.headless.login_methods.index');
636
+ router
637
+ .put(`${hb}/login-methods`, [C.headlessLoginMethods, 'update'])
638
+ .as('authkit.headless.login_methods.update');
639
+ }
625
640
  // Prefixos resolvidos dos consoles — `null` quando o grupo não foi montado.
626
641
  // Compõem o `AuthHostRouteMap` devolvido no fim.
627
642
  let resolvedAdminPrefix = null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adonis-agora/authkit-server",
3
- "version": "0.60.0",
3
+ "version": "0.61.1",
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",
@@ -23,7 +23,8 @@
23
23
  "identity-provider",
24
24
  "rate-limiting",
25
25
  "mfa",
26
- "totp"
26
+ "totp",
27
+ "tanstack-intent"
27
28
  ],
28
29
  "publishConfig": {
29
30
  "access": "public"
@@ -33,7 +34,8 @@
33
34
  "types": "./build/index.d.ts",
34
35
  "files": [
35
36
  "build",
36
- "stubs"
37
+ "stubs",
38
+ "skills"
37
39
  ],
38
40
  "exports": {
39
41
  ".": "./build/index.js",
@@ -113,6 +115,7 @@
113
115
  "devDependencies": {
114
116
  "@adonis-agora/durable": "0.22.0",
115
117
  "@adonis-agora/telescope": "0.6.0",
118
+ "@tanstack/intent": "^0.3.2",
116
119
  "@adonisjs/ally": "6.3.0",
117
120
  "@adonisjs/auth": "10.1.0",
118
121
  "@adonisjs/core": "7.4.0",
@@ -150,7 +153,7 @@
150
153
  "react-error-boundary": "6.1.2",
151
154
  "nuqs": "2.9.5",
152
155
  "recharts": "3.10.1",
153
- "@adonis-agora/authkit-react": "0.20.0"
156
+ "@adonis-agora/authkit-react": "0.20.1"
154
157
  },
155
158
  "scripts": {
156
159
  "build": "node scripts/build_host_css.mjs && node scripts/build_webauthn.mjs && node scripts/build_ui.mjs && node -e \"const fs=require('node:fs');for(const d of ['build/stubs','build/host/views'])fs.rmSync(d,{recursive:true,force:true})\" && tsc && node -e \"require('node:fs').cpSync('src/host/assets','build/src/host/assets',{recursive:true})\" && node -e \"require('node:fs').cpSync('stubs','build/stubs',{recursive:true,filter:(s)=>!s.endsWith('.ts')})\" && node -e \"const fs=require('node:fs');if(fs.existsSync('assets'))fs.cpSync('assets','build/assets',{recursive:true})\" && node -e \"require('node:fs').cpSync('src/host/views','build/host/views',{recursive:true})\" && node -e \"const fs=require('node:fs');fs.mkdirSync('build/host/ui',{recursive:true});fs.readdirSync('src/host/ui').filter(f=>f.endsWith('.html')).forEach(f=>fs.copyFileSync('src/host/ui/'+f,'build/host/ui/'+f))\" && node -e \"require('node:fs').copyFileSync('commands/commands.json','build/commands/commands.json')\" && node -e \"const fs=require('node:fs');fs.mkdirSync('build/src/password',{recursive:true});fs.copyFileSync('src/password/common_passwords.txt','build/src/password/common_passwords.txt')\"",
@@ -0,0 +1,222 @@
1
+ ---
2
+ name: authkit-idp-setup
3
+ description: >-
4
+ Set up the @adonis-agora/authkit-server OIDC/OAuth2 Authorization Server (Identity
5
+ Provider) in an AdonisJS app. Covers `node ace add @adonis-agora/authkit-server`,
6
+ defineConfig({ issuer, adapter, jwks, ttl, globalRolesClaim, accountStore }) with
7
+ adapters.redis({ connection }) vs adapters.database({ connection? }),
8
+ lucidAccountStore(AuthUser), JWKS management ({ source: 'managed' | 'jwks' } or
9
+ 'auto', keystore store file/drive/lucid/redis/hashicorp vaults plus
10
+ @adonis-agora/authkit-vault-aws/azure/gcp createKeystoreVault add-ons),
11
+ registerOidcRoutes(router, { mountPath?, metrics?, dashboard? }), mounting consoles
12
+ via registerAuthHost(router, { admin, adminApi, account... }) or config.routes,
13
+ and AUTHKIT_ISSUER / observability env. Use when booting an IdP app, wiring
14
+ config/authkit.ts, choosing a persistence adapter, managing signing keys, or
15
+ debugging 404s on /oidc, /account/*, /admin.
16
+ license: MIT
17
+ metadata:
18
+ type: core
19
+ library: "@adonis-agora/authkit-server"
20
+ library_version: "0.60.0"
21
+ framework: adonisjs
22
+ sources:
23
+ - "DavideCarvalho/adonis-authkit:README.md"
24
+ - "DavideCarvalho/adonis-authkit:packages/authkit-server/README.md"
25
+ - "DavideCarvalho/adonis-authkit:packages/authkit-server/src/define_config.ts"
26
+ - "DavideCarvalho/adonis-authkit:packages/authkit-server/src/register_routes.ts"
27
+ - "DavideCarvalho/adonis-authkit:packages/authkit-server/stubs/config/authkit.stub"
28
+ ---
29
+
30
+ # Setting up the AuthKit Authorization Server
31
+
32
+ `@adonis-agora/authkit-server` turns an AdonisJS app into an OpenID Connect /
33
+ OAuth2 Authorization Server (IdP) on top of `oidc-provider`. Setup has three
34
+ independent pieces that all must exist before anything works: **config**
35
+ (`config/authkit.ts` via `defineConfig`), **OIDC routes** (`registerOidcRoutes`),
36
+ and the host routes/consoles (`registerAuthHost` or `config.routes`). The login
37
+ screens are a separate concern — see `authkit-interactions`.
38
+
39
+ ## Setup
40
+
41
+ Install and run the configurator:
42
+
43
+ ```bash
44
+ node ace add @adonis-agora/authkit-server
45
+ ```
46
+
47
+ `configure` publishes `config/authkit.ts`, the model `app/models/auth_user.ts`, the
48
+ interaction controller stub and registers the provider. Then wire the routes:
49
+
50
+ ```ts
51
+ // start/routes.ts
52
+ import router from '@adonisjs/core/services/router'
53
+ import { registerOidcRoutes } from '@adonis-agora/authkit-server'
54
+
55
+ registerOidcRoutes(router) // mounts the oidc-provider catch-all at /oidc
56
+ ```
57
+
58
+ Point `AUTHKIT_ISSUER` at `<host>/oidc` — the issuer must end with the mount path.
59
+
60
+ Source: `packages/authkit-server/README.md`, `src/register_routes.ts`.
61
+
62
+ ## Core patterns
63
+
64
+ ### Pattern 1 — `defineConfig`: issuer, adapter, accountStore
65
+
66
+ ```ts
67
+ // config/authkit.ts
68
+ import env from '#start/env'
69
+ import AuthUser from '#models/auth_user'
70
+ import { defineConfig, adapters, lucidAccountStore } from '@adonis-agora/authkit-server'
71
+
72
+ const authServerConfig = defineConfig({
73
+ issuer: env.get('AUTHKIT_ISSUER'),
74
+ adapter: adapters.redis({ connection: 'main' }), // or adapters.database()
75
+ jwks: { source: 'managed', algorithm: 'RS256' },
76
+ ttl: { accessToken: '15m', refreshToken: '30d' },
77
+ globalRolesClaim: 'roles',
78
+ accountStore: lucidAccountStore(AuthUser),
79
+ })
80
+
81
+ export default authServerConfig
82
+ ```
83
+
84
+ `adapter` is where oidc-provider persists grants/tokens/codes: `adapters.redis({
85
+ connection })` requires `@adonisjs/redis` configured; `adapters.database({
86
+ connection? })` uses Lucid and requires running the `authkit_oidc_payloads`
87
+ migration. `accountStore` is the identity contract — `lucidAccountStore(AuthUser)`
88
+ derives `findAccount`/`verifyCredentials` from your user model.
89
+
90
+ Source: `stubs/config/authkit.stub`, `src/define_config.ts` (`AuthServerConfigInput`),
91
+ `src/adapters/factory.ts`, README § Persistência.
92
+
93
+ ### Pattern 2 — signing keys: managed JWKS, `'auto'`, and inline keys
94
+
95
+ ```ts
96
+ defineConfig({
97
+ issuer: env.get('AUTHKIT_ISSUER'),
98
+ adapter: adapters.redis({ connection: 'main' }),
99
+ // Ephemeral-friendly: AUTHKIT_JWKS inline JSON wins, else file-managed keystore.
100
+ jwks: 'auto',
101
+ accountStore: lucidAccountStore(AuthUser),
102
+ })
103
+ ```
104
+
105
+ Explicit forms: `{ source: 'managed' }` generates/rotates/persists the private
106
+ keystore (`rotationDays`, `algorithm`, `encrypt`, and `store` choosing where the
107
+ blob lives); `{ source: 'jwks', keys }` supplies keys inline.
108
+
109
+ The managed keystore's `store` accepts a path shortcut (`store: 'tmp/keystore.json'`)
110
+ or `{ driver: ... }`: built-in vaults are `file`, `drive` (a `@adonisjs/drive` disk),
111
+ `lucid` (table created on first write), `redis`, and `hashicorp`; for cloud secret
112
+ managers install the add-ons `@adonis-agora/authkit-vault-aws` / `-azure` / `-gcp`
113
+ and pass their `createKeystoreVault(...)` result as `store` (any object with a
114
+ `read()` method is accepted). With no `store` at all, keys are ephemeral per boot.
115
+
116
+ Rotation tooling ships as `node ace authkit:rotate-keys`; health checks via
117
+ `node ace authkit:doctor`.
118
+
119
+ Source: `src/keys/keystore_manager.ts` (`resolveKeystoreVault`),
120
+ `packages/authkit-core/src/types/server_config.ts` (`JwksConfig`), index.ts exports.
121
+
122
+ ### Pattern 3 — mounting consoles with `registerAuthHost` (or `config.routes`)
123
+
124
+ ```ts
125
+ // start/routes.ts
126
+ import router from '@adonisjs/core/services/router'
127
+ import { registerOidcRoutes, registerAuthHost } from '@adonis-agora/authkit-server'
128
+
129
+ registerOidcRoutes(router)
130
+ const authkitRoutes = registerAuthHost(router, {
131
+ mountPath: '/oidc',
132
+ admin: true, // admin console under /admin
133
+ adminApi: true, // Admin REST API under /api/authkit/v1
134
+ })
135
+ // Hand authkitRoutes to your frontend instead of hardcoding hrefs.
136
+ router.get('/', async ({ inertia }) => inertia.render('home', { authkitRoutes }))
137
+ ```
138
+
139
+ Alternatively set `routes: true` in `config/authkit.ts` to auto-mount in the
140
+ provider's `boot()` (before `start/routes.ts`) — then do NOT also call
141
+ `registerAuthHost` manually (double registration crashes the boot). Policy flags
142
+ (`admin`, `adminApi`, `social`, `rateLimit`) declared in `defineConfig` LOCK the
143
+ on/off switch: `registerAuthHost` may only adjust structural prefixes.
144
+
145
+ Observability routes are opt-in on the OIDC mount itself:
146
+ `registerOidcRoutes(router, { metrics: true, dashboard: true })` mounts
147
+ `GET /authkit/metrics` (JSON snapshot) and `GET /authkit/dashboard` (embedded HTML).
148
+
149
+ Source: `src/host/register_auth_host.ts` (`AuthHostOptions`, policy-vs-structural),
150
+ `src/define_config.ts` (`routes`, `admin`, `adminApi` JSDoc), server README
151
+ § Observabilidade.
152
+
153
+ ## Common mistakes
154
+
155
+ ### CRITICAL — Issuer not ending with the mount path
156
+
157
+ ```ts
158
+ // Wrong — issuer says /oauth but routes mount at default /oidc
159
+ defineConfig({ issuer: 'https://idp.example.com/oauth' })
160
+ registerOidcRoutes(router) // mounts /oidc/*
161
+ ```
162
+
163
+ ```ts
164
+ // Correct — issuer ends exactly at the mount path
165
+ defineConfig({ issuer: 'https://idp.example.com/oidc' })
166
+ registerOidcRoutes(router) // default mountPath '/oidc' matches the issuer
167
+ ```
168
+
169
+ oidc-provider routes internally UNDER the issuer URL, so a trailing mismatch makes
170
+ discovery and every protocol endpoint unreachable while the app otherwise boots fine.
171
+
172
+ Source: `packages/authkit-server/README.md` § Montar as rotas OIDC;
173
+ `src/host/register_auth_host.ts` (`mountPath` doc: "Deve casar com o final do issuer").
174
+
175
+ ### HIGH — Consoles/account screens 404 because no host routes were mounted
176
+
177
+ ```ts
178
+ // Wrong — only the protocol endpoints exist; /account/* and /admin are dead
179
+ registerOidcRoutes(router)
180
+ ```
181
+
182
+ ```ts
183
+ // Correct — mount the host surface too (or set config.routes: true)
184
+ registerOidcRoutes(router)
185
+ registerAuthHost(router, { mountPath: '/oidc', admin: true })
186
+ ```
187
+
188
+ `registerOidcRoutes` only registers the oidc-provider catch-all; everything else
189
+ (account console `/account/*`, admin console, Admin API, sudo routes) exists solely
190
+ after `registerAuthHost` runs or `config.routes` auto-mounts them. Conversely,
191
+ calling `registerAuthHost` while `config.routes: true` double-registers named routes
192
+ and throws at boot — pick one.
193
+
194
+ Source: `src/define_config.ts` (`routes` JSDoc: "duplo registro ... derrubam o boot"),
195
+ `src/register_routes.ts`.
196
+
197
+ ### HIGH — `jwks: 'auto'` in production without AUTHKIT_JWKS
198
+
199
+ ```ts
200
+ // Wrong — prod deploy with no AUTHKIT_JWKS env: falls back to a file keystore in tmp/
201
+ jwks: 'auto'
202
+ ```
203
+
204
+ ```ts
205
+ // Correct — explicit managed keystore persisted in a durable vault for prod
206
+ jwks: {
207
+ source: 'managed',
208
+ algorithm: 'RS256',
209
+ store: { driver: 'lucid' }, // or redis/drive/hashicorp/vault add-on
210
+ }
211
+ ```
212
+
213
+ `'auto'` prefers the `AUTHKIT_JWKS` env JSON but silently degrades to
214
+ `tmp/authkit_jwks.json` on disk — on ephemeral filesystems every restart mints new
215
+ signing keys and every issued token fails validation after redeploy.
216
+
217
+ Source: `src/define_config.ts` (`jwks` JSDoc: "'auto' (recomendado p/ deploys
218
+ efêmeros)... senão cai no managed persistido em arquivo"),
219
+ `packages/authkit-core/src/types/server_config.ts`.
220
+
221
+ See also: `authkit-interactions/SKILL.md` — after setup, the authorization flow ends
222
+ at the interaction screens, which the host must register and render itself.
@@ -0,0 +1,236 @@
1
+ ---
2
+ name: authkit-interactions
3
+ description: >-
4
+ Implement the host-owned login/consent interaction screens of @adonis-agora/authkit-server.
5
+ Covers oidc-provider's interactions.url redirect to /auth/interaction/:uid, the three
6
+ routes every IdP app must register (show/login/consent on AuthInteractionController),
7
+ `node ace configure --ui=edge|react|headless` presets, the shell-controller +
8
+ service.interactions split (details(ctx), login(ctx,{email,password}), consent(ctx)),
9
+ overriding verifyCredentials in config/authkit.ts, renderers edgeRenderer/inertiaRenderer,
10
+ and end-to-end testing with @adonis-agora/authkit-testing (createTestIdentity,
11
+ mintTestIdToken, serveJwks, fakeAuthenticator). Use when the authorization flow 404s at
12
+ the login screen, wiring custom login UI, plugging an external user base, or testing
13
+ OIDC flows without booting an IdP.
14
+ license: MIT
15
+ metadata:
16
+ type: core
17
+ library: "@adonis-agora/authkit-server"
18
+ library_version: "0.60.0"
19
+ framework: adonisjs
20
+ sources:
21
+ - "DavideCarvalho/adonis-authkit:packages/authkit-server/README.md"
22
+ - "DavideCarvalho/adonis-authkit:packages/authkit-server/src/host/renderers/inertia_renderer.ts"
23
+ - "DavideCarvalho/adonis-authkit:packages/authkit-server/src/define_config.ts"
24
+ - "DavideCarvalho/adonis-authkit:packages/authkit-testing/README.md"
25
+ ---
26
+
27
+ # Login & consent screens (interactions)
28
+
29
+ When `oidc-provider` meets an unauthenticated user it redirects to
30
+ `interactions.url` (`/auth/interaction/:uid`). Those screens are **yours** — the kit
31
+ ejects a controller shell via `node ace configure` and you register the routes that
32
+ point at it. Without them the authorization code flow dies with a 404 exactly when
33
+ the user should log in.
34
+
35
+ ## Setup
36
+
37
+ Pick a UI preset when configuring (asks interactively if omitted):
38
+
39
+ ```bash
40
+ node ace configure @adonis-agora/authkit-server --ui=edge
41
+ # values: edge | react | headless
42
+ ```
43
+
44
+ Each preset publishes `app/controllers/auth_interaction_controller.ts`; `edge` adds
45
+ Edge views, `react` adds Inertia pages (validating that `@adonisjs/inertia` + Vite +
46
+ React exist first), `headless` returns JSON only. Register the three routes:
47
+
48
+ ```ts
49
+ // start/routes.ts
50
+ import router from '@adonisjs/core/services/router'
51
+ import AuthInteractionController from '#controllers/auth_interaction_controller'
52
+
53
+ router.get('/auth/interaction/:uid', [AuthInteractionController, 'show'])
54
+ router.post('/auth/interaction/:uid/login', [AuthInteractionController, 'login'])
55
+ router.post('/auth/interaction/:uid/consent', [AuthInteractionController, 'consent'])
56
+ ```
57
+
58
+ Source: `packages/authkit-server/README.md` § Rotas de interaction.
59
+
60
+ ## Core patterns
61
+
62
+ ### Pattern 1 — edit the shell, keep logic in `service.interactions`
63
+
64
+ In all presets the ejected controller is a thin shell: the logic lives in
65
+ `service.interactions`, resolved via `containerResolver.make('authkit.server')`.
66
+ It exposes `details(ctx)` (the prompt + params), `login(ctx, { email, password })`
67
+ and `consent(ctx)`. You only edit the render/redirect parts:
68
+
69
+ ```ts
70
+ // headless preset flavor — show() returns JSON, you build your own front
71
+ async show({ request, response }) {
72
+ const service = await this.ctx.containerResolver.make('authkit.server')
73
+ const details = await service.interactions.details(this.ctx)
74
+ return response.json({ uid: request.param('uid'), prompt: details.prompt, params: details.params })
75
+ }
76
+ ```
77
+
78
+ Keep validation, MFA prompts and grant bookkeeping inside `service.interactions`;
79
+ the shell only translates between HTTP and those calls.
80
+
81
+ Source: `packages/authkit-server/README.md` § UI de login/consent ("o controller
82
+ ejetado é casca: a lógica vive em `service.interactions`").
83
+
84
+ ### Pattern 2 — plug your user base via `verifyCredentials`
85
+
86
+ `verifyCredentials` in `config/authkit.ts` decides whether credentials are valid;
87
+ `service.interactions.login` calls it. The default queries the `AuthUser` model by
88
+ email and uses `verifyPassword` — override to authenticate against anything else:
89
+
90
+ ```ts
91
+ // config/authkit.ts
92
+ defineConfig({
93
+ issuer: env.get('AUTHKIT_ISSUER'),
94
+ adapter: adapters.redis({ connection: 'main' }),
95
+ accountStore: lucidAccountStore(AuthUser),
96
+ verifyCredentials: async (email, password) => {
97
+ // Return the account on success; throw/falsy paths fail the login.
98
+ const account = await AuthUser.query().where('email', email).first()
99
+ if (!account) throw new Error('Invalid credentials')
100
+ await verifyPassword(account.passwordHash, password)
101
+ return account
102
+ },
103
+ })
104
+ ```
105
+
106
+ Whatever it returns must be the same identity surface the configured
107
+ `accountStore` serves, or downstream `findAccount` lookups diverge from what just
108
+ logged in.
109
+
110
+ Source: `packages/authkit-server/README.md` § UI de login/consent (verifyCredentials),
111
+ `src/define_config.ts` (`accountStore` derives findAccount/verifyCredentials).
112
+
113
+ ### Pattern 3 — custom rendering with `inertiaRenderer`
114
+
115
+ Hosts building their own React screens set the `render` option instead of relying
116
+ on the default Edge renderer:
117
+
118
+ ```ts
119
+ import { defineConfig, inertiaRenderer } from '@adonis-agora/authkit-server'
120
+
121
+ defineConfig({
122
+ issuer: env.get('AUTHKIT_ISSUER'),
123
+ adapter: adapters.database(),
124
+ accountStore: lucidAccountStore(AuthUser),
125
+ render: inertiaRenderer(), // renders Inertia pages for /account/* and /auth/interaction/*
126
+ })
127
+ ```
128
+
129
+ Without any renderer, every `/account/*` and `/auth/interaction/*` request fails
130
+ with an unexplained 500 (`render` is undefined).
131
+
132
+ Source: `src/define_config.ts` (`render` JSDoc), `index.ts` exports
133
+ (`inertiaRenderer`, `edgeRenderer`).
134
+
135
+ ### Pattern 4 — test flows without booting an IdP
136
+
137
+ `@adonis-agora/authkit-testing` mints real signed ID tokens validated by a local
138
+ JWKS, and fakes the authenticator for controller tests:
139
+
140
+ ```ts
141
+ import { mintTestIdToken, serveJwks, fakeAuthenticator } from '@adonis-agora/authkit-testing'
142
+ import { resolvers } from '@adonis-agora/authkit-client'
143
+
144
+ const { token, jwks } = await mintTestIdToken({
145
+ issuer: 'https://idp.test',
146
+ clientId: 'my-app',
147
+ claims: { sub: 'user-42', email: 'jane@test.dev', roles: ['ADMIN'] },
148
+ })
149
+
150
+ const served = await serveJwks(jwks)
151
+ const factory = resolvers.jwt({ jwksUri: served.jwksUri })
152
+ const resolver = await factory.resolver({
153
+ issuer: 'https://idp.test',
154
+ clientId: 'my-app',
155
+ sessionKey: 'authkit',
156
+ globalRolesClaim: 'roles',
157
+ })
158
+ const ctx = { auth: fakeAuthenticator({ identity: null }) } // anonymous request fake
159
+ await served.close()
160
+ ```
161
+
162
+ Also available: `createTestIdentity(overrides?)` for valid `Identity` defaults,
163
+ `fakeAccountStore({ withMfa: true, ... })` for capability-probed store fakes.
164
+
165
+ Source: `packages/authkit-testing/README.md`.
166
+
167
+ ## Common mistakes
168
+
169
+ ### CRITICAL — Forgetting to register the interaction routes
170
+
171
+ ```ts
172
+ // Wrong — only the protocol endpoints exist
173
+ registerOidcRoutes(router)
174
+ // no GET /auth/interaction/:uid anywhere
175
+ ```
176
+
177
+ ```ts
178
+ // Correct — the three host-owned routes are registered next to the OIDC mount
179
+ registerOidcRoutes(router)
180
+ router.get('/auth/interaction/:uid', [AuthInteractionController, 'show'])
181
+ router.post('/auth/interaction/:uid/login', [AuthInteractionController, 'login'])
182
+ router.post('/auth/interaction/:uid/consent', [AuthInteractionController, 'consent'])
183
+ ```
184
+
185
+ Nothing crashes at boot; the failure surfaces mid-flow — the user's browser lands
186
+ on `/auth/interaction/:uid` and gets a 404, so no client can ever complete login.
187
+
188
+ Source: `packages/authkit-server/README.md` § Rotas de interaction ("Sem essas rotas
189
+ o fluxo de autorização cai num 404 ao chegar na tela de login").
190
+
191
+ ### HIGH — Writing login/consent logic inside the ejected controller shell
192
+
193
+ ```ts
194
+ // Wrong — reimplementing prompt handling/grants in the ejected shell
195
+ async consent(ctx) {
196
+ // hand-rolling grant persistence against oidc-provider internals...
197
+ }
198
+ ```
199
+
200
+ ```ts
201
+ // Correct — shell delegates; the service owns the flow
202
+ async consent(ctx) {
203
+ const service = await ctx.containerResolver.make('authkit.server')
204
+ await service.interactions.consent(ctx) // handles the grant, returns the redirect
205
+ }
206
+ ```
207
+
208
+ The shell is regenerated by `configure` and has no access to the provider's
209
+ interaction plumbing; duplicating logic there silently drifts from prompt/consent
210
+ semantics the provider expects.
211
+
212
+ Source: `packages/authkit-server/README.md` § UI de login/consent ("Você edita só a
213
+ parte de render/redirect").
214
+
215
+ ### MEDIUM — Choosing `--ui=react` without the Inertia stack installed
216
+
217
+ ```bash
218
+ # Wrong — react preset in a bare API-only AdonisJS app
219
+ node ace configure @adonis-agora/authkit-server --ui=react
220
+ ```
221
+
222
+ ```bash
223
+ # Correct — match the preset to the app stack
224
+ node ace configure @adonis-agora/authkit-server --ui=edge # server-rendered views
225
+ node ace configure @adonis-agora/authkit-server --ui=headless # build your own front
226
+ ```
227
+
228
+ The `react` preset requires `@adonisjs/inertia` + Vite + React in the host app —
229
+ `configure` validates the stack before publishing, so the command aborts instead of
230
+ half-configuring your app.
231
+
232
+ Source: `packages/authkit-server/README.md` § UI de login/consent ("Exige
233
+ @adonisjs/inertia + Vite + React no app — o configure valida essa stack").
234
+
235
+ See also: `authkit-rp-client/SKILL.md` — the consuming side that ends up holding
236
+ the session these screens create.