@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 +17 -0
- package/build/index.d.ts +2 -0
- package/build/index.js +1 -0
- package/build/providers/authkit_server_provider.js +8 -0
- package/build/src/console_authorize.d.ts +56 -0
- package/build/src/console_authorize.js +85 -0
- package/build/src/define_config.d.ts +20 -2
- package/build/src/define_config.js +2 -1
- package/build/src/doctor/checks.d.ts +7 -1
- package/build/src/doctor/checks.js +47 -1
- package/build/src/host/admin_clients_service.js +10 -1
- package/build/src/host/admin_console_warning.d.ts +8 -0
- package/build/src/host/admin_console_warning.js +18 -0
- package/build/src/host/auth_host_config.d.ts +5 -0
- package/build/src/host/console_denied_page.d.ts +6 -0
- package/build/src/host/console_denied_page.js +18 -0
- package/build/src/host/console_sso.d.ts +24 -0
- package/build/src/host/console_sso.js +70 -0
- package/build/src/host/controllers/account_session_controller.js +10 -1
- package/build/src/host/controllers/console_sso_controller.d.ts +12 -0
- package/build/src/host/controllers/console_sso_controller.js +61 -0
- package/build/src/host/controllers/interaction_controller.js +3 -1
- package/build/src/host/register_auth_host.d.ts +2 -0
- package/build/src/host/register_auth_host.js +84 -3
- package/build/src/provider/build_provider.js +6 -0
- package/package.json +4 -4
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 `
|
|
1229
|
-
*
|
|
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
|
|
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
|
|
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
|
-
|
|
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 {
|
|
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
|
|
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.
|
|
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.
|
|
113
|
+
"koa": "3.3.0",
|
|
114
114
|
"koa-mount": "4.2.0",
|
|
115
115
|
"oidc-provider": "9.13.3",
|
|
116
|
-
"otplib": "13.
|
|
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.
|
|
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",
|