@adonis-agora/authkit-server 1.0.0-rc.0 → 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 CHANGED
@@ -1,5 +1,22 @@
1
1
  # @adonis-agora/authkit-server
2
2
 
3
+ ## 1.0.0-rc.1
4
+
5
+ ### Major Changes
6
+
7
+ - [#451](https://github.com/DavideCarvalho/adonis-agora-authkit/pull/451) [`1162aee`](https://github.com/DavideCarvalho/adonis-agora-authkit/commit/1162aeeee00d4706b8515d89e37f93d172cec44a) Thanks [@DavideCarvalho](https://github.com/DavideCarvalho)! - The account and admin consoles now sign in through the IdP login by default.
8
+
9
+ - `accountSession.acceptIdpSession` defaults to `true`. Without a console session, `/account/*` and the admin console send the visitor to the IdP login (`/auth/console/sso`) and come back with the console open; a user already signed in to an app opens them directly.
10
+ - `/account/login` redirects to the IdP login. The password form stays at `/account/login?method=password` while password login is on.
11
+ - The console's idle timeout now also ends the IdP session the console was opened from, like "Sign out" already did.
12
+ - `accountSession: { acceptIdpSession: false }` with `authMethods: { password: false }` and no `accountLoginUrl` (passed to `registerAuthHost` or set in `routes`) now fails at boot when the routes are registered, since the consoles would have no way in. `authkit:doctor` warns about it, and when SSO is off.
13
+ - The client id `authkit-console` is reserved for the consoles' internal client (`response_type=none`, never listed in the console) and cannot be registered.
14
+ - New `admin.authorize`, the access check every `@adonis-agora` console takes: an inline function, an authorizer class (built through the container, so `@inject()` works) or a lazy import (`() => import('#policies/console_access')`). It runs after `admin.roles` (both must pass) on the shell and on every `{prefix}/api/*` route. An account without one of the roles still goes to `accountHome`.
15
+ - **Breaking:** without `admin.authorize`, the admin console is open outside production and answers 403 to every request in production. The app logs `[@adonis-agora/authkit-server] console has no \`authorize\` configured: …` once at boot, and `authkit:doctor` warns. To keep letting every admin in, set `admin: { enabled: true, roles: ['ADMIN'], authorize: () => true }`.
16
+ - An `authorize` denial answers 403 (an access-denied page, or `{ error: 'forbidden' }` for JSON); `reason: 'unauthenticated'` answers 401 (`{ error: 'unauthenticated' }`); `{ redirectTo }` redirects page navigations and answers 401 `{ error: 'unauthenticated', redirectTo }` to JSON calls; a thrown error is a 403.
17
+ - New `authorizeByToken({ token })`: a `ConsoleAuthorize` that allows `Authorization: Bearer <token>` (timing-safe compare) and answers 401 otherwise. An empty token throws when the config loads.
18
+ - The types `ConsoleAuthorize`, `ConsoleAuthorizer`, `ConsoleAuthorizerClass` and `ConsoleAuthorizeResult` are exported, next to `authorizeByToken`.
19
+
3
20
  ## 1.0.0-rc.0
4
21
 
5
22
  ### Major Changes
package/build/index.d.ts CHANGED
@@ -30,6 +30,8 @@ export type { BuiltinProtocolId, PactOptions, PersonalAgentProtocol, } from './s
30
30
  export { pact, pactProtocol } from './src/agents/protocol.js';
31
31
  export type { AuditEvent, AuditEventType, AuditPage, AuditSink, ListAuditParams, StoredAuditEvent, } from './src/audit/audit_sink.js';
32
32
  export { lucidAuditSink } from './src/audit/lucid_audit_sink.js';
33
+ export type { ConsoleAuthorize, ConsoleAuthorizeResult, ConsoleAuthorizer, ConsoleAuthorizerClass, } from './src/console_authorize.js';
34
+ export { authorizeByToken } from './src/console_authorize.js';
33
35
  export type { AdminApiConfigInput, AdminConfigInput, AuthHostRenderer, AuthMethodsConfigInput, AuthServerConfigInput, AuthSocialConfig, DynamicRegistrationConfigInput, LoginConfigInput, OrganizationsConfigInput, PasswordlessConfigInput, RateLimitBucket, RateLimitConfigInput, RegistrationConfigInput, ResolvedAdminApiConfig, ResolvedAdminConfig, ResolvedAuthMethodsConfig, ResolvedDynamicRegistrationConfig, ResolvedLoginConfig, ResolvedNotificationsConfig, ResolvedOrganizationsConfig, ResolvedPasswordlessConfig, ResolvedRateLimitConfig, ResolvedRegistrationConfig, ResolvedServerConfig, ResolvedWebauthnConfig, WebauthnConfigInput, } from './src/define_config.js';
34
36
  export { adapters, defineConfig, resolveAdmin, resolveAdminApi, resolveAuthMethodsConfig, resolveDynamicRegistration, resolveLogin, resolveNotifications, resolveOrganizations, resolvePasswordless, resolveRateLimit, resolveRegistration, resolveWebauthn, toSeconds, } from './src/define_config.js';
35
37
  export type { EventsConfigInput, ResolvedEventsConfig } from './src/events/dispatcher.js';
package/build/index.js CHANGED
@@ -19,6 +19,7 @@ export { isBlockedAddress, SafeFetchError, safeFetchJson, } from './src/agents/p
19
19
  export { poppyWebSession, poppyWebSessionOf, } from './src/agents/poppy/web_session.js';
20
20
  export { pact, pactProtocol } from './src/agents/protocol.js';
21
21
  export { lucidAuditSink } from './src/audit/lucid_audit_sink.js';
22
+ export { authorizeByToken } from './src/console_authorize.js';
22
23
  export { adapters, defineConfig, resolveAdmin, resolveAdminApi, resolveAuthMethodsConfig, resolveDynamicRegistration, resolveLogin, resolveNotifications, resolveOrganizations, resolvePasswordless, resolveRateLimit, resolveRegistration, resolveWebauthn, toSeconds, } from './src/define_config.js';
23
24
  export { buildWebhookBody, composeAuditSink, resolveEvents, signWebhookBody, } from './src/events/dispatcher.js';
24
25
  export { inProcessAccessTokenVerifier, remoteAccessTokenVerifier, } from './src/host/access_token_verifier.js';
@@ -130,6 +130,7 @@ export default class AuthkitServerProvider {
130
130
  // Defaults estruturais de `config.routes` (o argumento ainda vence).
131
131
  routes: typeof config.routes === 'object' ? config.routes : undefined,
132
132
  lockedRouteOptions: config.lockedRouteOptions,
133
+ consoleNeedsLoginUrl: config.accountSession?.acceptIdpSession === false && config.authMethods?.password === false,
133
134
  personalAgents: config.personalAgents
134
135
  ? {
135
136
  prefix: config.personalAgents.prefix,
@@ -146,6 +147,13 @@ export default class AuthkitServerProvider {
146
147
  }
147
148
  : undefined,
148
149
  });
150
+ // Admin console without `admin.authorize`: open outside production, 403 in production.
151
+ // Said once, at boot.
152
+ if (config.admin.enabled && !config.admin.authorize) {
153
+ const { warnAdminConsoleUnauthorized } = await import('../src/host/admin_console_warning.js');
154
+ const logger = await this.app.container.make('logger').catch(() => null);
155
+ warnAdminConsoleUnauthorized(logger);
156
+ }
149
157
  // Poppy: o slot global que o `@adonis-agora/agent` usa para autenticar o endpoint de
150
158
  // conversas (e o MCP) sem importar este pacote — o contrato é o símbolo.
151
159
  if (config.personalAgents?.poppy) {
@@ -0,0 +1,56 @@
1
+ import type { HttpContext } from '@adonisjs/core/http';
2
+ /**
3
+ * What a console `authorize` returns. Same shape in every `@adonis-agora` console.
4
+ *
5
+ * - `true` / `{ allowed: true }` → allowed
6
+ * - `false` / `{ allowed: false }` → 403
7
+ * - `{ allowed: false, reason: 'unauthenticated' }` → 401
8
+ * - `{ redirectTo }` → 302 to that path on a page navigation, 401 JSON on an API call
9
+ */
10
+ export type ConsoleAuthorizeResult = boolean | {
11
+ allowed: true;
12
+ } | {
13
+ allowed: false;
14
+ reason?: 'unauthenticated' | 'forbidden';
15
+ } | {
16
+ redirectTo: string;
17
+ };
18
+ /** A class form, for an authorizer in its own file (`@inject()` works). */
19
+ export interface ConsoleAuthorizer {
20
+ authorize(ctx: HttpContext): ConsoleAuthorizeResult | Promise<ConsoleAuthorizeResult>;
21
+ }
22
+ export type ConsoleAuthorizerClass = new (...args: any[]) => ConsoleAuthorizer;
23
+ /**
24
+ * Who may open a console: an inline function, an authorizer class, or a lazy
25
+ * import of a module whose default export is an authorizer class.
26
+ */
27
+ export type ConsoleAuthorize = ((ctx: HttpContext) => ConsoleAuthorizeResult | Promise<ConsoleAuthorizeResult>) | ConsoleAuthorizerClass | (() => Promise<{
28
+ default: ConsoleAuthorizerClass;
29
+ }>);
30
+ /** The normalized outcome of a `ConsoleAuthorize` call. */
31
+ export type ConsoleAuthorizeDecision = {
32
+ allowed: true;
33
+ } | {
34
+ allowed: false;
35
+ status: 401 | 403;
36
+ redirectTo?: string;
37
+ };
38
+ export declare function normalizeConsoleAuthorizeResult(result: unknown): ConsoleAuthorizeDecision;
39
+ /**
40
+ * Turns a `ConsoleAuthorize` into a checker returning a normalized decision.
41
+ * A class is built through the container (so `@inject()` works); a lazy import
42
+ * runs once and its class is remembered. A thrown error is a denial (403).
43
+ */
44
+ export declare function resolveConsoleAuthorize(authorize: ConsoleAuthorize): (ctx: HttpContext) => Promise<ConsoleAuthorizeDecision>;
45
+ /**
46
+ * A `ConsoleAuthorize` that lets in requests carrying `Authorization: Bearer <token>`.
47
+ * The comparison is timing-safe. Anything else is `{ allowed: false, reason: 'unauthenticated' }`.
48
+ * Throws when `token` is empty, so a missing env var fails at config time.
49
+ *
50
+ * ```ts
51
+ * authorize: authorizeByToken({ token: env.get('CONSOLE_TOKEN') })
52
+ * ```
53
+ */
54
+ export declare function authorizeByToken(options: {
55
+ token: string | undefined | null;
56
+ }): (ctx: HttpContext) => ConsoleAuthorizeResult;
@@ -0,0 +1,85 @@
1
+ import { createHash, timingSafeEqual } from 'node:crypto';
2
+ function isAuthorizerClass(value) {
3
+ return (typeof value === 'function' &&
4
+ typeof value.prototype?.authorize === 'function');
5
+ }
6
+ function isLazyModule(value) {
7
+ return (typeof value === 'object' &&
8
+ value !== null &&
9
+ 'default' in value &&
10
+ isAuthorizerClass(value.default));
11
+ }
12
+ export function normalizeConsoleAuthorizeResult(result) {
13
+ if (result === true)
14
+ return { allowed: true };
15
+ if (typeof result === 'object' && result !== null) {
16
+ const r = result;
17
+ if (typeof r.redirectTo === 'string' && r.redirectTo.length > 0) {
18
+ return { allowed: false, status: 401, redirectTo: r.redirectTo };
19
+ }
20
+ if (r.allowed === true)
21
+ return { allowed: true };
22
+ if (r.allowed === false && r.reason === 'unauthenticated') {
23
+ return { allowed: false, status: 401 };
24
+ }
25
+ }
26
+ // `false`, `{ allowed: false }` and anything unrecognized deny.
27
+ return { allowed: false, status: 403 };
28
+ }
29
+ async function runClass(Authorizer, ctx) {
30
+ const instance = (await ctx.containerResolver.make(Authorizer));
31
+ return instance.authorize(ctx);
32
+ }
33
+ /**
34
+ * Turns a `ConsoleAuthorize` into a checker returning a normalized decision.
35
+ * A class is built through the container (so `@inject()` works); a lazy import
36
+ * runs once and its class is remembered. A thrown error is a denial (403).
37
+ */
38
+ export function resolveConsoleAuthorize(authorize) {
39
+ let lazyClass;
40
+ return async (ctx) => {
41
+ try {
42
+ if (isAuthorizerClass(authorize)) {
43
+ return normalizeConsoleAuthorizeResult(await runClass(authorize, ctx));
44
+ }
45
+ if (lazyClass)
46
+ return normalizeConsoleAuthorizeResult(await runClass(lazyClass, ctx));
47
+ const result = await authorize(ctx);
48
+ if (isLazyModule(result)) {
49
+ lazyClass = result.default;
50
+ return normalizeConsoleAuthorizeResult(await runClass(lazyClass, ctx));
51
+ }
52
+ return normalizeConsoleAuthorizeResult(result);
53
+ }
54
+ catch (error) {
55
+ ctx.logger?.warn?.({ err: error }, 'console authorize threw; access denied');
56
+ return { allowed: false, status: 403 };
57
+ }
58
+ };
59
+ }
60
+ /**
61
+ * A `ConsoleAuthorize` that lets in requests carrying `Authorization: Bearer <token>`.
62
+ * The comparison is timing-safe. Anything else is `{ allowed: false, reason: 'unauthenticated' }`.
63
+ * Throws when `token` is empty, so a missing env var fails at config time.
64
+ *
65
+ * ```ts
66
+ * authorize: authorizeByToken({ token: env.get('CONSOLE_TOKEN') })
67
+ * ```
68
+ */
69
+ export function authorizeByToken(options) {
70
+ const token = options?.token;
71
+ if (typeof token !== 'string' || token.length === 0) {
72
+ throw new Error('authorizeByToken: `token` is empty. Set it (e.g. env CONSOLE_TOKEN).');
73
+ }
74
+ const expected = createHash('sha256').update(token).digest();
75
+ return (ctx) => {
76
+ const header = ctx.request.header('authorization');
77
+ const match = typeof header === 'string' ? /^Bearer\s+(.+)$/i.exec(header.trim()) : null;
78
+ if (match) {
79
+ const given = createHash('sha256').update(match[1]).digest();
80
+ if (timingSafeEqual(given, expected))
81
+ return { allowed: true };
82
+ }
83
+ return { allowed: false, reason: 'unauthenticated' };
84
+ };
85
+ }
@@ -4,6 +4,7 @@ import type { AccountStore, AuthAccount } from './accounts/account_store.js';
4
4
  import { type AdapterFactory, adapters, type OidcAdapterClass } from './adapters/factory.js';
5
5
  import { type PersonalAgentsConfigInput, type ResolvedPersonalAgentsConfig } from './agents/config.js';
6
6
  import type { AuditSink } from './audit/audit_sink.js';
7
+ import type { ConsoleAuthorize } from './console_authorize.js';
7
8
  import { type EventsConfigInput } from './events/dispatcher.js';
8
9
  import { type BotProtectionConfigInput, type ResolvedBotProtectionConfig } from './host/bot_protection.js';
9
10
  import { type BrandingConfig } from './host/branding.js';
@@ -708,12 +709,21 @@ export interface AdminConfigInput {
708
709
  * default é decisão de major, mesma regra de `impersonation`).
709
710
  */
710
711
  impersonateAdmins?: boolean;
712
+ /**
713
+ * Access check for the console, run after `roles` (both must pass), on the shell
714
+ * and on every `{prefix}/api/*` route. An inline function, an authorizer class
715
+ * (`@inject()` works), a lazy import (`() => import('#policies/...')`) or
716
+ * `authorizeByToken({ token })`. Omitted: open outside production, 403 in production.
717
+ */
718
+ authorize?: ConsoleAuthorize;
711
719
  }
712
720
  export interface ResolvedAdminConfig {
713
721
  enabled: boolean;
714
722
  roles: string[];
715
723
  impersonation: boolean;
716
724
  impersonateAdmins: boolean;
725
+ /** `admin.authorize` as configured, or `null` when omitted. */
726
+ authorize: ConsoleAuthorize | null;
717
727
  }
718
728
  export declare function resolveAdmin(input?: AdminConfigInput): ResolvedAdminConfig;
719
729
  /**
@@ -1225,8 +1235,16 @@ export interface AuthServerConfigInput {
1225
1235
  * expiração), e o "Sair" do console encerra também a sessão do IdP. Contas
1226
1236
  * desabilitadas não entram. Operações sensíveis continuam pedindo sudo.
1227
1237
  *
1228
- * Default `false`: o console só aceita a própria sessão (`POST /account/login`),
1229
- * como sempre.
1238
+ * Default `true`: o console é mais um app do IdP. Sem sessão, `/account/*` e o
1239
+ * console admin mandam o visitante para o login do IdP (`/auth/console/sso`,
1240
+ * client interno `authkit-console`) e voltam com o console aberto. Um único
1241
+ * login, como no Keycloak, Okta e Google; o que protege as operações
1242
+ * sensíveis é o sudo, não um segundo login.
1243
+ *
1244
+ * `false`: o console só aceita a própria sessão (`POST /account/login`, por
1245
+ * senha). Com `authMethods: { password: false }` isso deixa o console sem
1246
+ * nenhum jeito de entrar, então o boot recusa a combinação, a menos que
1247
+ * `routes.accountLoginUrl` aponte para um login do próprio host.
1230
1248
  */
1231
1249
  accountSession?: {
1232
1250
  acceptIdpSession?: boolean;
@@ -193,6 +193,7 @@ export function resolveAdmin(input) {
193
193
  // registrado). Ver o docblock de `AdminConfigInput.impersonation`.
194
194
  impersonation: input?.impersonation !== false,
195
195
  impersonateAdmins: input?.impersonateAdmins !== false,
196
+ authorize: input?.authorize ?? null,
196
197
  };
197
198
  }
198
199
  /** Lê API keys de `AUTHKIT_ADMIN_API_KEY` (uma ou várias, separadas por vírgula). */
@@ -496,7 +497,7 @@ export function defineConfig(config) {
496
497
  lockedRouteOptions: deriveLockedRouteOptions(config),
497
498
  // Opt-in: ausente = authkit nunca toca `ctx.auth` (comportamento de sempre).
498
499
  adonisAuth: config.adonisAuth,
499
- accountSession: { acceptIdpSession: config.accountSession?.acceptIdpSession === true },
500
+ accountSession: { acceptIdpSession: config.accountSession?.acceptIdpSession !== false },
500
501
  };
501
502
  });
502
503
  }
@@ -87,7 +87,7 @@ export declare function checkOtpLockout(input: DoctorInput): Finding | null;
87
87
  export declare function checkSudoMode(_input: DoctorInput): Finding | null;
88
88
  /** rateLimit ligado mas @adonisjs/limiter ausente → warn. */
89
89
  export declare function checkRateLimit(input: DoctorInput): Finding;
90
- /** admin.enabled mas sem roles → warn. Reporta o modo de UI ativo. */
90
+ /** admin.enabled mas sem roles, ou sem `admin.authorize` → warn. Reporta o modo de UI ativo. */
91
91
  export declare function checkAdmin(input: DoctorInput): Finding | null;
92
92
  /** requireVerifiedEmail ligado mas o store não sabe checar verificação → warn. */
93
93
  export declare function checkRequireVerifiedEmail(input: DoctorInput): Finding | null;
@@ -214,6 +214,12 @@ export declare function checkPasskeyAutofill(input: DoctorInput): Finding | null
214
214
  * de TERCEIROS precisa saber que existe uma lista para fechar.
215
215
  */
216
216
  export declare function checkFirstPartyClients(input: DoctorInput): Finding | null;
217
+ /**
218
+ * Como se entra no console de conta e no console admin. O default é o login do
219
+ * IdP (SSO, `accountSession.acceptIdpSession`); desligado, o console só tem o
220
+ * login próprio por senha.
221
+ */
222
+ export declare function checkConsoleSignIn(input: DoctorInput): Finding | null;
217
223
  /**
218
224
  * Finding da idade da chave de assinatura managed. `ageDays === null` (sem
219
225
  * keystore em arquivo/cofre) → no-op `ok`. Acima de `maxAgeDays` → `warn`.
@@ -311,7 +311,7 @@ export function checkRateLimit(input) {
311
311
  }
312
312
  return { level: 'ok', message: 'rate-limiting on and @adonisjs/limiter available.' };
313
313
  }
314
- /** admin.enabled mas sem roles → warn. Reporta o modo de UI ativo. */
314
+ /** admin.enabled mas sem roles, ou sem `admin.authorize` → warn. Reporta o modo de UI ativo. */
315
315
  export function checkAdmin(input) {
316
316
  const admin = input.authkitConfig?.admin;
317
317
  if (!admin || admin.enabled !== true)
@@ -323,6 +323,12 @@ export function checkAdmin(input) {
323
323
  message: 'admin console on, but no `admin.roles` — nobody will have access (the default ["ADMIN"] was not resolved here).',
324
324
  };
325
325
  }
326
+ if (!admin.authorize) {
327
+ return {
328
+ level: 'warn',
329
+ message: `admin console on for roles: ${roles.join(', ')}, but no \`admin.authorize\` — it is open outside production and denies every request in production. Set \`admin.authorize\` in config/authkit.ts.`,
330
+ };
331
+ }
326
332
  return {
327
333
  level: 'ok',
328
334
  message: `admin console on for roles: ${roles.join(', ')} (React SPA self-contained — JSON API under {prefix}/api/*).`,
@@ -908,6 +914,43 @@ export function checkFirstPartyClients(input) {
908
914
  message: `firstPartyClients: ${allowlist.length} client(s) receive the roles/organization claims (${allowlist.join(', ')}).`,
909
915
  };
910
916
  }
917
+ /**
918
+ * Como se entra no console de conta e no console admin. O default é o login do
919
+ * IdP (SSO, `accountSession.acceptIdpSession`); desligado, o console só tem o
920
+ * login próprio por senha.
921
+ */
922
+ export function checkConsoleSignIn(input) {
923
+ const cfg = input.authkitConfig;
924
+ if (!cfg)
925
+ return null;
926
+ const sso = cfg.accountSession?.acceptIdpSession !== false;
927
+ const passwordOff = cfg.authMethods?.password === false;
928
+ const routes = typeof cfg.routes === 'object' && cfg.routes ? cfg.routes : undefined;
929
+ if (!sso && passwordOff && !routes?.accountLoginUrl) {
930
+ return {
931
+ level: 'warn',
932
+ message: 'The account and admin consoles have no way to sign in unless `registerAuthHost` gets an ' +
933
+ '`accountLoginUrl`: `accountSession.acceptIdpSession` is false and password login is off ' +
934
+ '(`authMethods: { password: false }`), and route registration refuses to start without ' +
935
+ 'one. Remove `acceptIdpSession: false` so the consoles sign in through the IdP, or point ' +
936
+ '`accountLoginUrl` at a login route of your app.',
937
+ };
938
+ }
939
+ if (!sso) {
940
+ return {
941
+ level: 'warn',
942
+ message: 'The consoles use their own password login (`accountSession.acceptIdpSession: false`): ' +
943
+ 'users sign in twice, and passwordless accounts cannot open them. The default (SSO through ' +
944
+ 'the IdP login) is recommended; sensitive operations still ask for sudo.',
945
+ };
946
+ }
947
+ return {
948
+ level: 'ok',
949
+ message: 'The consoles sign in through the IdP login (SSO). Sensitive operations ask for sudo. ' +
950
+ 'Make sure admin accounts have MFA enrolled (or require it for everyone with ' +
951
+ '`mfa.requiredFactors`).',
952
+ };
953
+ }
911
954
  /**
912
955
  * Finding da idade da chave de assinatura managed. `ageDays === null` (sem
913
956
  * keystore em arquivo/cofre) → no-op `ok`. Acima de `maxAgeDays` → `warn`.
@@ -1007,6 +1050,9 @@ export function runAllChecks(input) {
1007
1050
  const firstPartyClients = checkFirstPartyClients(input);
1008
1051
  if (firstPartyClients)
1009
1052
  findings.push(firstPartyClients);
1053
+ const consoleSignIn = checkConsoleSignIn(input);
1054
+ if (consoleSignIn)
1055
+ findings.push(consoleSignIn);
1010
1056
  return findings;
1011
1057
  }
1012
1058
  /** Há algum finding de nível 'error'? (define o exit code). */
@@ -1,5 +1,6 @@
1
1
  import { randomBytes } from 'node:crypto';
2
- import { assertClientMetadata } from './client_metadata.js';
2
+ import { assertClientMetadata, ClientMetadataError, } from './client_metadata.js';
3
+ import { CONSOLE_SSO_CLIENT_ID } from './console_sso.js';
3
4
  /** Métodos públicos (sem segredo) — espelham os "non-secret" do oidc-provider. */
4
5
  const PUBLIC_AUTH_METHODS = new Set(['none']);
5
6
  /** Gera um identificador opaco no estilo do oidc-provider (~43 chars base64url). */
@@ -51,6 +52,7 @@ export class AdminClientsService {
51
52
  */
52
53
  async create(input) {
53
54
  const clientId = (input.clientId ?? '').trim() || randomId();
55
+ assertNotReservedClientId(clientId);
54
56
  const confidential = !PUBLIC_AUTH_METHODS.has(input.tokenEndpointAuthMethod);
55
57
  const clientSecret = confidential ? randomId() : undefined;
56
58
  const payload = this.#buildPayload(clientId, input, clientSecret);
@@ -68,6 +70,7 @@ export class AdminClientsService {
68
70
  */
69
71
  async createWithSecret(input, providedSecret) {
70
72
  const clientId = (input.clientId ?? '').trim() || randomId();
73
+ assertNotReservedClientId(clientId);
71
74
  const confidential = !PUBLIC_AUTH_METHODS.has(input.tokenEndpointAuthMethod);
72
75
  const clientSecret = confidential ? (providedSecret ?? randomId()) : undefined;
73
76
  const payload = this.#buildPayload(clientId, input, clientSecret);
@@ -215,3 +218,9 @@ export class AdminClientsService {
215
218
  return result;
216
219
  }
217
220
  }
221
+ /** O `client_id` do client interno do console (ver `console_sso.ts`) não pode ser cadastrado. */
222
+ function assertNotReservedClientId(clientId) {
223
+ if (clientId === CONSOLE_SSO_CLIENT_ID) {
224
+ throw new ClientMetadataError(`client_id "${clientId}" é reservado pelo authkit`);
225
+ }
226
+ }
@@ -0,0 +1,8 @@
1
+ /** The boot warning for an admin console with no `admin.authorize`. Same wording in every console. */
2
+ export declare const ADMIN_CONSOLE_NO_AUTHORIZE_WARNING = "[@adonis-agora/authkit-server] console has no `authorize` configured: it is open outside production and denies every request in production. Set `authorize` in config/authkit.ts.";
3
+ /** Logs {@link ADMIN_CONSOLE_NO_AUTHORIZE_WARNING} once per process. */
4
+ export declare function warnAdminConsoleUnauthorized(logger: {
5
+ warn: (message: string) => unknown;
6
+ } | null | undefined): boolean;
7
+ /** Test helper: lets the warning fire again. */
8
+ export declare function resetAdminConsoleWarning(): void;
@@ -0,0 +1,18 @@
1
+ /** The boot warning for an admin console with no `admin.authorize`. Same wording in every console. */
2
+ export const ADMIN_CONSOLE_NO_AUTHORIZE_WARNING = '[@adonis-agora/authkit-server] console has no `authorize` configured: it is open outside production and denies every request in production. Set `authorize` in config/authkit.ts.';
3
+ let warned = false;
4
+ /** Logs {@link ADMIN_CONSOLE_NO_AUTHORIZE_WARNING} once per process. */
5
+ export function warnAdminConsoleUnauthorized(logger) {
6
+ if (warned)
7
+ return false;
8
+ warned = true;
9
+ if (logger)
10
+ logger.warn(ADMIN_CONSOLE_NO_AUTHORIZE_WARNING);
11
+ else
12
+ console.warn(ADMIN_CONSOLE_NO_AUTHORIZE_WARNING);
13
+ return true;
14
+ }
15
+ /** Test helper: lets the warning fire again. */
16
+ export function resetAdminConsoleWarning() {
17
+ warned = false;
18
+ }
@@ -40,6 +40,11 @@ export interface AuthHostRuntimeConfig {
40
40
  prefix: string;
41
41
  poppyPrefix?: string;
42
42
  };
43
+ /**
44
+ * `accountSession.acceptIdpSession: false` com `authMethods: { password: false }`:
45
+ * o console só entra por um `accountLoginUrl` do host (`registerAuthHost` recusa sem ele).
46
+ */
47
+ consoleNeedsLoginUrl?: boolean;
43
48
  /**
44
49
  * API headless (Clerk-style). Presente quando o host a declarou — o provider o
45
50
  * stash no boot para `registerAuthHost` montar as rotas sem reler o config inteiro.
@@ -0,0 +1,6 @@
1
+ /**
2
+ * The page a browser gets when `admin.authorize` denies the admin console
3
+ * (403), or asks for a sign-in it cannot do itself (401). Self-contained: no
4
+ * script and no external asset, so any CSP renders it.
5
+ */
6
+ export declare function consoleDeniedPage(status: 401 | 403): string;
@@ -0,0 +1,18 @@
1
+ /**
2
+ * The page a browser gets when `admin.authorize` denies the admin console
3
+ * (403), or asks for a sign-in it cannot do itself (401). Self-contained: no
4
+ * script and no external asset, so any CSP renders it.
5
+ */
6
+ export function consoleDeniedPage(status) {
7
+ const title = status === 401 ? 'Sign in required' : 'Access denied';
8
+ const body = status === 401
9
+ ? 'This console needs credentials this request did not carry.'
10
+ : 'You do not have access to this console.';
11
+ return `<!doctype html>
12
+ <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
13
+ <title>${title}</title>
14
+ <style>body{font-family:system-ui,sans-serif;background:#f9fafb;color:#111827;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0;padding:16px}
15
+ .box{max-width:420px;text-align:center}h1{font-size:20px;margin:0 0 8px}p{color:#4b5563;font-size:14px;line-height:1.6;margin:0}
16
+ .code{font-size:12px;color:#9ca3af;margin-bottom:8px}</style>
17
+ </head><body><main class="box"><p class="code">${status}</p><h1>${title}</h1><p>${body}</p></main></body></html>`;
18
+ }
@@ -0,0 +1,24 @@
1
+ /** `client_id` do client interno do console. Reservado: não pode ser cadastrado. */
2
+ export declare const CONSOLE_SSO_CLIENT_ID = "authkit-console";
3
+ /** Rota que inicia o login do console pelo IdP. */
4
+ export declare const CONSOLE_SSO_START_PATH = "/auth/console/sso";
5
+ /** `redirect_uri` do client interno: valida o `state` e abre o console. */
6
+ export declare const CONSOLE_SSO_CALLBACK_PATH = "/auth/console/callback";
7
+ /** Chave da sessão Adonis com o `state` e o destino de um login em andamento. */
8
+ export declare const CONSOLE_SSO_STATE_KEY = "authkit_console_sso";
9
+ /** O que fica na sessão entre o início e o callback. */
10
+ export interface ConsoleSsoState {
11
+ state: string;
12
+ returnTo: string | null;
13
+ }
14
+ /** Metadata do client interno para o `clients` estático do oidc-provider. */
15
+ export declare function consoleSsoClientMetadata(issuer: string): Record<string, unknown>;
16
+ /** Novo `state` opaco para um login do console. */
17
+ export declare function newConsoleSsoState(): string;
18
+ /**
19
+ * Path do authorize do provider. O issuer termina no `mountPath` (garantido por
20
+ * `registerAuthHost`), então o mount é o path do issuer.
21
+ */
22
+ export declare function authorizationPath(issuer: string, provider?: any): string;
23
+ /** URL do login do console pelo IdP, preservando o destino como `return_to`. */
24
+ export declare function consoleSsoUrl(returnTo?: string | null): string;
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Login do console pelo IdP (SSO), o jeito padrão de entrar em `/account/*` e no
3
+ * console admin.
4
+ *
5
+ * O console é tratado como mais um app do IdP, como o Keycloak faz com o
6
+ * `account-console` e o `security-admin-console`: sem sessão, o visitante vai
7
+ * para a tela de login do IdP (a mesma interaction de qualquer client: senha,
8
+ * magic link, OTP, passkey, social), volta, e a ponte de
9
+ * `idp_session_bridge.ts` abre o console para a conta da sessão do IdP.
10
+ *
11
+ * Para isso existe um client interno, `authkit-console`, injetado como client
12
+ * ESTÁTICO do provider (não fica no adapter, então não aparece na lista de
13
+ * clients do console nem pode ser editado):
14
+ *
15
+ * - `response_types: ['none']`: o authorize não emite code nem token. O client
16
+ * só usa o IdP para autenticar; o que abre o console é a sessão do IdP que o
17
+ * login criou, lida pela ponte. Não há segredo, PKCE ou troca de code.
18
+ * - o consent é concedido automaticamente (é a própria lib).
19
+ * - a única `redirect_uri` é `/auth/console/callback`, no MESMO host do issuer.
20
+ *
21
+ * Só existe com `accountSession.acceptIdpSession` ligado (o default).
22
+ */
23
+ import { randomBytes } from 'node:crypto';
24
+ /** `client_id` do client interno do console. Reservado: não pode ser cadastrado. */
25
+ export const CONSOLE_SSO_CLIENT_ID = 'authkit-console';
26
+ /** Rota que inicia o login do console pelo IdP. */
27
+ export const CONSOLE_SSO_START_PATH = '/auth/console/sso';
28
+ /** `redirect_uri` do client interno: valida o `state` e abre o console. */
29
+ export const CONSOLE_SSO_CALLBACK_PATH = '/auth/console/callback';
30
+ /** Chave da sessão Adonis com o `state` e o destino de um login em andamento. */
31
+ export const CONSOLE_SSO_STATE_KEY = 'authkit_console_sso';
32
+ /** Metadata do client interno para o `clients` estático do oidc-provider. */
33
+ export function consoleSsoClientMetadata(issuer) {
34
+ const origin = new URL(issuer).origin;
35
+ return {
36
+ client_id: CONSOLE_SSO_CLIENT_ID,
37
+ client_name: 'Account console',
38
+ application_type: 'web',
39
+ token_endpoint_auth_method: 'none',
40
+ response_types: ['none'],
41
+ grant_types: [],
42
+ redirect_uris: [`${origin}${CONSOLE_SSO_CALLBACK_PATH}`],
43
+ };
44
+ }
45
+ /** Novo `state` opaco para um login do console. */
46
+ export function newConsoleSsoState() {
47
+ return randomBytes(24).toString('base64url');
48
+ }
49
+ /**
50
+ * Path do authorize do provider. O issuer termina no `mountPath` (garantido por
51
+ * `registerAuthHost`), então o mount é o path do issuer.
52
+ */
53
+ export function authorizationPath(issuer, provider) {
54
+ const mount = new URL(issuer).pathname.replace(/\/+$/, '');
55
+ if (provider && typeof provider.pathFor === 'function') {
56
+ try {
57
+ return provider.pathFor('authorization', { mountPath: mount });
58
+ }
59
+ catch {
60
+ // cai no default do oidc-provider abaixo
61
+ }
62
+ }
63
+ return `${mount}/auth`;
64
+ }
65
+ /** URL do login do console pelo IdP, preservando o destino como `return_to`. */
66
+ export function consoleSsoUrl(returnTo) {
67
+ if (!returnTo)
68
+ return CONSOLE_SSO_START_PATH;
69
+ return `${CONSOLE_SSO_START_PATH}?return_to=${encodeURIComponent(returnTo)}`;
70
+ }
@@ -3,6 +3,7 @@ import { accountHome } from '../account_home.js';
3
3
  import { getAccountLoginUrl } from '../account_login_url.js';
4
4
  import { ACCOUNT_SESSION_KEY } from '../account_session_key.js';
5
5
  import { syncAdonisAuthLogin, syncAdonisAuthLogout } from '../adonis_auth_sync.js';
6
+ import { consoleSsoUrl } from '../console_sso.js';
6
7
  import { normalizeEmailIdentifier } from '../email_identifier.js';
7
8
  import { translate } from '../i18n.js';
8
9
  import { endBridgedIdpSession } from '../idp_session_bridge.js';
@@ -49,8 +50,16 @@ export default class AccountSessionController {
49
50
  return ctx.response.redirect(accountHome(cfg));
50
51
  }
51
52
  // Lê e valida o return_to da query-string — descarta valores inválidos (open-redirect).
52
- const rawReturnTo = ctx.request.qs?.()?.return_to ?? ctx.request.input?.('return_to');
53
+ const qs = (ctx.request.qs?.() ?? {});
54
+ const rawReturnTo = qs.return_to ?? ctx.request.input?.('return_to');
53
55
  const returnTo = validateReturnTo(rawReturnTo);
56
+ // SSO (default): o console entra pelo login do IdP, a mesma tela de qualquer
57
+ // app. O formulário de senha daqui fica como porta alternativa
58
+ // (`?method=password`), e só quando a senha não foi desligada no config.
59
+ const passwordForm = qs.method === 'password' && cfg.authMethods?.password !== false;
60
+ if (cfg.accountSession?.acceptIdpSession === true && !passwordForm) {
61
+ return redirectExact(ctx.response, consoleSsoUrl(returnTo));
62
+ }
54
63
  const props = {
55
64
  csrfToken: ctx.request.csrfToken,
56
65
  returnTo,
@@ -0,0 +1,12 @@
1
+ import '../augmentations.js';
2
+ import type { HttpContext } from '@adonisjs/core/http';
3
+ /**
4
+ * Login do console pelo IdP (ver `console_sso.ts`).
5
+ *
6
+ * GET /auth/console/sso?return_to=… → authorize do client interno
7
+ * GET /auth/console/callback?state=… → valida o state, abre o console pela ponte
8
+ */
9
+ export default class ConsoleSsoController {
10
+ start(ctx: HttpContext): Promise<unknown>;
11
+ callback(ctx: HttpContext): Promise<unknown>;
12
+ }
@@ -0,0 +1,61 @@
1
+ import '../augmentations.js';
2
+ import { accountHome } from '../account_home.js';
3
+ import { getAccountLoginUrl } from '../account_login_url.js';
4
+ import { authorizationPath, CONSOLE_SSO_CALLBACK_PATH, CONSOLE_SSO_CLIENT_ID, CONSOLE_SSO_STATE_KEY, newConsoleSsoState, } from '../console_sso.js';
5
+ import { ensureConsoleSession } from '../idp_session_bridge.js';
6
+ import { redirectExact } from '../redirect_exact.js';
7
+ import { validateReturnTo } from './account_session_controller.js';
8
+ /**
9
+ * Login do console pelo IdP (ver `console_sso.ts`).
10
+ *
11
+ * GET /auth/console/sso?return_to=… → authorize do client interno
12
+ * GET /auth/console/callback?state=… → valida o state, abre o console pela ponte
13
+ */
14
+ export default class ConsoleSsoController {
15
+ async start(ctx) {
16
+ const service = await ctx.containerResolver.make('authkit.server');
17
+ const cfg = service.config;
18
+ const returnTo = validateReturnTo(ctx.request.qs?.()?.return_to ?? ctx.request.input?.('return_to'));
19
+ // SSO desligado (`acceptIdpSession: false`): o console só tem o login próprio.
20
+ if (cfg.accountSession?.acceptIdpSession !== true) {
21
+ const login = getAccountLoginUrl();
22
+ const sep = login.includes('?') ? '&' : '?';
23
+ return redirectExact(ctx.response, returnTo ? `${login}${sep}return_to=${encodeURIComponent(returnTo)}` : login);
24
+ }
25
+ // Já tem sessão (de console ou do IdP): não precisa passar pelo IdP.
26
+ if (await ensureConsoleSession(ctx)) {
27
+ return redirectExact(ctx.response, returnTo ?? accountHome(cfg));
28
+ }
29
+ const state = newConsoleSsoState();
30
+ ctx.session.put(CONSOLE_SSO_STATE_KEY, { state, returnTo });
31
+ const origin = new URL(cfg.issuer).origin;
32
+ const query = new URLSearchParams({
33
+ client_id: CONSOLE_SSO_CLIENT_ID,
34
+ response_type: 'none',
35
+ scope: 'openid',
36
+ redirect_uri: `${origin}${CONSOLE_SSO_CALLBACK_PATH}`,
37
+ state,
38
+ });
39
+ return redirectExact(ctx.response, `${authorizationPath(cfg.issuer, service.provider)}?${query.toString()}`);
40
+ }
41
+ async callback(ctx) {
42
+ const service = await ctx.containerResolver.make('authkit.server');
43
+ const cfg = service.config;
44
+ const pending = ctx.session.get(CONSOLE_SSO_STATE_KEY);
45
+ ctx.session.forget(CONSOLE_SSO_STATE_KEY);
46
+ const qs = (ctx.request.qs?.() ?? {});
47
+ // `state` amarra o callback ao navegador que iniciou o login (anti-CSRF de login).
48
+ if (!pending || typeof qs.state !== 'string' || qs.state !== pending.state) {
49
+ return ctx.response.status(400).send('Invalid or expired sign-in request.');
50
+ }
51
+ // O IdP recusou (o usuário cancelou, conta desabilitada, …): sem console.
52
+ // Vai para a raiz, e não de volta ao console, para não entrar em loop.
53
+ if (typeof qs.error === 'string') {
54
+ return redirectExact(ctx.response, '/');
55
+ }
56
+ if (!(await ensureConsoleSession(ctx))) {
57
+ return redirectExact(ctx.response, '/');
58
+ }
59
+ return redirectExact(ctx.response, pending.returnTo ?? accountHome(cfg));
60
+ }
61
+ }
@@ -5,6 +5,7 @@ import { AdminSessionsService } from '../admin_sessions_service.js';
5
5
  import { guardBotProtection, resolveEffectiveBotProtection } from '../bot_protection.js';
6
6
  import { brandFor, isFirstParty } from '../branding.js';
7
7
  import { consentRequestParams } from '../consent_params.js';
8
+ import { CONSOLE_SSO_CLIENT_ID } from '../console_sso.js';
8
9
  import { isCustomLoginMethodId } from '../custom_login.js';
9
10
  import { completeCustomLoginSession } from '../custom_login_completion.js';
10
11
  import { beginCustomMfa, clearMfaFlow, customMfaViewProps, getMfaFlow, initializeMfaFlow, recordVerifiedMfaFactor, resolveCustomMfaMethods, verifyCustomMfa, } from '../custom_mfa.js';
@@ -254,7 +255,8 @@ export default class AuthInteractionController {
254
255
  }
255
256
  }
256
257
  if (details.prompt.name === 'consent' &&
257
- isFirstParty(cfg.branding, details.params.client_id)) {
258
+ (details.params.client_id === CONSOLE_SSO_CLIENT_ID ||
259
+ isFirstParty(cfg.branding, details.params.client_id))) {
258
260
  // Clients first-party: auto-concede o consent (pula a tela de consent).
259
261
  // interactions.consent monta o Grant + interactionFinished e escreve o
260
262
  // redirect de volta para o client — opera via provider.interactionDetails,
@@ -20,6 +20,8 @@ export declare function wantsJson(ctx: any): boolean;
20
20
  * (senão → `accountHome(cfg)`, default /account/security — NÃO revela a
21
21
  * existência do /admin, e cai numa tela que o host controla via `accountHome`;
22
22
  * se a tela default estiver desmontada, aponte `config.accountHome` para uma montada).
23
+ * 3. `config.admin.authorize` liberando (403/401/redirect conforme o resultado). Omitido:
24
+ * aberto fora de produção, 403 em produção.
23
25
  * As roles permitidas são resolvidas em runtime do `authkit.server` (config lazy).
24
26
  */
25
27
  export declare const adminGuard: (ctx: any, next: () => Promise<void>) => Promise<any>;
@@ -1,3 +1,5 @@
1
+ import { getBootedApp } from '../../services/booted_app.js';
2
+ import { resolveConsoleAuthorize, } from '../console_authorize.js';
1
3
  import { resolveRateLimit } from '../define_config.js';
2
4
  import { accountHome } from './account_home.js';
3
5
  import { getAccountLoginUrl, setAccountLoginUrl } from './account_login_url.js';
@@ -7,7 +9,9 @@ import { ACCOUNT_SESSION_KEY } from './account_session_key.js';
7
9
  import { adminApiGuard } from './admin_api/admin_api_guard.js';
8
10
  import { normalizeAdminApiPrefix, normalizeAdminPrefix, setAdminApiPrefix, setAdminPrefix, } from './admin_prefix.js';
9
11
  import { getAuthHostConfig, markAuthHostAutoMounted, wasAuthHostAutoMounted, } from './auth_host_config.js';
10
- import { ensureConsoleSession } from './idp_session_bridge.js';
12
+ import { consoleDeniedPage } from './console_denied_page.js';
13
+ import { CONSOLE_SSO_CALLBACK_PATH, CONSOLE_SSO_START_PATH } from './console_sso.js';
14
+ import { endBridgedIdpSession, ensureConsoleSession } from './idp_session_bridge.js';
11
15
  import { POPPY_WEB_SESSION_KEY } from './poppy_session_key.js';
12
16
  import { createAuthThrottles } from './rate_limit.js';
13
17
  import { redirectExact } from './redirect_exact.js';
@@ -67,7 +71,10 @@ async function checkAndRefreshIdle(ctx) {
67
71
  const lastSeen = ctx.session?.get(ACCOUNT_LAST_SEEN_KEY);
68
72
  const now = Date.now();
69
73
  if (lastSeen !== undefined && now - lastSeen > idleMs) {
70
- // Idle excedido: encerra a sessão.
74
+ // Idle excedido: encerra a sessão. Console aberto pela sessão do IdP (SSO):
75
+ // encerra também essa sessão, senão a ponte reabriria o console no próximo
76
+ // request e o idle timeout não teria efeito.
77
+ await endBridgedIdpSession(ctx);
71
78
  ctx.session?.forget(ACCOUNT_SESSION_KEY);
72
79
  ctx.session?.forget(ACCOUNT_LAST_SEEN_KEY);
73
80
  return true;
@@ -166,6 +173,8 @@ const accountGuard = async (ctx, next) => {
166
173
  * (senão → `accountHome(cfg)`, default /account/security — NÃO revela a
167
174
  * existência do /admin, e cai numa tela que o host controla via `accountHome`;
168
175
  * se a tela default estiver desmontada, aponte `config.accountHome` para uma montada).
176
+ * 3. `config.admin.authorize` liberando (403/401/redirect conforme o resultado). Omitido:
177
+ * aberto fora de produção, 403 em produção.
169
178
  * As roles permitidas são resolvidas em runtime do `authkit.server` (config lazy).
170
179
  */
171
180
  export const adminGuard = async (ctx, next) => {
@@ -206,10 +215,63 @@ export const adminGuard = async (ctx, next) => {
206
215
  .status(403)
207
216
  .send({ error: { code: 'forbidden', message: 'Admin role required.' } });
208
217
  }
209
- return ctx.response.redirect(accountHome(cfg));
218
+ return redirectExact(ctx.response, accountHome(cfg));
210
219
  }
220
+ // `admin.authorize`, after the roles: both must pass. Omitted, the console is open
221
+ // outside production and closed (403) in production.
222
+ const decision = cfg.admin.authorize
223
+ ? await adminAuthorizeChecker(cfg.admin.authorize)(ctx)
224
+ : appInProduction()
225
+ ? { allowed: false, status: 403 }
226
+ : { allowed: true };
227
+ if (!decision.allowed)
228
+ return consoleDenied(ctx, decision);
211
229
  return next();
212
230
  };
231
+ /**
232
+ * Whether the app runs in production. Unknown (the provider did not register the
233
+ * app) counts as production, so an omitted `admin.authorize` fails closed.
234
+ */
235
+ function appInProduction() {
236
+ try {
237
+ return getBootedApp().inProduction !== false;
238
+ }
239
+ catch {
240
+ return true;
241
+ }
242
+ }
243
+ /** The response for an `admin.authorize` denial: same rules in every `@adonis-agora` console. */
244
+ function consoleDenied(ctx, decision) {
245
+ const json = wantsJson(ctx);
246
+ if (decision.redirectTo) {
247
+ if (json) {
248
+ return ctx.response
249
+ .status(401)
250
+ .send({ error: 'unauthenticated', redirectTo: decision.redirectTo });
251
+ }
252
+ return redirectExact(ctx.response, decision.redirectTo);
253
+ }
254
+ if (decision.status === 401) {
255
+ if (json)
256
+ return ctx.response.status(401).send({ error: 'unauthenticated' });
257
+ ctx.response.header('Content-Type', 'text/html; charset=utf-8');
258
+ return ctx.response.status(401).send(consoleDeniedPage(401));
259
+ }
260
+ if (json)
261
+ return ctx.response.status(403).send({ error: 'forbidden' });
262
+ ctx.response.header('Content-Type', 'text/html; charset=utf-8');
263
+ return ctx.response.status(403).send(consoleDeniedPage(403));
264
+ }
265
+ /** One checker per configured `admin.authorize`, so a lazy import runs once. */
266
+ const adminAuthorizeCheckers = new WeakMap();
267
+ function adminAuthorizeChecker(authorize) {
268
+ let checker = adminAuthorizeCheckers.get(authorize);
269
+ if (!checker) {
270
+ checker = resolveConsoleAuthorize(authorize);
271
+ adminAuthorizeCheckers.set(authorize, checker);
272
+ }
273
+ return checker;
274
+ }
213
275
  const C = {
214
276
  oidc: () => import('../controllers/oidc_callback_controller.js'),
215
277
  authorizationServerMetadata: () => import('../controllers/authorization_server_metadata_controller.js'),
@@ -218,6 +280,7 @@ const C = {
218
280
  social: () => import('./controllers/social_controller.js'),
219
281
  patIntrospection: () => import('./controllers/pat_introspection_controller.js'),
220
282
  accountSession: () => import('./controllers/account_session_controller.js'),
283
+ consoleSso: () => import('./controllers/console_sso_controller.js'),
221
284
  accountTokens: () => import('./controllers/account_tokens_controller.js'),
222
285
  accountSecurity: () => import('./controllers/account_security_controller.js'),
223
286
  accountApps: () => import('./controllers/account_apps_controller.js'),
@@ -380,6 +443,17 @@ export function registerAuthHost(router, opts = {}) {
380
443
  if (accountLoginUrlOpt !== undefined) {
381
444
  setAccountLoginUrl(accountLoginUrlOpt);
382
445
  }
446
+ // Console sem porta de entrada: SSO desligado (`acceptIdpSession: false`) deixa só o
447
+ // login próprio do console, que é por senha; com a senha desligada ninguém entra em
448
+ // `/account/*` nem no console admin. Só passa com um `accountLoginUrl` do host (aqui
449
+ // ou em `routes.accountLoginUrl`) — por isso a checagem é no registro, não no config.
450
+ if (hostCfg?.consoleNeedsLoginUrl && !accountLoginUrlOpt) {
451
+ throw new Error('authkit: `accountSession.acceptIdpSession: false` com `authMethods: { password: false }` ' +
452
+ 'deixa o console de conta e o console admin sem nenhum jeito de entrar (o login próprio ' +
453
+ 'do console é por senha). Remova `acceptIdpSession: false` (o default entra pelo login ' +
454
+ 'do IdP) ou passe `accountLoginUrl` (no `registerAuthHost` ou em `routes`) apontando ' +
455
+ 'para um login do seu app.');
456
+ }
383
457
  // Montagem por tela do console de conta. `undefined` → tudo montado;
384
458
  // `false` → nada; objeto → cada flag ausente default `true`.
385
459
  const accountOpt = opts.account ?? routesCfg?.account;
@@ -506,6 +580,13 @@ export function registerAuthHost(router, opts = {}) {
506
580
  // Provider OIDC (wildcard + root) — o que registerOidcRoutes fazia.
507
581
  router.any(`${mount}/*`, [C.oidc]).as('authkit.oidc.wildcard');
508
582
  router.any(mount, [C.oidc]).as('authkit.oidc.root');
583
+ // Login do console pelo IdP (SSO; client interno `authkit-console`, ver
584
+ // `console_sso.ts`). Sempre montado: com `acceptIdpSession: false` o início só
585
+ // redireciona para o login próprio do console.
586
+ router.get(CONSOLE_SSO_START_PATH, [C.consoleSso, 'start']).as('authkit.console_sso.start');
587
+ router
588
+ .get(CONSOLE_SSO_CALLBACK_PATH, [C.consoleSso, 'callback'])
589
+ .as('authkit.console_sso.callback');
509
590
  // Interaction (login multi-step + consent + signup).
510
591
  // Cada rota passa por `keep(key, …)`: entra em `AuthHostRouteMap.routes`/`urls` e
511
592
  // recebe o `extraMiddleware` do host (depois do throttle da lib).
@@ -1,6 +1,7 @@
1
1
  import * as oidc from 'oidc-provider';
2
2
  import { pickModelAdapterClass } from '../adapters/factory.js';
3
3
  import { normalizeActiveOrg, readActiveOrgFromKoaCtx } from '../host/active_org_cookie.js';
4
+ import { consoleSsoClientMetadata } from '../host/console_sso.js';
4
5
  import { findMcpResource, mcpAuthorizeMiddleware } from '../mcp/mcp_oauth.js';
5
6
  import { createDeviceSources } from './device_sources.js';
6
7
  import { createLogoutSources } from './logout_sources.js';
@@ -129,6 +130,11 @@ export function buildProvider(config, options, sessionTtlHolder, tokenTtlHolder)
129
130
  }
130
131
  : {};
131
132
  const provider = new oidc.Provider(config.issuer, {
133
+ // Client interno do login do console pelo IdP (ver `host/console_sso.ts`).
134
+ // Estático: não passa pelo adapter, então não aparece no console de clients.
135
+ ...(config.accountSession?.acceptIdpSession
136
+ ? { clients: [consoleSsoClientMetadata(config.issuer)] }
137
+ : {}),
132
138
  // O ator (`act`, RFC 8693) dos access tokens de impersonation — ver
133
139
  // `impersonationExtraClaims` em `token_exchange.ts`. Sem isto o token trocado
134
140
  // não se distingue de um token do próprio alvo no resource server.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adonis-agora/authkit-server",
3
- "version": "1.0.0-rc.0",
3
+ "version": "1.0.0-rc.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",
@@ -110,10 +110,10 @@
110
110
  "@simplewebauthn/browser": "14.0.0",
111
111
  "@simplewebauthn/server": "14.1.0",
112
112
  "jose": "6.2.15",
113
- "koa": "3.2.1",
113
+ "koa": "3.3.0",
114
114
  "koa-mount": "4.2.0",
115
115
  "oidc-provider": "9.13.3",
116
- "otplib": "13.5.0",
116
+ "otplib": "13.6.0",
117
117
  "qrcode": "1.5.4"
118
118
  },
119
119
  "devDependencies": {
@@ -140,7 +140,7 @@
140
140
  "@types/koa": "3.0.3",
141
141
  "@types/koa-mount": "4.0.5",
142
142
  "@types/luxon": "3.7.6",
143
- "@types/node": "26.6.5",
143
+ "@types/node": "26.6.6",
144
144
  "@types/qrcode": "1.5.6",
145
145
  "@types/react": "19.3.0",
146
146
  "@types/react-dom": "19.3.0",