@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/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-D7ZfanKT.mjs";
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 { AuthorizeError, NoopOidcClaimsResolver } from "@aooth/auth/authz";
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
- cookieName: "aooth_trusted_device",
970
- ttlMs: 1440 * 6e4,
971
- bindsTo: "cookie",
972
- ...opts.deviceTrust
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: any
1174
- * existing row `'reject'`; nothing → `'allow'`. Multi-tenant apps that
1175
- * allow re-inviting the same email into a different tenant override.
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, ["clientName", "scope"]);
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
- if (await this.duplicateInviteCheck({
2542
+ const action = await this.duplicateInviteCheck({
2434
2543
  email,
2435
2544
  existingUser: existing
2436
- }) === "reject") {
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) channelState.emailConfirmed = true;
2960
- else channelState.phoneConfirmed = true;
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 ? ctx.email : channelState.phone;
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
- (ctx.otp ??= {}).verified = true;
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
- if (ctx.changePassword?.revokeOtherSessions) {
3574
- const currentSessionId = useAuth().getSessionId();
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
- const sessionCookies = auth.buildFinishedCookies(issue);
3607
- const cookies = ctx.trust?.deviceTrustToken ? {
3608
- ...sessionCookies,
3609
- [this.opts.deviceTrust.cookieName]: {
3610
- value: ctx.trust.deviceTrustToken,
3611
- options: auth.cookieAttrs({ maxAge: this.opts.deviceTrust.ttlMs / 1e3 })
3612
- }
3613
- } : sessionCookies;
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 && !!ctx.trust.newDevice`.
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
- if (!ctx.email) return void 0;
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: ctx.email,
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
- if (req.clientId !== void 0) authz.clientName = req.clientId;
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.email);
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
- * `fallbackEmail` (the provider / snapshot email) is a DISPLAY fallback only —
4129
- * never promoted to the unique login handle (a gated, later-phase concern).
4130
- */
4131
- seedChannelState(ctx, user, fallbackEmail) {
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 if (fallbackEmail) ctx.email = fallbackEmail;
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?.email);
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 && !!ctx.trust?.newDevice
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
- async authorize(responseType, redirectUri, clientId, state, codeChallenge, codeChallengeMethod, scope, nonce) {
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 (e) {
6250
+ } catch {
5986
6251
  res.status = 400;
5987
- return e instanceof AuthorizeError ? `invalid request: ${e.code}` : "invalid request";
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 };