@aooth/auth-moost 0.1.18 → 0.1.20
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/dist/atscript/index.d.mts +449 -1
- package/dist/atscript/index.mjs +1 -1
- package/dist/{forms-D7ZfanKT.mjs → forms-uqegc32h.mjs} +1 -1
- package/dist/index.d.mts +314 -19
- package/dist/index.mjs +434 -60
- package/package.json +19 -19
- package/src/atscript/models/forms.as +8 -3
- package/src/atscript/models/forms.as.d.ts +1 -1
package/dist/index.mjs
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
|
-
import { C as Select2faForm, E as TermsBumpForm, S as RemoveMfaConfirmForm, T as SignupForm, _ as PincodeForm, a as ConcurrencyLimitForm, c as EnrollConfirmForm, d as InviteForm, f as LoginCredentialsForm, g as PasswordReauthForm, h as MfaCodeForm, i as ChangePasswordForm, l as EnrollPickMethodForm, m as ManageMfaForm, n as AskPhoneForm, o as EmailIdentifierForm, r as AuthorizeConsentForm, s as EnrollAddressForm, t as AskEmailForm, u as EnrollTotpQrForm, v as ProveControlForm, w as SetPasswordForm, y as ProveControlOtpForm } from "./forms-
|
|
1
|
+
import { C as Select2faForm, E as TermsBumpForm, S as RemoveMfaConfirmForm, T as SignupForm, _ as PincodeForm, a as ConcurrencyLimitForm, c as EnrollConfirmForm, d as InviteForm, f as LoginCredentialsForm, g as PasswordReauthForm, h as MfaCodeForm, i as ChangePasswordForm, l as EnrollPickMethodForm, m as ManageMfaForm, n as AskPhoneForm, o as EmailIdentifierForm, r as AuthorizeConsentForm, s as EnrollAddressForm, t as AskEmailForm, u as EnrollTotpQrForm, v as ProveControlForm, w as SetPasswordForm, y as ProveControlOtpForm } from "./forms-uqegc32h.mjs";
|
|
2
2
|
import { Controller, HandlerPaths, Inherit, Inject, InjectMoostLogger, Injectable, Intercept, MoostInit, Optional, Param, Resolve, TInterceptorPriority, defineAfterInterceptor, defineBeforeInterceptor, getMoostMate, useControllerContext } from "moost";
|
|
3
3
|
import { AuthCredential, AuthError, generateMagicLinkToken } from "@aooth/auth";
|
|
4
4
|
import { current, defineWook, eventTypeKey, key } from "@wooksjs/event-core";
|
|
5
5
|
import { HttpError, useAuthorization, useCookies, useHeaders, useRequest, useResponse, useUrlParams } from "@wooksjs/event-http";
|
|
6
6
|
import { ArbacAction, ArbacResource, getArbacMate } from "@aooth/arbac-moost";
|
|
7
|
-
import { FederatedIdentityStore, UserAuthError, UserService, generateMfaCode, generateTotpSecret, generateTotpUri, maskEmail, maskPhone, pickDefinedProfile, verifyTotpCode } from "@aooth/user";
|
|
7
|
+
import { FederatedIdentityStore, SEEN_DEVICES_DEFAULT_CAP, UserAuthError, UserService, generateMfaCode, generateTotpSecret, generateTotpUri, maskEmail, maskPhone, pickDefinedProfile, verifyTotpCode } from "@aooth/user";
|
|
8
8
|
import { Body, Delete, Get, HttpError as HttpError$1, Post, Query } from "@moostjs/event-http";
|
|
9
9
|
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
|
10
10
|
import { createAsHttpOutlet, finishWf, handleAsOutletRequest, useAtscriptWf } from "@atscript/moost-wf";
|
|
11
11
|
import { EncapsulatedStateStrategy, MoostWf, Step, StepRetriableError, StepTTL, Workflow, WorkflowParam, WorkflowSchema, createEmailOutlet, outletEmail, swapStrategy, useWfFinished, useWfState } from "@moostjs/event-wf";
|
|
12
12
|
import { FederatedLoginService, OAuthError, OAuthProviderRegistry, generateRandomState, pkceChallengeFor } from "@aooth/idp";
|
|
13
|
-
import {
|
|
13
|
+
import { ClientRegistrationError, DynamicClientRegistration, NoopOidcClaimsResolver, buildAuthorizationServerMetadata, buildAuthorizationServerMetadata as buildAuthorizationServerMetadata$1, buildProtectedResourceMetadata, buildWwwAuthenticateBearerChallenge, canonicalizeIssuer, canonicalizeIssuer as canonicalizeIssuer$1 } from "@aooth/auth/authz";
|
|
14
14
|
//#region \0@oxc-project+runtime@0.133.0/helpers/esm/decorate.js
|
|
15
15
|
function __decorate(decorators, target, key, desc) {
|
|
16
16
|
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
@@ -705,6 +705,17 @@ const AUTH_CODE_STORE_TOKEN = "aooth:AuthCodeStore";
|
|
|
705
705
|
* `CompositeClientPolicy` of both) under this string.
|
|
706
706
|
*/
|
|
707
707
|
const CLIENT_REDIRECT_POLICY_TOKEN = "aooth:ClientRedirectPolicy";
|
|
708
|
+
/**
|
|
709
|
+
* DI token for the {@link import("@aooth/auth/authz").DynamicClientStore}
|
|
710
|
+
* (RFC 7591 dynamic registrations) — an abstract class, same auto-instantiation
|
|
711
|
+
* hazard as the store tokens above. The BASE `AuthorizeController` never
|
|
712
|
+
* injects it (DCR is optional, and an optional `@Inject` panics in moost's
|
|
713
|
+
* route-table pass): a consumer subclass that enables DCR adds it as a
|
|
714
|
+
* REQUIRED ctor param, builds a `DynamicClientRegistration` around it, and
|
|
715
|
+
* overrides `getDynamicClientRegistration()`. The same store instance also
|
|
716
|
+
* backs the `DynamicClientPolicy` composed into the redirect policy.
|
|
717
|
+
*/
|
|
718
|
+
const DYNAMIC_CLIENT_STORE_TOKEN = "aooth:DynamicClientStore";
|
|
708
719
|
//#endregion
|
|
709
720
|
//#region \0@oxc-project+runtime@0.133.0/helpers/esm/decorateParam.js
|
|
710
721
|
function __decorateParam(paramIndex, decorator) {
|
|
@@ -953,6 +964,12 @@ const DEFAULT_FORMS = {
|
|
|
953
964
|
authzConsent: AuthorizeConsentForm
|
|
954
965
|
};
|
|
955
966
|
function mergeAuthWorkflowOpts(opts) {
|
|
967
|
+
const deviceTrust = {
|
|
968
|
+
cookieName: "aooth_trusted_device",
|
|
969
|
+
ttlMs: 1440 * 6e4,
|
|
970
|
+
bindsTo: "cookie",
|
|
971
|
+
...opts.deviceTrust
|
|
972
|
+
};
|
|
956
973
|
return {
|
|
957
974
|
autoLoginOnInvite: opts.autoLoginOnInvite ?? true,
|
|
958
975
|
autoLoginOnRecover: opts.autoLoginOnRecover ?? false,
|
|
@@ -965,11 +982,11 @@ function mergeAuthWorkflowOpts(opts) {
|
|
|
965
982
|
recoveryStateTtlMs: opts.recoveryStateTtlMs ?? 3600 * 1e3,
|
|
966
983
|
loginUrl: opts.loginUrl ?? "/login",
|
|
967
984
|
totpIssuer: opts.totpIssuer ?? "aooth",
|
|
968
|
-
deviceTrust
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
985
|
+
deviceTrust,
|
|
986
|
+
deviceRecognition: {
|
|
987
|
+
cookieName: opts.deviceRecognition?.cookieName ?? `${deviceTrust.cookieName}_seen`,
|
|
988
|
+
ttlMs: opts.deviceRecognition?.ttlMs ?? 4320 * 60 * 6e4,
|
|
989
|
+
maxDevices: opts.deviceRecognition?.maxDevices ?? SEEN_DEVICES_DEFAULT_CAP
|
|
973
990
|
},
|
|
974
991
|
forms: {
|
|
975
992
|
...DEFAULT_FORMS,
|
|
@@ -995,6 +1012,7 @@ const RESERVED_USER_KEYS = new Set([
|
|
|
995
1012
|
"passwordHistory",
|
|
996
1013
|
"mfa",
|
|
997
1014
|
"trustedDevices",
|
|
1015
|
+
"seenDevices",
|
|
998
1016
|
"pendingInvitation"
|
|
999
1017
|
]);
|
|
1000
1018
|
/**
|
|
@@ -1006,6 +1024,50 @@ function stripReservedUserKeys(profile) {
|
|
|
1006
1024
|
for (const key of Object.keys(profile)) if (!RESERVED_USER_KEYS.has(key)) out[key] = profile[key];
|
|
1007
1025
|
return out;
|
|
1008
1026
|
}
|
|
1027
|
+
const UA_BROWSERS = [
|
|
1028
|
+
["Edg", "Edge"],
|
|
1029
|
+
["Chrome", "Chrome"],
|
|
1030
|
+
["Firefox", "Firefox"],
|
|
1031
|
+
["Safari", "Safari"]
|
|
1032
|
+
];
|
|
1033
|
+
const UA_OSES = [
|
|
1034
|
+
[/iPhone|iPad|iPod/, "iOS"],
|
|
1035
|
+
[/Android/, "Android"],
|
|
1036
|
+
[/Mac OS X|Macintosh/, "macOS"],
|
|
1037
|
+
[/Windows/, "Windows"],
|
|
1038
|
+
[/Linux/, "Linux"]
|
|
1039
|
+
];
|
|
1040
|
+
/**
|
|
1041
|
+
* Best-effort "Browser on OS" label from a raw User-Agent string — feeds the
|
|
1042
|
+
* `name` field of `seenDevices` records so a device list reads "Chrome on
|
|
1043
|
+
* Windows" instead of a token. Deliberately tiny (no UA-parser dependency);
|
|
1044
|
+
* detection order lives in the `UA_BROWSERS` / `UA_OSES` tables above.
|
|
1045
|
+
* Returns just the browser or just the OS when only one side is detected;
|
|
1046
|
+
* `undefined` for empty / fully unrecognized input.
|
|
1047
|
+
*/
|
|
1048
|
+
function humanizeUserAgent(ua) {
|
|
1049
|
+
if (!ua) return void 0;
|
|
1050
|
+
const browser = UA_BROWSERS.find(([token]) => ua.includes(token))?.[1];
|
|
1051
|
+
const os = UA_OSES.find(([pattern]) => pattern.test(ua))?.[1];
|
|
1052
|
+
if (browser && os) return `${browser} on ${os}`;
|
|
1053
|
+
return browser ?? os;
|
|
1054
|
+
}
|
|
1055
|
+
/**
|
|
1056
|
+
* Great-circle distance in kilometres between two coordinates (haversine,
|
|
1057
|
+
* WGS84 mean radius 6371 km) — for impossible-travel thresholds against
|
|
1058
|
+
* per-session geo metadata captured via `resolveIssueMetadata`. Pure and
|
|
1059
|
+
* dependency-free; aooth ships no geo resolution or default thresholds —
|
|
1060
|
+
* feeding coordinates in (and deciding what distance is "impossible") is
|
|
1061
|
+
* consumer policy.
|
|
1062
|
+
*/
|
|
1063
|
+
const toRad = (deg) => deg * Math.PI / 180;
|
|
1064
|
+
function haversineKm(a, b) {
|
|
1065
|
+
const R = 6371;
|
|
1066
|
+
const dLat = toRad(b.lat - a.lat);
|
|
1067
|
+
const dLon = toRad(b.lon - a.lon);
|
|
1068
|
+
const h = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(a.lat)) * Math.cos(toRad(b.lat)) * Math.sin(dLon / 2) ** 2;
|
|
1069
|
+
return 2 * R * Math.asin(Math.sqrt(h));
|
|
1070
|
+
}
|
|
1009
1071
|
/** Trim + de-duplicate role identifiers submitted via the admin invite form. */
|
|
1010
1072
|
function parseInviteRoles(input) {
|
|
1011
1073
|
if (!Array.isArray(input)) return [];
|
|
@@ -1142,6 +1204,27 @@ let AuthWorkflow = class AuthWorkflow {
|
|
|
1142
1204
|
*/
|
|
1143
1205
|
deliver(_payload) {}
|
|
1144
1206
|
/**
|
|
1207
|
+
* The blessed one-call alert path for risk overrides — emit a
|
|
1208
|
+
* `security-alert` delivery (e.g. from an impossible-travel
|
|
1209
|
+
* `resolveRiskStepUp` override). Recipient comes from `ctx.notice.email`,
|
|
1210
|
+
* the proven-first correspondence chain seeded by `credentials` /
|
|
1211
|
+
* `seedChannelState` and refreshed by `verify/email`. No recipient →
|
|
1212
|
+
* SILENT no-op (a user with no provable inbox simply can't be alerted —
|
|
1213
|
+
* mirrors `notifyNewDevice`'s posture). Never called by the base class.
|
|
1214
|
+
*/
|
|
1215
|
+
async sendSecurityAlert(ctx, reason, context) {
|
|
1216
|
+
const recipient = ctx.notice?.email;
|
|
1217
|
+
if (!recipient) return;
|
|
1218
|
+
await this.deliver({
|
|
1219
|
+
kind: "security-alert",
|
|
1220
|
+
channel: "email",
|
|
1221
|
+
recipient,
|
|
1222
|
+
reason,
|
|
1223
|
+
loginAt: Date.now(),
|
|
1224
|
+
...context && { context }
|
|
1225
|
+
});
|
|
1226
|
+
}
|
|
1227
|
+
/**
|
|
1145
1228
|
* Return the list of selectable role identifiers for the admin invite form.
|
|
1146
1229
|
* Mirrors the prior `InviteWorkflow.getAvailableRoles()` consumer hook —
|
|
1147
1230
|
* `undefined` (default) means no whitelist is enforced. Read by
|
|
@@ -1170,11 +1253,19 @@ let AuthWorkflow = class AuthWorkflow {
|
|
|
1170
1253
|
return [];
|
|
1171
1254
|
}
|
|
1172
1255
|
/**
|
|
1173
|
-
* Override the structural duplicate rule for `admin-form`. Default:
|
|
1174
|
-
*
|
|
1175
|
-
*
|
|
1256
|
+
* Override the structural duplicate rule for `admin-form`. Default: a row
|
|
1257
|
+
* still parked on `account.pendingInvitation` → `'reuse'` (re-invite:
|
|
1258
|
+
* `create-user` refreshes the existing record in place and `send-email`
|
|
1259
|
+
* mints a fresh magic link — see `createUser`); any other existing row →
|
|
1260
|
+
* `'reject'`; nothing → `'allow'`.
|
|
1261
|
+
*
|
|
1262
|
+
* Multi-tenant apps that allow re-inviting the same email into a different
|
|
1263
|
+
* tenant override to `'allow'`. Apps that want the strict legacy behavior
|
|
1264
|
+
* ("Invite already pending" error on a duplicate invite of a pending user)
|
|
1265
|
+
* return `'reject'` for pending rows.
|
|
1176
1266
|
*/
|
|
1177
1267
|
duplicateInviteCheck(input) {
|
|
1268
|
+
if (input.existingUser?.account?.pendingInvitation) return "reuse";
|
|
1178
1269
|
return input.existingUser ? "reject" : "allow";
|
|
1179
1270
|
}
|
|
1180
1271
|
/**
|
|
@@ -1642,6 +1733,19 @@ let AuthWorkflow = class AuthWorkflow {
|
|
|
1642
1733
|
*/
|
|
1643
1734
|
resolvePromoteHandleField(_ctx, _channel) {}
|
|
1644
1735
|
/**
|
|
1736
|
+
* Decide whether a verified federated profile's email claim counts as inbox
|
|
1737
|
+
* proof for the CORRESPONDENCE address (`users.setVerifiedEmail`). Default
|
|
1738
|
+
* trusts the provider's `email_verified` claim — a provider trusted to
|
|
1739
|
+
* AUTHENTICATE the user is strictly more trusted than its email claim. The
|
|
1740
|
+
* capture is correspondence-only: it never promotes the address to a login
|
|
1741
|
+
* handle and never resolves accounts by it. Override to exclude providers
|
|
1742
|
+
* whose claim should not be taken at face value (e.g. an internal OIDC
|
|
1743
|
+
* issuer that stamps `email_verified` on unverified directory entries).
|
|
1744
|
+
*/
|
|
1745
|
+
resolveFederatedEmailTrust(_ctx, profile) {
|
|
1746
|
+
return profile.emailVerified === true;
|
|
1747
|
+
}
|
|
1748
|
+
/**
|
|
1645
1749
|
* Route a form alt-action click to a canonical outcome. Defaults match the
|
|
1646
1750
|
* action ids the bundled `PincodeForm` declares; customers override per
|
|
1647
1751
|
* form when adding new actions or remapping the canonical ones.
|
|
@@ -1751,7 +1855,11 @@ let AuthWorkflow = class AuthWorkflow {
|
|
|
1751
1855
|
}
|
|
1752
1856
|
if (ctx.newPasswordRequired !== void 0) pub.newPasswordRequired = ctx.newPasswordRequired;
|
|
1753
1857
|
if (ctx.authz) {
|
|
1754
|
-
const sub = pickDefined(ctx.authz, [
|
|
1858
|
+
const sub = pickDefined(ctx.authz, [
|
|
1859
|
+
"clientName",
|
|
1860
|
+
"scope",
|
|
1861
|
+
"redirectHost"
|
|
1862
|
+
]);
|
|
1755
1863
|
if (sub) pub.authz = sub;
|
|
1756
1864
|
}
|
|
1757
1865
|
ctx.public = pub;
|
|
@@ -2093,7 +2201,7 @@ let AuthWorkflow = class AuthWorkflow {
|
|
|
2093
2201
|
else if (ctx.isPasswordExpired) (ctx.password ??= {}).changeReason = "expired";
|
|
2094
2202
|
const email = result.user.mfa.methods.find((m) => m.name === "email" && m.confirmed);
|
|
2095
2203
|
if (email) {
|
|
2096
|
-
ctx.email = email.value;
|
|
2204
|
+
(ctx.notice ??= {}).email = email.value;
|
|
2097
2205
|
(ctx.channel ??= {}).emailConfirmed = true;
|
|
2098
2206
|
}
|
|
2099
2207
|
const phone = result.user.mfa.methods.find((m) => m.name === "sms" && m.confirmed);
|
|
@@ -2102,6 +2210,7 @@ let AuthWorkflow = class AuthWorkflow {
|
|
|
2102
2210
|
channel.phone = phone.value;
|
|
2103
2211
|
channel.phoneConfirmed = true;
|
|
2104
2212
|
}
|
|
2213
|
+
if (!ctx.notice?.email) await this.seedCorrespondenceEmail(ctx, result.user);
|
|
2105
2214
|
} catch (err) {
|
|
2106
2215
|
if (err instanceof UserAuthError) {
|
|
2107
2216
|
if (err.type === "LOCKED") throw this.throwPublic(ctx, wf, { formMessage: "Account locked, please try again later" });
|
|
@@ -2430,14 +2539,16 @@ let AuthWorkflow = class AuthWorkflow {
|
|
|
2430
2539
|
if (parsed.find((r) => !allowed.has(r)) !== void 0) throw this.throwPublic(ctx, wf, { errors: { roles: "Invalid role" } });
|
|
2431
2540
|
}
|
|
2432
2541
|
const existing = await this.users.findByHandle(email);
|
|
2433
|
-
|
|
2542
|
+
const action = await this.duplicateInviteCheck({
|
|
2434
2543
|
email,
|
|
2435
2544
|
existingUser: existing
|
|
2436
|
-
})
|
|
2545
|
+
});
|
|
2546
|
+
if (action === "reject") {
|
|
2437
2547
|
if (existing?.account?.pendingInvitation) throw this.throwPublic(ctx, wf, { errors: { email: "Invite already pending" } });
|
|
2438
2548
|
if (existing) throw this.throwPublic(ctx, wf, { errors: { email: "User already exists" } });
|
|
2439
2549
|
throw this.throwPublic(ctx, wf, { errors: { email: "Duplicate invite rejected" } });
|
|
2440
2550
|
}
|
|
2551
|
+
if (action === "reuse") (ctx.admin ??= {}).reuseExisting = true;
|
|
2441
2552
|
ctx.email = email;
|
|
2442
2553
|
if (parsed.length > 0) (ctx.admin ??= {}).roles = parsed;
|
|
2443
2554
|
}
|
|
@@ -2473,6 +2584,19 @@ let AuthWorkflow = class AuthWorkflow {
|
|
|
2473
2584
|
* Create the user row from `ctx.admin.userExtras` (plus the admin-supplied
|
|
2474
2585
|
* `ctx.admin.roles`), then stamp `pendingInvitation = true` via a follow-up
|
|
2475
2586
|
* deep-merge update so `createUser`-applied account defaults survive.
|
|
2587
|
+
*
|
|
2588
|
+
* Re-invite (`ctx.admin.reuseExisting`, stamped by `admin-form` on a
|
|
2589
|
+
* `'reuse'` verdict): REFRESH the existing row instead of creating — apply
|
|
2590
|
+
* the freshly-picked roles + `prepareUser` extras, re-assert
|
|
2591
|
+
* `pendingInvitation`, and leave password/MFA state untouched (a pending
|
|
2592
|
+
* record never had usable credentials). `send-email` downstream then mints
|
|
2593
|
+
* a fresh durable handle, i.e. a new full-TTL magic link. Guarded by a
|
|
2594
|
+
* FRESH `pendingInvitation` read: a `'reuse'` verdict for an accepted
|
|
2595
|
+
* account 409s as a logic error rather than silently re-pending a live
|
|
2596
|
+
* user; a row that vanished since `admin-form` falls through to the normal
|
|
2597
|
+
* create path. The refresh is a deep-merge update: arrays (`roles`)
|
|
2598
|
+
* replace wholesale, but extras keys the current `prepareUser` no longer
|
|
2599
|
+
* returns linger from the original invite.
|
|
2476
2600
|
*/
|
|
2477
2601
|
async createUser(ctx) {
|
|
2478
2602
|
if (!ctx.email) throw new HttpError$1(500, "Workflow state corrupted: missing email");
|
|
@@ -2481,6 +2605,18 @@ let AuthWorkflow = class AuthWorkflow {
|
|
|
2481
2605
|
...ctx.admin?.userExtras,
|
|
2482
2606
|
...adminRoles && adminRoles.length > 0 && { roles: adminRoles }
|
|
2483
2607
|
};
|
|
2608
|
+
if (ctx.admin?.reuseExisting) {
|
|
2609
|
+
const existing = await this.users.findByHandle(ctx.email);
|
|
2610
|
+
if (existing) {
|
|
2611
|
+
if (!existing.account?.pendingInvitation) throw new HttpError$1(409, "User already exists");
|
|
2612
|
+
await this.users.update(existing.id, {
|
|
2613
|
+
...fields,
|
|
2614
|
+
account: { pendingInvitation: true }
|
|
2615
|
+
});
|
|
2616
|
+
ctx.subject = existing.id;
|
|
2617
|
+
return;
|
|
2618
|
+
}
|
|
2619
|
+
}
|
|
2484
2620
|
let created;
|
|
2485
2621
|
try {
|
|
2486
2622
|
created = await this.users.createUser(ctx.email, void 0, fields);
|
|
@@ -2552,7 +2688,7 @@ let AuthWorkflow = class AuthWorkflow {
|
|
|
2552
2688
|
/** Activate the invited user account (flips the account status flag). */
|
|
2553
2689
|
async activateUser(ctx) {
|
|
2554
2690
|
this.requireSubject(ctx);
|
|
2555
|
-
await this.users.activateAccount(ctx.subject);
|
|
2691
|
+
await this.users.activateAccount(ctx.subject, ctx.email ? { verifiedEmail: ctx.email } : {});
|
|
2556
2692
|
}
|
|
2557
2693
|
/**
|
|
2558
2694
|
* Emit the success-confirmation envelope. The downstream `finalize-auto-
|
|
@@ -2909,7 +3045,7 @@ let AuthWorkflow = class AuthWorkflow {
|
|
|
2909
3045
|
value,
|
|
2910
3046
|
confirmed: false
|
|
2911
3047
|
}));
|
|
2912
|
-
if (isEmail) ctx.email = value;
|
|
3048
|
+
if (isEmail) (ctx.channel ??= {}).email = value;
|
|
2913
3049
|
else (ctx.channel ??= {}).phone = value;
|
|
2914
3050
|
const code = this.mintPin(ctx, this.opts.mfa.pincodeLength, this.opts.mfa.pincodeTtlMs);
|
|
2915
3051
|
await this.deliver({
|
|
@@ -2936,7 +3072,7 @@ let AuthWorkflow = class AuthWorkflow {
|
|
|
2936
3072
|
const remainingSec = Math.ceil((ctx.pincode.resendAllowedAt - Date.now()) / 1e3);
|
|
2937
3073
|
throw this.throwPublic(ctx, pincodeWf, { formMessage: `Please wait ${remainingSec}s before requesting a new code.` });
|
|
2938
3074
|
}
|
|
2939
|
-
const recipient = isEmail ? ctx.email : ctx.channel?.phone;
|
|
3075
|
+
const recipient = isEmail ? ctx.channel?.email : ctx.channel?.phone;
|
|
2940
3076
|
const code = this.mintPin(ctx, this.opts.mfa.pincodeLength, this.opts.mfa.pincodeTtlMs);
|
|
2941
3077
|
await this.deliver({
|
|
2942
3078
|
kind: "enroll-pincode",
|
|
@@ -2956,11 +3092,16 @@ let AuthWorkflow = class AuthWorkflow {
|
|
|
2956
3092
|
if (pinErr) throw this.throwPublic(ctx, pincodeWf, { errors: pinErr });
|
|
2957
3093
|
await this.withStoreErrorTranslation(() => this.users.confirmMfaMethod(ctx.subject, isEmail ? "email" : "sms"));
|
|
2958
3094
|
const channelState = ctx.channel ??= {};
|
|
2959
|
-
if (isEmail)
|
|
2960
|
-
|
|
3095
|
+
if (isEmail) {
|
|
3096
|
+
channelState.emailConfirmed = true;
|
|
3097
|
+
if (channelState.email) {
|
|
3098
|
+
await this.users.setVerifiedEmail(ctx.subject, channelState.email);
|
|
3099
|
+
(ctx.notice ??= {}).email = channelState.email;
|
|
3100
|
+
}
|
|
3101
|
+
} else channelState.phoneConfirmed = true;
|
|
2961
3102
|
if (channelState.otpDisclosure) {
|
|
2962
3103
|
const channelArg = isEmail ? "email" : "sms";
|
|
2963
|
-
const target = isEmail ?
|
|
3104
|
+
const target = isEmail ? channelState.email : channelState.phone;
|
|
2964
3105
|
await this.consentStore.recordOtpChannelConsent(ctx.subject, channelArg, target, channelState.otpDisclosure);
|
|
2965
3106
|
}
|
|
2966
3107
|
delete ctx.pin;
|
|
@@ -3149,6 +3290,9 @@ let AuthWorkflow = class AuthWorkflow {
|
|
|
3149
3290
|
code,
|
|
3150
3291
|
expiresInMs: this.opts.mfa.pincodeTtlMs
|
|
3151
3292
|
});
|
|
3293
|
+
const otp = ctx.otp ??= {};
|
|
3294
|
+
otp.deliveredTo = target.address;
|
|
3295
|
+
otp.deliveredChannel = target.channel;
|
|
3152
3296
|
const pincode = ctx.pincode ??= {};
|
|
3153
3297
|
pincode.sentTo = this.maskAddress(target.address, target.channel);
|
|
3154
3298
|
pincode.codeLength = this.opts.mfa.pincodeLength;
|
|
@@ -3199,7 +3343,9 @@ let AuthWorkflow = class AuthWorkflow {
|
|
|
3199
3343
|
}
|
|
3200
3344
|
const pinErr = this.verifyPin(ctx, input.code);
|
|
3201
3345
|
if (pinErr) throw this.throwPublic(ctx, wf, { errors: pinErr });
|
|
3202
|
-
|
|
3346
|
+
const otp = ctx.otp ??= {};
|
|
3347
|
+
otp.verified = true;
|
|
3348
|
+
if (ctx.subject && otp.deliveredChannel === "email" && otp.deliveredTo) await this.users.setVerifiedEmail(ctx.subject, otp.deliveredTo);
|
|
3203
3349
|
(ctx.session ??= {}).riskStepUpEvaluated = false;
|
|
3204
3350
|
if (ctx.deviceTrust?.enabled && ctx.deviceTrust?.optIn) (ctx.trust ??= {}).rememberDevice = Boolean(input.rememberDevice);
|
|
3205
3351
|
delete ctx.pin;
|
|
@@ -3399,6 +3545,7 @@ let AuthWorkflow = class AuthWorkflow {
|
|
|
3399
3545
|
value,
|
|
3400
3546
|
confirmed: true
|
|
3401
3547
|
}));
|
|
3548
|
+
if (methodName === "email") await this.users.setVerifiedEmail(username, value);
|
|
3402
3549
|
if (!m.keepExistingDefault) await this.users.setDefaultMfaMethod(username, methodName);
|
|
3403
3550
|
m.done = true;
|
|
3404
3551
|
(ctx.otp ??= {}).verified = true;
|
|
@@ -3495,6 +3642,47 @@ let AuthWorkflow = class AuthWorkflow {
|
|
|
3495
3642
|
(ctx.trust ??= {}).deviceTrustToken = record.token;
|
|
3496
3643
|
}
|
|
3497
3644
|
/**
|
|
3645
|
+
* Always-on device RECOGNITION — verify-or-mint the long-lived recognition
|
|
3646
|
+
* cookie against the `seenDevices` ledger. Recognition is a notification
|
|
3647
|
+
* suppressor ONLY (the notify-new-device gate reads `trust.recognized`); it
|
|
3648
|
+
* never skips MFA — that is `deviceTrust`, which stays opt-in and strict.
|
|
3649
|
+
*
|
|
3650
|
+
* Deliberately a SEPARATE step from `check-trusted-device`: that step is
|
|
3651
|
+
* schema-gated on `deviceTrust.enabled && skipsMfa`, so recognition must
|
|
3652
|
+
* not piggyback on it or recognition dies whenever trust is disabled —
|
|
3653
|
+
* exactly the consumers who get the noisiest notify behaviour today.
|
|
3654
|
+
* Verify-or-mint lives in ONE step so `trust.recognized` captures the
|
|
3655
|
+
* PRE-MINT arrival state the notify gate needs (a freshly minted token
|
|
3656
|
+
* must not mark the current login as recognized).
|
|
3657
|
+
*
|
|
3658
|
+
* A valid arriving cookie is verified with `slideTtlMs` (LRU bump) and
|
|
3659
|
+
* re-stashed on `trust.seenDeviceToken` so `issue` re-sets it with a fresh
|
|
3660
|
+
* maxAge. An unrecognized arrival mints + persists a new record (capped at
|
|
3661
|
+
* `deviceRecognition.maxDevices`) and stashes the new token — `recognized`
|
|
3662
|
+
* stays unset so the notification still fires for this login. Degrades
|
|
3663
|
+
* gracefully to a no-op when no `deviceTrust.secret` is configured,
|
|
3664
|
+
* preserving the legacy notify behaviour for those consumers.
|
|
3665
|
+
*/
|
|
3666
|
+
async deviceRecognition(ctx) {
|
|
3667
|
+
if (!ctx.subject) return void 0;
|
|
3668
|
+
if (!this.users.hasDeviceTrustSecret()) return void 0;
|
|
3669
|
+
const cookieValue = useCookies(current()).getCookie(this.opts.deviceRecognition.cookieName);
|
|
3670
|
+
const trust = ctx.trust ??= {};
|
|
3671
|
+
if (cookieValue) {
|
|
3672
|
+
if (await this.users.verifySeenDevice(ctx.subject, cookieValue, { slideTtlMs: this.opts.deviceRecognition.ttlMs })) {
|
|
3673
|
+
trust.recognized = true;
|
|
3674
|
+
trust.seenDeviceToken = cookieValue;
|
|
3675
|
+
return;
|
|
3676
|
+
}
|
|
3677
|
+
}
|
|
3678
|
+
const record = this.users.issueSeenDevice(ctx.subject, {
|
|
3679
|
+
ttlMs: this.opts.deviceRecognition.ttlMs,
|
|
3680
|
+
name: humanizeUserAgent(this.resolveUserAgent())
|
|
3681
|
+
});
|
|
3682
|
+
await this.users.addSeenDevice(ctx.subject, record, { cap: this.opts.deviceRecognition.maxDevices });
|
|
3683
|
+
trust.seenDeviceToken = record.token;
|
|
3684
|
+
}
|
|
3685
|
+
/**
|
|
3498
3686
|
* Standalone terms-bump prompt for returning users whose accepted terms
|
|
3499
3687
|
* version is stale and no carrier form ran. Delegates to
|
|
3500
3688
|
* `processInlineConsent` for validation + ctx writes.
|
|
@@ -3570,14 +3758,8 @@ let AuthWorkflow = class AuthWorkflow {
|
|
|
3570
3758
|
*/
|
|
3571
3759
|
async revokeSessions(ctx) {
|
|
3572
3760
|
if (!ctx.subject) return void 0;
|
|
3573
|
-
|
|
3574
|
-
|
|
3575
|
-
if (currentSessionId) {
|
|
3576
|
-
await this.auth.revokeOtherSessions(ctx.subject, currentSessionId);
|
|
3577
|
-
return;
|
|
3578
|
-
}
|
|
3579
|
-
}
|
|
3580
|
-
await this.auth.revokeAllForUser(ctx.subject);
|
|
3761
|
+
const currentSessionId = ctx.changePassword?.revokeOtherSessions ? useAuth().getSessionId() : void 0;
|
|
3762
|
+
await Promise.all([currentSessionId ? this.auth.revokeOtherSessions(ctx.subject, currentSessionId) : this.auth.revokeAllForUser(ctx.subject), this.users.revokeSeenDevices(ctx.subject)]);
|
|
3581
3763
|
}
|
|
3582
3764
|
/**
|
|
3583
3765
|
* Lift a failed-login lockout after a successful password reset. Recovery
|
|
@@ -3603,14 +3785,19 @@ let AuthWorkflow = class AuthWorkflow {
|
|
|
3603
3785
|
finished: true,
|
|
3604
3786
|
data: auth.buildLoginResponse(ctx.subject, issue)
|
|
3605
3787
|
};
|
|
3606
|
-
|
|
3607
|
-
const
|
|
3608
|
-
|
|
3609
|
-
|
|
3610
|
-
|
|
3611
|
-
|
|
3612
|
-
|
|
3613
|
-
|
|
3788
|
+
let cookies = auth.buildFinishedCookies(issue);
|
|
3789
|
+
const attachDeviceCookie = (name, value, ttlMs) => {
|
|
3790
|
+
if (!value) return;
|
|
3791
|
+
cookies = {
|
|
3792
|
+
...cookies,
|
|
3793
|
+
[name]: {
|
|
3794
|
+
value,
|
|
3795
|
+
options: auth.cookieAttrs({ maxAge: ttlMs / 1e3 })
|
|
3796
|
+
}
|
|
3797
|
+
};
|
|
3798
|
+
};
|
|
3799
|
+
attachDeviceCookie(this.opts.deviceTrust.cookieName, ctx.trust?.deviceTrustToken, this.opts.deviceTrust.ttlMs);
|
|
3800
|
+
attachDeviceCookie(this.opts.deviceRecognition.cookieName, ctx.trust?.seenDeviceToken, this.opts.deviceRecognition.ttlMs);
|
|
3614
3801
|
useWfFinished().set({
|
|
3615
3802
|
type: "data",
|
|
3616
3803
|
value: envelope,
|
|
@@ -3620,14 +3807,24 @@ let AuthWorkflow = class AuthWorkflow {
|
|
|
3620
3807
|
/**
|
|
3621
3808
|
* Notify the user of a login from a new device via the unified `deliver`
|
|
3622
3809
|
* hook. Gated upstream by
|
|
3623
|
-
* `!ctx.isFirstLogin && !!ctx.finalize.notifyNewDevice &&
|
|
3810
|
+
* `!ctx.isFirstLogin && !!ctx.finalize.notifyNewDevice && !ctx.trust.recognized`
|
|
3811
|
+
* — "not recognized" (no valid recognition cookie on arrival), NOT "no
|
|
3812
|
+
* valid trust cookie": users who decline remember-me, or whose strict trust
|
|
3813
|
+
* cookie expired / failed IP binding, must not get the email on every
|
|
3814
|
+
* login. Recognition is the loose always-on ledger minted by
|
|
3815
|
+
* `device-recognition`; trust stays strict and drives MFA skip only.
|
|
3816
|
+
*
|
|
3817
|
+
* Recipient is `notice.email` — the security-notice slot owned by the
|
|
3818
|
+
* `credentials` / `seedChannelState` seeding and refreshed by
|
|
3819
|
+
* `verify/email`. No recipient seeded → silently skips.
|
|
3624
3820
|
*/
|
|
3625
3821
|
async notifyNewDevice(ctx) {
|
|
3626
|
-
|
|
3822
|
+
const recipient = ctx.notice?.email;
|
|
3823
|
+
if (!recipient) return void 0;
|
|
3627
3824
|
await this.deliver({
|
|
3628
3825
|
kind: "new-device-notice",
|
|
3629
3826
|
channel: "email",
|
|
3630
|
-
recipient
|
|
3827
|
+
recipient,
|
|
3631
3828
|
loginAt: Date.now()
|
|
3632
3829
|
});
|
|
3633
3830
|
}
|
|
@@ -3696,8 +3893,12 @@ let AuthWorkflow = class AuthWorkflow {
|
|
|
3696
3893
|
} });
|
|
3697
3894
|
return;
|
|
3698
3895
|
}
|
|
3699
|
-
|
|
3896
|
+
const clientName = req.clientName ?? req.clientId;
|
|
3897
|
+
if (clientName !== void 0) authz.clientName = clientName;
|
|
3700
3898
|
if (req.scope !== void 0) authz.scope = req.scope;
|
|
3899
|
+
try {
|
|
3900
|
+
authz.redirectHost = new URL(req.redirectUri).host;
|
|
3901
|
+
} catch {}
|
|
3701
3902
|
const wf = this.useAtscriptWfPublic(ctx, this.opts.forms.authzConsent);
|
|
3702
3903
|
if (wf.resolveAction() === "deny") {
|
|
3703
3904
|
await pending.delete(authz.handle);
|
|
@@ -3762,6 +3963,7 @@ let AuthWorkflow = class AuthWorkflow {
|
|
|
3762
3963
|
redirectUri: req.redirectUri,
|
|
3763
3964
|
...req.clientId !== void 0 && { clientId: req.clientId },
|
|
3764
3965
|
...req.scope !== void 0 && { scope: req.scope },
|
|
3966
|
+
...req.resource !== void 0 && { resource: req.resource },
|
|
3765
3967
|
...req.nonce !== void 0 && { nonce: req.nonce },
|
|
3766
3968
|
...req.idToken !== void 0 && { idToken: req.idToken },
|
|
3767
3969
|
...req.accessToken !== void 0 && { accessToken: req.accessToken },
|
|
@@ -4094,7 +4296,7 @@ let AuthWorkflow = class AuthWorkflow {
|
|
|
4094
4296
|
});
|
|
4095
4297
|
if (Object.keys(extras).length > 0) await this.users.update(outcome.userId, extras);
|
|
4096
4298
|
}
|
|
4097
|
-
this.seedChannelState(ctx, user, profile
|
|
4299
|
+
await this.seedChannelState(ctx, user, profile);
|
|
4098
4300
|
swapStrategy("store");
|
|
4099
4301
|
}
|
|
4100
4302
|
/**
|
|
@@ -4121,19 +4323,31 @@ let AuthWorkflow = class AuthWorkflow {
|
|
|
4121
4323
|
} });
|
|
4122
4324
|
}
|
|
4123
4325
|
/**
|
|
4124
|
-
* Seed `ctx.email` / `ctx.channel` from a resolved user's confirmed channels —
|
|
4326
|
+
* Seed `ctx.notice.email` / `ctx.channel` from a resolved user's confirmed channels —
|
|
4125
4327
|
* shared by `ssoCallback` (linked / created / auto-linked) and `proveControl`
|
|
4126
4328
|
* (interactively-linked) so the post-success channel shape can't drift between
|
|
4127
|
-
* the two federated entry points. Mirrors `credentials`' post-login seeding
|
|
4128
|
-
*
|
|
4129
|
-
*
|
|
4130
|
-
|
|
4131
|
-
|
|
4329
|
+
* the two federated entry points. Mirrors `credentials`' post-login seeding,
|
|
4330
|
+
* including the correspondence fallback (confirmed email-MFA →
|
|
4331
|
+
* `users.getCorrespondenceEmail` → provider display email).
|
|
4332
|
+
*
|
|
4333
|
+
* Also the single federated capture point for `users.setVerifiedEmail`: a
|
|
4334
|
+
* trusted `profile.email` (per `resolveFederatedEmailTrust`) is recorded as
|
|
4335
|
+
* the proven correspondence address on EVERY federated login — first-time
|
|
4336
|
+
* create, returning link, and interactive link alike (the store write is
|
|
4337
|
+
* skipped when the capture is already current). The profile email is
|
|
4338
|
+
* otherwise a DISPLAY fallback only — never promoted to the unique login
|
|
4339
|
+
* handle (a gated, later-phase concern).
|
|
4340
|
+
*/
|
|
4341
|
+
async seedChannelState(ctx, user, profile) {
|
|
4342
|
+
if (profile?.email && user.account.verifiedEmail !== profile.email && await this.resolveFederatedEmailTrust(ctx, profile)) {
|
|
4343
|
+
await this.users.setVerifiedEmail(user.id, profile.email);
|
|
4344
|
+
user.account.verifiedEmail = profile.email;
|
|
4345
|
+
}
|
|
4132
4346
|
const email = user.mfa.methods.find((m) => m.name === "email" && m.confirmed);
|
|
4133
4347
|
if (email) {
|
|
4134
|
-
ctx.email = email.value;
|
|
4348
|
+
(ctx.notice ??= {}).email = email.value;
|
|
4135
4349
|
(ctx.channel ??= {}).emailConfirmed = true;
|
|
4136
|
-
} else
|
|
4350
|
+
} else await this.seedCorrespondenceEmail(ctx, user, profile?.email);
|
|
4137
4351
|
const phone = user.mfa.methods.find((m) => m.name === "sms" && m.confirmed);
|
|
4138
4352
|
if (phone) {
|
|
4139
4353
|
const channel = ctx.channel ??= {};
|
|
@@ -4142,6 +4356,19 @@ let AuthWorkflow = class AuthWorkflow {
|
|
|
4142
4356
|
}
|
|
4143
4357
|
}
|
|
4144
4358
|
/**
|
|
4359
|
+
* Correspondence tail of the `ctx.notice.email` seeding — shared by
|
|
4360
|
+
* `credentials` (post-login) and `seedChannelState` (federated) so the
|
|
4361
|
+
* fallback chain (`users.getCorrespondenceEmail` → optional display email)
|
|
4362
|
+
* can't drift between the two. Does NOT set `channel.emailConfirmed` — that
|
|
4363
|
+
* flag means "confirmed email-MFA channel" and gates enrolment; a
|
|
4364
|
+
* correspondence address is a notice recipient, not a proven OTP channel.
|
|
4365
|
+
*/
|
|
4366
|
+
async seedCorrespondenceEmail(ctx, user, displayEmail) {
|
|
4367
|
+
const correspondence = await this.users.getCorrespondenceEmail(user);
|
|
4368
|
+
if (correspondence) (ctx.notice ??= {}).email = correspondence;
|
|
4369
|
+
else if (displayEmail) (ctx.notice ??= {}).email = displayEmail;
|
|
4370
|
+
}
|
|
4371
|
+
/**
|
|
4145
4372
|
* `needs-link` setup (decision A — password, OTP fallback). Decide how the
|
|
4146
4373
|
* user will prove control of the matched account, stash the pending-link
|
|
4147
4374
|
* state, and return so the `prove-control` @Step (gated on `ctx.pendingLink`)
|
|
@@ -4296,7 +4523,7 @@ let AuthWorkflow = class AuthWorkflow {
|
|
|
4296
4523
|
isNew: false,
|
|
4297
4524
|
...pending.redirect ? { redirect: pending.redirect } : {}
|
|
4298
4525
|
};
|
|
4299
|
-
this.seedChannelState(ctx, candidate, pending.snapshot
|
|
4526
|
+
await this.seedChannelState(ctx, candidate, pending.snapshot);
|
|
4300
4527
|
delete ctx.pendingLink;
|
|
4301
4528
|
swapStrategy("store");
|
|
4302
4529
|
}
|
|
@@ -4892,6 +5119,14 @@ __decorate([
|
|
|
4892
5119
|
__decorateMetadata("design:paramtypes", [Object]),
|
|
4893
5120
|
__decorateMetadata("design:returntype", Promise)
|
|
4894
5121
|
], AuthWorkflow.prototype, "deviceTrust", null);
|
|
5122
|
+
__decorate([
|
|
5123
|
+
Step("device-recognition"),
|
|
5124
|
+
Public(),
|
|
5125
|
+
__decorateParam(0, WorkflowParam("context")),
|
|
5126
|
+
__decorateMetadata("design:type", Function),
|
|
5127
|
+
__decorateMetadata("design:paramtypes", [Object]),
|
|
5128
|
+
__decorateMetadata("design:returntype", Promise)
|
|
5129
|
+
], AuthWorkflow.prototype, "deviceRecognition", null);
|
|
4895
5130
|
__decorate([
|
|
4896
5131
|
Step("terms-bump-prompt"),
|
|
4897
5132
|
Public(),
|
|
@@ -5124,11 +5359,11 @@ __decorate([
|
|
|
5124
5359
|
},
|
|
5125
5360
|
{
|
|
5126
5361
|
id: "ask/email",
|
|
5127
|
-
condition: (ctx) => (!!ctx.enrollment?.ensureEmail || !!ctx.guards?.emailVerifiedRequired) && !ctx.email
|
|
5362
|
+
condition: (ctx) => (!!ctx.enrollment?.ensureEmail || !!ctx.guards?.emailVerifiedRequired) && !ctx.channel?.email && !ctx.channel?.emailConfirmed
|
|
5128
5363
|
},
|
|
5129
5364
|
{
|
|
5130
5365
|
id: "verify/email",
|
|
5131
|
-
condition: (ctx) => (!!ctx.enrollment?.ensureEmail || !!ctx.guards?.emailVerifiedRequired) && !!ctx.email && !ctx.channel?.emailConfirmed
|
|
5366
|
+
condition: (ctx) => (!!ctx.enrollment?.ensureEmail || !!ctx.guards?.emailVerifiedRequired) && !!ctx.channel?.email && !ctx.channel?.emailConfirmed
|
|
5132
5367
|
},
|
|
5133
5368
|
{
|
|
5134
5369
|
id: "ask/phone",
|
|
@@ -5163,10 +5398,11 @@ __decorate([
|
|
|
5163
5398
|
{
|
|
5164
5399
|
condition: (ctx) => !ctx.authz,
|
|
5165
5400
|
steps: [
|
|
5401
|
+
{ id: "device-recognition" },
|
|
5166
5402
|
{ id: "issue" },
|
|
5167
5403
|
{
|
|
5168
5404
|
id: "notify-new-device",
|
|
5169
|
-
condition: (ctx) => !ctx.isFirstLogin && !!ctx.finalize?.notifyNewDevice &&
|
|
5405
|
+
condition: (ctx) => !ctx.isFirstLogin && !!ctx.finalize?.notifyNewDevice && !ctx.trust?.recognized
|
|
5170
5406
|
},
|
|
5171
5407
|
{ id: "redirect" }
|
|
5172
5408
|
]
|
|
@@ -5969,7 +6205,36 @@ let AuthorizeController = class AuthorizeController {
|
|
|
5969
6205
|
loginPath() {
|
|
5970
6206
|
return "/login";
|
|
5971
6207
|
}
|
|
5972
|
-
|
|
6208
|
+
/**
|
|
6209
|
+
* The issuer identifier for the RFC 8414 metadata document. Defaults to the
|
|
6210
|
+
* Tier-2 signer's issuer; a SIGNER-LESS deployment that serves MCP connector
|
|
6211
|
+
* clients must **override** this (return `{origin}/auth`-style, byte-exact —
|
|
6212
|
+
* never derived from the Host header, which would let a request inject its
|
|
6213
|
+
* host into a cacheable discovery document). `undefined` ⇒ the
|
|
6214
|
+
* `oauth-authorization-server` endpoint 404s.
|
|
6215
|
+
*/
|
|
6216
|
+
getIssuer() {
|
|
6217
|
+
return this.getIdTokenSigner()?.issuer;
|
|
6218
|
+
}
|
|
6219
|
+
/**
|
|
6220
|
+
* The RFC 7591 dynamic-client-registration operation, or `undefined` (the
|
|
6221
|
+
* default) to disable DCR — then `POST /auth/register` 404s and neither
|
|
6222
|
+
* discovery document advertises a `registration_endpoint`. **Override** in a
|
|
6223
|
+
* subclass: inject a `DynamicClientStore` (the `DYNAMIC_CLIENT_STORE_TOKEN`
|
|
6224
|
+
* provider) as a required ctor param, build one `DynamicClientRegistration`
|
|
6225
|
+
* around it, and return it here. Same plain-getter pattern as
|
|
6226
|
+
* {@link getIdTokenSigner} (an optional `@Inject` panics in moost's
|
|
6227
|
+
* route-table pass).
|
|
6228
|
+
*/
|
|
6229
|
+
getDynamicClientRegistration() {}
|
|
6230
|
+
/**
|
|
6231
|
+
* `scopes_supported` for the RFC 8414 document (optional per the RFC; omitted
|
|
6232
|
+
* by default — deliberately NOT inheriting the OIDC document's hardcoded
|
|
6233
|
+
* list, which describes Tier-2 sign-in scopes). Override to advertise what
|
|
6234
|
+
* connector clients may request.
|
|
6235
|
+
*/
|
|
6236
|
+
scopesSupported() {}
|
|
6237
|
+
async authorize(responseType, redirectUri, clientId, state, codeChallenge, codeChallengeMethod, scope, nonce, resource) {
|
|
5973
6238
|
const res = useResponse(current());
|
|
5974
6239
|
if (!redirectUri) {
|
|
5975
6240
|
res.status = 400;
|
|
@@ -5982,17 +6247,22 @@ let AuthorizeController = class AuthorizeController {
|
|
|
5982
6247
|
redirectUri,
|
|
5983
6248
|
...scope !== void 0 && { scope }
|
|
5984
6249
|
});
|
|
5985
|
-
} catch
|
|
6250
|
+
} catch {
|
|
5986
6251
|
res.status = 400;
|
|
5987
|
-
return
|
|
6252
|
+
return "invalid request";
|
|
5988
6253
|
}
|
|
5989
6254
|
if (responseType !== "code" || !codeChallenge || codeChallengeMethod !== "S256") return this.redirectError(resolved.redirectUri, "invalid_request", state);
|
|
6255
|
+
if (resource !== void 0) {
|
|
6256
|
+
if (useUrlParams(current()).params().getAll("resource").length > 1 || resource.length > 2e3) return this.redirectError(resolved.redirectUri, "invalid_target", state);
|
|
6257
|
+
}
|
|
5990
6258
|
const binding = randomBytes(32).toString("base64url");
|
|
5991
6259
|
const { handle, expiresAt } = await this.pending.create({
|
|
5992
6260
|
...resolved.clientId !== void 0 && { clientId: resolved.clientId },
|
|
6261
|
+
...resolved.clientName !== void 0 && { clientName: resolved.clientName },
|
|
5993
6262
|
redirectUri: resolved.redirectUri,
|
|
5994
6263
|
codeChallenge,
|
|
5995
6264
|
...state !== void 0 && { clientState: state },
|
|
6265
|
+
...resource !== void 0 && { resource },
|
|
5996
6266
|
...resolved.scope !== void 0 && { scope: resolved.scope },
|
|
5997
6267
|
...nonce !== void 0 && { nonce },
|
|
5998
6268
|
...resolved.idToken !== void 0 && { idToken: resolved.idToken },
|
|
@@ -6049,6 +6319,14 @@ let AuthorizeController = class AuthorizeController {
|
|
|
6049
6319
|
res.status = 401;
|
|
6050
6320
|
return { error: "invalid_client" };
|
|
6051
6321
|
}
|
|
6322
|
+
if (Array.isArray(body.resource)) {
|
|
6323
|
+
res.status = 400;
|
|
6324
|
+
return { error: "invalid_target" };
|
|
6325
|
+
}
|
|
6326
|
+
if (row.resource !== void 0 && body.resource !== void 0 && row.resource !== body.resource) {
|
|
6327
|
+
res.status = 400;
|
|
6328
|
+
return { error: "invalid_target" };
|
|
6329
|
+
}
|
|
6052
6330
|
const wantIdToken = row.idToken === true;
|
|
6053
6331
|
const wantAccessToken = row.accessToken !== false;
|
|
6054
6332
|
if (!wantIdToken && !wantAccessToken) {
|
|
@@ -6100,7 +6378,10 @@ let AuthorizeController = class AuthorizeController {
|
|
|
6100
6378
|
* OIDC discovery (Tier 2). Derives every endpoint from the signer's `issuer`
|
|
6101
6379
|
* (configured as `{origin}/auth`), so a relying `OidcProvider` configured with
|
|
6102
6380
|
* the same `issuer` resolves `/authorize`, `/token`, and `/jwks` automatically.
|
|
6103
|
-
* 404 when no signer is wired (Tier-1-only deployment).
|
|
6381
|
+
* 404 when no signer is wired (Tier-1-only deployment). When DCR is also
|
|
6382
|
+
* wired, `registration_endpoint` is advertised here too — in a combined
|
|
6383
|
+
* deployment a client that prefers `openid-configuration` over the RFC 8414
|
|
6384
|
+
* document must see the same capability set.
|
|
6104
6385
|
*/
|
|
6105
6386
|
discovery() {
|
|
6106
6387
|
const res = useResponse(current());
|
|
@@ -6115,6 +6396,7 @@ let AuthorizeController = class AuthorizeController {
|
|
|
6115
6396
|
authorization_endpoint: `${iss}/authorize`,
|
|
6116
6397
|
token_endpoint: `${iss}/token`,
|
|
6117
6398
|
jwks_uri: `${iss}/jwks`,
|
|
6399
|
+
...this.getDynamicClientRegistration() && { registration_endpoint: `${iss}/register` },
|
|
6118
6400
|
response_types_supported: ["code"],
|
|
6119
6401
|
grant_types_supported: ["authorization_code"],
|
|
6120
6402
|
subject_types_supported: ["public"],
|
|
@@ -6128,6 +6410,81 @@ let AuthorizeController = class AuthorizeController {
|
|
|
6128
6410
|
token_endpoint_auth_methods_supported: ["none", "client_secret_post"]
|
|
6129
6411
|
};
|
|
6130
6412
|
}
|
|
6413
|
+
/**
|
|
6414
|
+
* RFC 8414 Authorization Server Metadata — the OAuth-flavored discovery MCP
|
|
6415
|
+
* connector clients fetch (OAUTH.md R1). Served signer-INDEPENDENTLY: it
|
|
6416
|
+
* needs only an issuer ({@link getIssuer} — overridable for a Tier-1-style
|
|
6417
|
+
* deployment with no `IdTokenSigner`). Mounted under the controller this is
|
|
6418
|
+
* the suffix form `{issuer}/.well-known/oauth-authorization-server`; the
|
|
6419
|
+
* RFC-correct path-insertion form at the HTTP-server ROOT
|
|
6420
|
+
* (`/.well-known/oauth-authorization-server/<issuer-path>`) cannot be
|
|
6421
|
+
* registered by a prefix-mounted controller — consumers mount it themselves
|
|
6422
|
+
* from the exported `buildAuthorizationServerMetadata` (re-exported by this
|
|
6423
|
+
* package).
|
|
6424
|
+
*/
|
|
6425
|
+
oauthServerMetadata() {
|
|
6426
|
+
const res = useResponse(current());
|
|
6427
|
+
const rawIssuer = this.getIssuer();
|
|
6428
|
+
if (!rawIssuer) {
|
|
6429
|
+
res.status = 404;
|
|
6430
|
+
return { error: "not_found" };
|
|
6431
|
+
}
|
|
6432
|
+
const issuer = canonicalizeIssuer$1(rawIssuer);
|
|
6433
|
+
return buildAuthorizationServerMetadata$1({
|
|
6434
|
+
issuer,
|
|
6435
|
+
...this.getDynamicClientRegistration() && { registrationEndpoint: `${issuer}/register` },
|
|
6436
|
+
...this.getIdTokenSigner() && { jwksUri: `${issuer}/jwks` },
|
|
6437
|
+
...this.scopesSupported() && { scopesSupported: this.scopesSupported() }
|
|
6438
|
+
});
|
|
6439
|
+
}
|
|
6440
|
+
/**
|
|
6441
|
+
* RFC 7591 Dynamic Client Registration (OAUTH.md R2) — anonymous by spec;
|
|
6442
|
+
* 404 unless a registration operation is wired ({@link
|
|
6443
|
+
* getDynamicClientRegistration}). A thin HTTP adapter: validation, abuse
|
|
6444
|
+
* knobs (guard / cap / never-used GC) and persistence live in
|
|
6445
|
+
* `DynamicClientRegistration` (`@aooth/auth`). Public clients only — the
|
|
6446
|
+
* response carries NO `client_secret` and NO `client_secret_expires_at`
|
|
6447
|
+
* (RFC 7591 §3.2.1 requires them only when a secret is issued).
|
|
6448
|
+
*/
|
|
6449
|
+
async register(body) {
|
|
6450
|
+
const res = useResponse(current());
|
|
6451
|
+
const registration = this.getDynamicClientRegistration();
|
|
6452
|
+
if (!registration) {
|
|
6453
|
+
res.status = 404;
|
|
6454
|
+
return { error: "not_found" };
|
|
6455
|
+
}
|
|
6456
|
+
if (!(useHeaders(current())["content-type"] ?? "").includes("application/json")) {
|
|
6457
|
+
res.status = 400;
|
|
6458
|
+
return {
|
|
6459
|
+
error: "invalid_client_metadata",
|
|
6460
|
+
error_description: "registration requests must be application/json"
|
|
6461
|
+
};
|
|
6462
|
+
}
|
|
6463
|
+
try {
|
|
6464
|
+
const client = await registration.register(body);
|
|
6465
|
+
res.status = 201;
|
|
6466
|
+
return {
|
|
6467
|
+
client_id: client.clientId,
|
|
6468
|
+
client_id_issued_at: Math.floor(client.createdAt / 1e3),
|
|
6469
|
+
redirect_uris: client.redirectUris,
|
|
6470
|
+
token_endpoint_auth_method: client.tokenEndpointAuthMethod,
|
|
6471
|
+
grant_types: client.grantTypes,
|
|
6472
|
+
response_types: client.responseTypes,
|
|
6473
|
+
...client.clientName !== void 0 && { client_name: client.clientName },
|
|
6474
|
+
...client.scope !== void 0 && { scope: client.scope }
|
|
6475
|
+
};
|
|
6476
|
+
} catch (e) {
|
|
6477
|
+
if (e instanceof ClientRegistrationError) {
|
|
6478
|
+
res.status = 400;
|
|
6479
|
+
return {
|
|
6480
|
+
error: e.code,
|
|
6481
|
+
error_description: e.message
|
|
6482
|
+
};
|
|
6483
|
+
}
|
|
6484
|
+
res.status = 500;
|
|
6485
|
+
return { error: "server_error" };
|
|
6486
|
+
}
|
|
6487
|
+
}
|
|
6131
6488
|
/** The signer's public JWKS (Tier 2). 404 when no signer is wired. */
|
|
6132
6489
|
jwks() {
|
|
6133
6490
|
const res = useResponse(current());
|
|
@@ -6160,6 +6517,7 @@ __decorate([
|
|
|
6160
6517
|
__decorateParam(5, Query("code_challenge_method")),
|
|
6161
6518
|
__decorateParam(6, Query("scope")),
|
|
6162
6519
|
__decorateParam(7, Query("nonce")),
|
|
6520
|
+
__decorateParam(8, Query("resource")),
|
|
6163
6521
|
__decorateMetadata("design:type", Function),
|
|
6164
6522
|
__decorateMetadata("design:paramtypes", [
|
|
6165
6523
|
Object,
|
|
@@ -6169,6 +6527,7 @@ __decorate([
|
|
|
6169
6527
|
Object,
|
|
6170
6528
|
Object,
|
|
6171
6529
|
Object,
|
|
6530
|
+
Object,
|
|
6172
6531
|
Object
|
|
6173
6532
|
]),
|
|
6174
6533
|
__decorateMetadata("design:returntype", Promise)
|
|
@@ -6188,6 +6547,21 @@ __decorate([
|
|
|
6188
6547
|
__decorateMetadata("design:paramtypes", []),
|
|
6189
6548
|
__decorateMetadata("design:returntype", Object)
|
|
6190
6549
|
], AuthorizeController.prototype, "discovery", null);
|
|
6550
|
+
__decorate([
|
|
6551
|
+
Get(".well-known/oauth-authorization-server"),
|
|
6552
|
+
Public(),
|
|
6553
|
+
__decorateMetadata("design:type", Function),
|
|
6554
|
+
__decorateMetadata("design:paramtypes", []),
|
|
6555
|
+
__decorateMetadata("design:returntype", Object)
|
|
6556
|
+
], AuthorizeController.prototype, "oauthServerMetadata", null);
|
|
6557
|
+
__decorate([
|
|
6558
|
+
Post("register"),
|
|
6559
|
+
Public(),
|
|
6560
|
+
__decorateParam(0, Body()),
|
|
6561
|
+
__decorateMetadata("design:type", Function),
|
|
6562
|
+
__decorateMetadata("design:paramtypes", [Object]),
|
|
6563
|
+
__decorateMetadata("design:returntype", Promise)
|
|
6564
|
+
], AuthorizeController.prototype, "register", null);
|
|
6191
6565
|
__decorate([
|
|
6192
6566
|
Get("jwks"),
|
|
6193
6567
|
Public(),
|
|
@@ -6244,4 +6618,4 @@ function createAuthEmailOutlet(deps) {
|
|
|
6244
6618
|
});
|
|
6245
6619
|
}
|
|
6246
6620
|
//#endregion
|
|
6247
|
-
export { ADD_MFA_WORKFLOW, AUTHZ_BINDING_COOKIE, AUTH_CODE_STORE_TOKEN, AuthController, AuthGuarded, AuthWorkflow, AuthorizeController, AuthorizeRuntime, CHANGE_PASSWORD_WORKFLOW, CLIENT_REDIRECT_POLICY_TOKEN, ConsentStore, DEFAULT_AUTH_WORKFLOWS, FEDERATED_IDENTITY_STORE_TOKEN, OAUTH_CSRF_COOKIE, OAuthController, OAuthRuntime, PENDING_AUTHORIZATION_STORE_TOKEN, Public, RESERVED_USER_KEYS, SessionEnricherProvider, SessionsController, UserId, WfTrigger, WfTriggerProvider, authGuardInterceptor, authzBindingCookieAttrs, buildInviteAlreadyAcceptedEnvelope, createAuthEmailOutlet, deriveWfStateSecret, generateMagicLinkToken, getAuthMate, isSafeRelativeRedirect, oauthCsrfCookieAttrs, parseInviteRoles, resolveOAuthRedirect, stripReservedUserKeys, useAuth };
|
|
6621
|
+
export { ADD_MFA_WORKFLOW, AUTHZ_BINDING_COOKIE, AUTH_CODE_STORE_TOKEN, AuthController, AuthGuarded, AuthWorkflow, AuthorizeController, AuthorizeRuntime, CHANGE_PASSWORD_WORKFLOW, CLIENT_REDIRECT_POLICY_TOKEN, ConsentStore, DEFAULT_AUTH_WORKFLOWS, DYNAMIC_CLIENT_STORE_TOKEN, DynamicClientRegistration, FEDERATED_IDENTITY_STORE_TOKEN, OAUTH_CSRF_COOKIE, OAuthController, OAuthRuntime, PENDING_AUTHORIZATION_STORE_TOKEN, Public, RESERVED_USER_KEYS, SessionEnricherProvider, SessionsController, UserId, WfTrigger, WfTriggerProvider, authGuardInterceptor, authzBindingCookieAttrs, buildAuthorizationServerMetadata, buildInviteAlreadyAcceptedEnvelope, buildProtectedResourceMetadata, buildWwwAuthenticateBearerChallenge, canonicalizeIssuer, createAuthEmailOutlet, deriveWfStateSecret, generateMagicLinkToken, getAuthMate, haversineKm, humanizeUserAgent, isSafeRelativeRedirect, oauthCsrfCookieAttrs, parseInviteRoles, resolveOAuthRedirect, stripReservedUserKeys, useAuth };
|