@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.d.mts CHANGED
@@ -6,7 +6,7 @@ import { FinishWfOpts, WfFinished, useAtscriptWf } from "@atscript/moost-wf";
6
6
  import { MoostWf, WfOutlet, WfOutletTokenConfig, WfStateStrategy } from "@moostjs/event-wf";
7
7
  import { FederatedLoginService, NormalizedProfile, OAuthProviderRegistry } from "@aooth/idp";
8
8
  import { TAtscriptAnnotatedType } from "@atscript/typescript/utils";
9
- import { AuthCodeStore, ClientRedirectPolicy, IdTokenSigner, OidcClaimsResolver, PendingAuthorizationStore } from "@aooth/auth/authz";
9
+ import { AuthCodeStore, AuthorizationServerMetadata, AuthorizationServerMetadata as AuthorizationServerMetadata$1, BuildAuthorizationServerMetadataOptions, BuildProtectedResourceMetadataOptions, ClientRedirectPolicy, DynamicClientRegistration, DynamicClientRegistration as DynamicClientRegistration$1, DynamicClientRegistrationOptions, IdTokenSigner, OidcClaimsResolver, PendingAuthorizationStore, ProtectedResourceMetadata, WwwAuthenticateBearerChallengeOptions, buildAuthorizationServerMetadata, buildProtectedResourceMetadata, buildWwwAuthenticateBearerChallenge, canonicalizeIssuer } from "@aooth/auth/authz";
10
10
 
11
11
  //#region src/auth.config.d.ts
12
12
  /** Resolved cookie attributes. Same shape is used for both access + refresh. */
@@ -852,6 +852,7 @@ interface OidcDiscoveryDocument {
852
852
  authorization_endpoint: string;
853
853
  token_endpoint: string;
854
854
  jwks_uri: string;
855
+ registration_endpoint?: string;
855
856
  response_types_supported: string[];
856
857
  grant_types_supported: string[];
857
858
  subject_types_supported: string[];
@@ -860,6 +861,23 @@ interface OidcDiscoveryDocument {
860
861
  code_challenge_methods_supported: string[];
861
862
  token_endpoint_auth_methods_supported: string[];
862
863
  }
864
+ /** RFC 7591 §3.2.1 registration response — public client, so no secret fields. */
865
+ interface ClientRegistrationSuccess {
866
+ client_id: string;
867
+ /** Seconds since epoch (the RFC's unit — NOT ms). */
868
+ client_id_issued_at: number;
869
+ redirect_uris: string[];
870
+ token_endpoint_auth_method: string;
871
+ grant_types: string[];
872
+ response_types: string[];
873
+ client_name?: string;
874
+ scope?: string;
875
+ }
876
+ /** RFC 7591 §3.2.2 registration error response. */
877
+ interface ClientRegistrationFailure {
878
+ error: string;
879
+ error_description?: string;
880
+ }
863
881
  /**
864
882
  * The authorization-server endpoints (AUTH-SERVER.md). Turns the existing login
865
883
  * workflow into an OAuth/OIDC authorization server for the app's OWN clients — a
@@ -909,21 +927,75 @@ declare class AuthorizeController {
909
927
  * custom login path.
910
928
  */
911
929
  protected loginPath(): string;
912
- authorize(responseType: string | undefined, redirectUri: string | undefined, clientId: string | undefined, state: string | undefined, codeChallenge: string | undefined, codeChallengeMethod: string | undefined, scope: string | undefined, nonce: string | undefined): Promise<string>;
930
+ /**
931
+ * The issuer identifier for the RFC 8414 metadata document. Defaults to the
932
+ * Tier-2 signer's issuer; a SIGNER-LESS deployment that serves MCP connector
933
+ * clients must **override** this (return `{origin}/auth`-style, byte-exact —
934
+ * never derived from the Host header, which would let a request inject its
935
+ * host into a cacheable discovery document). `undefined` ⇒ the
936
+ * `oauth-authorization-server` endpoint 404s.
937
+ */
938
+ protected getIssuer(): string | undefined;
939
+ /**
940
+ * The RFC 7591 dynamic-client-registration operation, or `undefined` (the
941
+ * default) to disable DCR — then `POST /auth/register` 404s and neither
942
+ * discovery document advertises a `registration_endpoint`. **Override** in a
943
+ * subclass: inject a `DynamicClientStore` (the `DYNAMIC_CLIENT_STORE_TOKEN`
944
+ * provider) as a required ctor param, build one `DynamicClientRegistration`
945
+ * around it, and return it here. Same plain-getter pattern as
946
+ * {@link getIdTokenSigner} (an optional `@Inject` panics in moost's
947
+ * route-table pass).
948
+ */
949
+ protected getDynamicClientRegistration(): DynamicClientRegistration$1 | undefined;
950
+ /**
951
+ * `scopes_supported` for the RFC 8414 document (optional per the RFC; omitted
952
+ * by default — deliberately NOT inheriting the OIDC document's hardcoded
953
+ * list, which describes Tier-2 sign-in scopes). Override to advertise what
954
+ * connector clients may request.
955
+ */
956
+ protected scopesSupported(): string[] | undefined;
957
+ authorize(responseType: string | undefined, redirectUri: string | undefined, clientId: string | undefined, state: string | undefined, codeChallenge: string | undefined, codeChallengeMethod: string | undefined, scope: string | undefined, nonce: string | undefined, resource: string | undefined): Promise<string>;
913
958
  token(body: {
914
959
  grant_type?: string;
915
960
  code?: string;
916
961
  code_verifier?: string;
917
962
  client_id?: string;
918
963
  client_secret?: string;
964
+ resource?: string | string[];
919
965
  } | undefined): Promise<TokenSuccess | TokenError>;
920
966
  /**
921
967
  * OIDC discovery (Tier 2). Derives every endpoint from the signer's `issuer`
922
968
  * (configured as `{origin}/auth`), so a relying `OidcProvider` configured with
923
969
  * the same `issuer` resolves `/authorize`, `/token`, and `/jwks` automatically.
924
- * 404 when no signer is wired (Tier-1-only deployment).
970
+ * 404 when no signer is wired (Tier-1-only deployment). When DCR is also
971
+ * wired, `registration_endpoint` is advertised here too — in a combined
972
+ * deployment a client that prefers `openid-configuration` over the RFC 8414
973
+ * document must see the same capability set.
925
974
  */
926
975
  discovery(): OidcDiscoveryDocument | TokenError;
976
+ /**
977
+ * RFC 8414 Authorization Server Metadata — the OAuth-flavored discovery MCP
978
+ * connector clients fetch (OAUTH.md R1). Served signer-INDEPENDENTLY: it
979
+ * needs only an issuer ({@link getIssuer} — overridable for a Tier-1-style
980
+ * deployment with no `IdTokenSigner`). Mounted under the controller this is
981
+ * the suffix form `{issuer}/.well-known/oauth-authorization-server`; the
982
+ * RFC-correct path-insertion form at the HTTP-server ROOT
983
+ * (`/.well-known/oauth-authorization-server/<issuer-path>`) cannot be
984
+ * registered by a prefix-mounted controller — consumers mount it themselves
985
+ * from the exported `buildAuthorizationServerMetadata` (re-exported by this
986
+ * package).
987
+ */
988
+ oauthServerMetadata(): AuthorizationServerMetadata$1 | TokenError;
989
+ /**
990
+ * RFC 7591 Dynamic Client Registration (OAUTH.md R2) — anonymous by spec;
991
+ * 404 unless a registration operation is wired ({@link
992
+ * getDynamicClientRegistration}). A thin HTTP adapter: validation, abuse
993
+ * knobs (guard / cap / never-used GC) and persistence live in
994
+ * `DynamicClientRegistration` (`@aooth/auth`). Public clients only — the
995
+ * response carries NO `client_secret` and NO `client_secret_expires_at`
996
+ * (RFC 7591 §3.2.1 requires them only when a secret is issued).
997
+ */
998
+ register(body: unknown): Promise<ClientRegistrationSuccess | ClientRegistrationFailure | TokenError>;
927
999
  /** The signer's public JWKS (Tier 2). 404 when no signer is wired. */
928
1000
  jwks(): Promise<Awaited<ReturnType<IdTokenSigner["jwks"]>>> | TokenError;
929
1001
  /** Fail soft: 302 the validated client redirect with an `?error=` (+ echoed `state`). */
@@ -1013,6 +1085,17 @@ declare const AUTH_CODE_STORE_TOKEN = "aooth:AuthCodeStore";
1013
1085
  * `CompositeClientPolicy` of both) under this string.
1014
1086
  */
1015
1087
  declare const CLIENT_REDIRECT_POLICY_TOKEN = "aooth:ClientRedirectPolicy";
1088
+ /**
1089
+ * DI token for the {@link import("@aooth/auth/authz").DynamicClientStore}
1090
+ * (RFC 7591 dynamic registrations) — an abstract class, same auto-instantiation
1091
+ * hazard as the store tokens above. The BASE `AuthorizeController` never
1092
+ * injects it (DCR is optional, and an optional `@Inject` panics in moost's
1093
+ * route-table pass): a consumer subclass that enables DCR adds it as a
1094
+ * REQUIRED ctor param, builds a `DynamicClientRegistration` around it, and
1095
+ * overrides `getDynamicClientRegistration()`. The same store instance also
1096
+ * backs the `DynamicClientPolicy` composed into the redirect policy.
1097
+ */
1098
+ declare const DYNAMIC_CLIENT_STORE_TOKEN = "aooth:DynamicClientStore";
1016
1099
  //#endregion
1017
1100
  //#region src/workflow/auth-workflow.ctx.d.ts
1018
1101
  type MfaTransport = "sms" | "email" | "totp";
@@ -1147,17 +1230,52 @@ interface AuthWfMfaState {
1147
1230
  }
1148
1231
  /** Channel-onboarding state (login Phase 3). */
1149
1232
  interface AuthWfChannelState {
1233
+ /**
1234
+ * The email address `ask/email` collected and sent the enrollment code to —
1235
+ * the sole enrollment ask→verify target, mirroring `phone`. The enrollment
1236
+ * gates key on it. Deliberately SEPARATE from `notice.email` (the
1237
+ * security-notice recipient): that slot may be seeded from
1238
+ * `getCorrespondenceEmail` / a federated profile without any code ever
1239
+ * being sent, so the enrollment gates must never key on it.
1240
+ */
1241
+ email?: string;
1150
1242
  emailConfirmed?: boolean;
1151
1243
  phone?: string;
1152
1244
  phoneConfirmed?: boolean;
1153
1245
  otpDisclosure?: string;
1154
1246
  }
1247
+ /**
1248
+ * Security-notice recipient state (login flow). `email` is the address
1249
+ * `notify-new-device` (and any future security notice) delivers to — owned
1250
+ * by the `credentials` / `seedChannelState` seeding (confirmed email-MFA →
1251
+ * `getCorrespondenceEmail` → provider display email) and refreshed by
1252
+ * `verify/email` once a freshly-proven inbox confirms. Server-only — never
1253
+ * `@wf.context.pass`'d, not mirrored into `ctx.public`. Deliberately
1254
+ * distinct from `channel.email` (the enrollment ask→verify target) and from
1255
+ * the flow-subject `ctx.email` that recovery / invite / signup use.
1256
+ */
1257
+ interface AuthWfNoticeState {
1258
+ email?: string;
1259
+ }
1155
1260
  /** Device-trust state (login). */
1156
1261
  interface AuthWfTrustState {
1157
1262
  deviceTrustToken?: string;
1158
1263
  newDevice?: boolean;
1159
1264
  rememberDevice?: boolean;
1160
1265
  optIn?: boolean;
1266
+ /**
1267
+ * The ARRIVING request presented a valid recognition cookie (stamped by
1268
+ * `device-recognition` BEFORE any mint — pre-mint arrival state). This is
1269
+ * what the notify-new-device gate reads: recognition suppresses the
1270
+ * notification; it never affects MFA (that's `newDevice` / trust).
1271
+ */
1272
+ recognized?: boolean;
1273
+ /**
1274
+ * Recognition token `issue` must set as a cookie on the finish envelope —
1275
+ * either the re-validated arriving cookie (re-issued with a fresh maxAge)
1276
+ * or a freshly minted one.
1277
+ */
1278
+ seenDeviceToken?: string;
1161
1279
  }
1162
1280
  /** Session-policy state (login). */
1163
1281
  interface AuthWfSessionState {
@@ -1289,12 +1407,32 @@ interface AuthWfRecoveryAltActions {
1289
1407
  */
1290
1408
  interface AuthWfOtpState {
1291
1409
  verified?: boolean;
1410
+ /**
1411
+ * The address the last `pincode-send` ACTUALLY delivered to — login MFA
1412
+ * email/SMS challenge, recovery M1 (typed identifier) / M2 (registered
1413
+ * method), and signup's reuse of the recovery pair. Server-only — never
1414
+ * `@wf.context.pass`'d. The email-channel case is consumed by
1415
+ * `pincode-check` as inbox proof (`users.setVerifiedEmail`).
1416
+ */
1417
+ deliveredTo?: string;
1418
+ /** Wire channel of that delivery — only `email` constitutes an inbox proof. */
1419
+ deliveredChannel?: "email" | "sms";
1292
1420
  }
1293
1421
  /** Invite admin-side (Phase A) state. */
1294
1422
  interface AuthWfAdminState {
1295
1423
  availableRoles?: string[];
1296
1424
  roles?: string[];
1297
1425
  userExtras?: Record<string, unknown>;
1426
+ /**
1427
+ * Re-invite decision stamp — set by `admin-form` when `duplicateInviteCheck`
1428
+ * returns `'reuse'` (the default for a row still parked on
1429
+ * `account.pendingInvitation`). `create-user` then refreshes the existing
1430
+ * row (fresh roles + extras, `pendingInvitation` re-asserted) instead of
1431
+ * creating, and `send-email` mints a fresh magic link. Re-validated in
1432
+ * `create-user` against a fresh read: non-pending row → 409, vanished row →
1433
+ * normal create path.
1434
+ */
1435
+ reuseExisting?: boolean;
1298
1436
  /**
1299
1437
  * Outlet-pause idempotency marker for `send-email`. Flipped to `true`
1300
1438
  * after the first dispatch so the invitee's magic-link resume — which
@@ -1577,18 +1715,26 @@ interface AuthWfPublicState {
1577
1715
  newPasswordRequired?: boolean;
1578
1716
  /**
1579
1717
  * Mirrors the display-only fields of `ctx.authz` — the requesting client's
1580
- * id/name and granted scope, shown on the authorize-consent form. The
1581
- * `handle` and the `approved` gate stay server-only (never whitelisted onto
1582
- * the wire).
1718
+ * id/name, granted scope, and the VALIDATED redirect host (the trustworthy
1719
+ * identity shown next to the attacker-choosable `clientName`), shown on the
1720
+ * authorize-consent form. The `handle` and the `approved` gate stay
1721
+ * server-only (never whitelisted onto the wire).
1583
1722
  */
1584
1723
  authz?: {
1585
1724
  clientName?: string;
1586
1725
  scope?: string;
1726
+ redirectHost?: string;
1587
1727
  };
1588
1728
  }
1589
1729
  /** Unified workflow context shape — one type for all three flows. */
1590
1730
  interface AuthWfCtx {
1591
1731
  subject?: string;
1732
+ /**
1733
+ * Flow-subject address — the typed recovery identifier, the invite target,
1734
+ * or the signup email. The LOGIN flow does NOT use this slot: its
1735
+ * security-notice recipient is `notice.email` and its enrollment
1736
+ * ask→verify target is `channel.email`.
1737
+ */
1592
1738
  email?: string;
1593
1739
  defaults?: AuthWfDefaults;
1594
1740
  pin?: string;
@@ -1630,6 +1776,7 @@ interface AuthWfCtx {
1630
1776
  recoveryAltActions?: AuthWfRecoveryAltActions;
1631
1777
  mfa?: AuthWfMfaState;
1632
1778
  channel?: AuthWfChannelState;
1779
+ notice?: AuthWfNoticeState;
1633
1780
  trust?: AuthWfTrustState;
1634
1781
  session?: AuthWfSessionState;
1635
1782
  altActions?: AuthWfAltActionsState;
@@ -1657,14 +1804,17 @@ interface AuthWfCtx {
1657
1804
  * "Continue with <provider>" detour mid-authorize. Presence routes the login
1658
1805
  * tail to the `authz-consent` → `mint-authz-code` terminal (deliver an auth
1659
1806
  * code to the client) INSTEAD of `issue`/`redirect` — no browser session is
1660
- * minted. `clientName`/`scope` are staged by `authz-consent` for the consent
1661
- * form's display copy; `approved` is the explicit user-consent gate the
1662
- * `mint-authz-code` terminal requires before it will mint a code.
1807
+ * minted. `clientName`/`scope`/`redirectHost` are staged by `authz-consent`
1808
+ * for the consent form's display copy (`redirectHost` is parsed from the
1809
+ * VALIDATED redirect the trustworthy identity next to the registrant-chosen
1810
+ * name); `approved` is the explicit user-consent gate the `mint-authz-code`
1811
+ * terminal requires before it will mint a code.
1663
1812
  */
1664
1813
  authz?: {
1665
1814
  handle: string;
1666
1815
  clientName?: string;
1667
1816
  scope?: string;
1817
+ redirectHost?: string;
1668
1818
  approved?: boolean;
1669
1819
  };
1670
1820
  /**
@@ -1698,6 +1848,24 @@ interface AuthWorkflowOpts {
1698
1848
  ttlMs?: number;
1699
1849
  bindsTo?: "cookie" | "cookie+ip";
1700
1850
  };
1851
+ /**
1852
+ * Device RECOGNITION infra — the always-on `seenDevices` ledger + cookie
1853
+ * that suppress the "new sign-in" notification on devices the user has
1854
+ * already logged in from. Strictly a notification suppressor, NOT an MFA
1855
+ * bypass (that is `deviceTrust`, which stays opt-in and strict).
1856
+ * Cookie-only binding by design — recognition is pure noise control, so it
1857
+ * never binds to IP (IP churn must not re-trigger the email).
1858
+ *
1859
+ * `cookieName` defaults to `<deviceTrust.cookieName>_seen` so a consumer
1860
+ * renaming the trust cookie gets a matching recognition name for free.
1861
+ * No policy flags here — the on/off gate is the existing
1862
+ * `resolveFinalize().notifyNewDevice` policy.
1863
+ */
1864
+ deviceRecognition?: {
1865
+ cookieName?: string;
1866
+ ttlMs?: number; /** Cap on the per-user `seenDevices` ledger — LRU-evicted beyond it. */
1867
+ maxDevices?: number;
1868
+ };
1701
1869
  forms?: {
1702
1870
  loginCredentials?: TAtscriptAnnotatedType;
1703
1871
  invite?: TAtscriptAnnotatedType;
@@ -1748,6 +1916,11 @@ interface ResolvedAuthWorkflowOpts {
1748
1916
  ttlMs: number;
1749
1917
  bindsTo: "cookie" | "cookie+ip";
1750
1918
  };
1919
+ deviceRecognition: {
1920
+ cookieName: string;
1921
+ ttlMs: number;
1922
+ maxDevices: number;
1923
+ };
1751
1924
  forms: {
1752
1925
  loginCredentials: TAtscriptAnnotatedType;
1753
1926
  invite: TAtscriptAnnotatedType;
@@ -1819,6 +1992,22 @@ type AuthDeliveryPayload = {
1819
1992
  recipient: string;
1820
1993
  deviceLabel?: string;
1821
1994
  loginAt: number;
1995
+ }
1996
+ /**
1997
+ * Consumer-triggered security notice (e.g. impossible-travel detected by a
1998
+ * `resolveRiskStepUp` override) — routed through the same `deliver()` as
1999
+ * every other notice. `reason` is the machine-readable trigger
2000
+ * (e.g. `"impossible-travel"`); `context` is free-form template data
2001
+ * (distances, cities). NEVER auto-sent by the base class — only a consumer
2002
+ * call to `sendSecurityAlert` emits it.
2003
+ */
2004
+ | {
2005
+ kind: "security-alert";
2006
+ channel: "email";
2007
+ recipient: string;
2008
+ reason: string;
2009
+ loginAt: number;
2010
+ context?: Record<string, unknown>;
1822
2011
  };
1823
2012
  /**
1824
2013
  * Top-level `UserCredentials` keys that workflow-collected profile payloads
@@ -1834,6 +2023,22 @@ declare const RESERVED_USER_KEYS: ReadonlySet<string>;
1834
2023
  * Does not mutate the input.
1835
2024
  */
1836
2025
  declare function stripReservedUserKeys(profile: Record<string, unknown>): Record<string, unknown>;
2026
+ /**
2027
+ * Best-effort "Browser on OS" label from a raw User-Agent string — feeds the
2028
+ * `name` field of `seenDevices` records so a device list reads "Chrome on
2029
+ * Windows" instead of a token. Deliberately tiny (no UA-parser dependency);
2030
+ * detection order lives in the `UA_BROWSERS` / `UA_OSES` tables above.
2031
+ * Returns just the browser or just the OS when only one side is detected;
2032
+ * `undefined` for empty / fully unrecognized input.
2033
+ */
2034
+ declare function humanizeUserAgent(ua: string | undefined): string | undefined;
2035
+ declare function haversineKm(a: {
2036
+ lat: number;
2037
+ lon: number;
2038
+ }, b: {
2039
+ lat: number;
2040
+ lon: number;
2041
+ }): number;
1837
2042
  /** Trim + de-duplicate role identifiers submitted via the admin invite form. */
1838
2043
  declare function parseInviteRoles(input?: string[]): string[];
1839
2044
  /**
@@ -1866,6 +2071,16 @@ declare class AuthWorkflow {
1866
2071
  * sync-friendly: the default `void` preserves the engine's sync fast path.
1867
2072
  */
1868
2073
  protected deliver(_payload: AuthDeliveryPayload): void | Promise<void>;
2074
+ /**
2075
+ * The blessed one-call alert path for risk overrides — emit a
2076
+ * `security-alert` delivery (e.g. from an impossible-travel
2077
+ * `resolveRiskStepUp` override). Recipient comes from `ctx.notice.email`,
2078
+ * the proven-first correspondence chain seeded by `credentials` /
2079
+ * `seedChannelState` and refreshed by `verify/email`. No recipient →
2080
+ * SILENT no-op (a user with no provable inbox simply can't be alerted —
2081
+ * mirrors `notifyNewDevice`'s posture). Never called by the base class.
2082
+ */
2083
+ protected sendSecurityAlert(ctx: AuthWfCtx, reason: string, context?: Record<string, unknown>): Promise<void>;
1869
2084
  /**
1870
2085
  * Return the list of selectable role identifiers for the admin invite form.
1871
2086
  * Mirrors the prior `InviteWorkflow.getAvailableRoles()` consumer hook —
@@ -1897,14 +2112,21 @@ declare class AuthWorkflow {
1897
2112
  email: string;
1898
2113
  }): Promise<string[]> | string[];
1899
2114
  /**
1900
- * Override the structural duplicate rule for `admin-form`. Default: any
1901
- * existing row `'reject'`; nothing → `'allow'`. Multi-tenant apps that
1902
- * allow re-inviting the same email into a different tenant override.
2115
+ * Override the structural duplicate rule for `admin-form`. Default: a row
2116
+ * still parked on `account.pendingInvitation` → `'reuse'` (re-invite:
2117
+ * `create-user` refreshes the existing record in place and `send-email`
2118
+ * mints a fresh magic link — see `createUser`); any other existing row →
2119
+ * `'reject'`; nothing → `'allow'`.
2120
+ *
2121
+ * Multi-tenant apps that allow re-inviting the same email into a different
2122
+ * tenant override to `'allow'`. Apps that want the strict legacy behavior
2123
+ * ("Invite already pending" error on a duplicate invite of a pending user)
2124
+ * return `'reject'` for pending rows.
1903
2125
  */
1904
2126
  protected duplicateInviteCheck(input: {
1905
2127
  email: string;
1906
2128
  existingUser: UserCredentials | null;
1907
- }): Promise<"allow" | "reject"> | "allow" | "reject";
2129
+ }): Promise<"allow" | "reject" | "reuse"> | "allow" | "reject" | "reuse";
1908
2130
  /**
1909
2131
  * Implements the "log out other sessions" branch of `sessionPolicy.concurrencyLimit`.
1910
2132
  * Default revokes every existing session via `auth.revokeAllForUser` — which is
@@ -2201,6 +2423,17 @@ declare class AuthWorkflow {
2201
2423
  * matching `resolveOtpDisclosure` / the MFA transport.
2202
2424
  */
2203
2425
  protected resolvePromoteHandleField(_ctx: AuthWfCtx, _channel: "email" | "sms"): string | undefined | Promise<string | undefined>;
2426
+ /**
2427
+ * Decide whether a verified federated profile's email claim counts as inbox
2428
+ * proof for the CORRESPONDENCE address (`users.setVerifiedEmail`). Default
2429
+ * trusts the provider's `email_verified` claim — a provider trusted to
2430
+ * AUTHENTICATE the user is strictly more trusted than its email claim. The
2431
+ * capture is correspondence-only: it never promotes the address to a login
2432
+ * handle and never resolves accounts by it. Override to exclude providers
2433
+ * whose claim should not be taken at face value (e.g. an internal OIDC
2434
+ * issuer that stamps `email_verified` on unverified directory entries).
2435
+ */
2436
+ protected resolveFederatedEmailTrust(_ctx: AuthWfCtx, profile: FederatedProfileSnapshot): boolean | Promise<boolean>;
2204
2437
  /**
2205
2438
  * Route a form alt-action click to a canonical outcome. Defaults match the
2206
2439
  * action ids the bundled `PincodeForm` declares; customers override per
@@ -2496,6 +2729,19 @@ declare class AuthWorkflow {
2496
2729
  * Create the user row from `ctx.admin.userExtras` (plus the admin-supplied
2497
2730
  * `ctx.admin.roles`), then stamp `pendingInvitation = true` via a follow-up
2498
2731
  * deep-merge update so `createUser`-applied account defaults survive.
2732
+ *
2733
+ * Re-invite (`ctx.admin.reuseExisting`, stamped by `admin-form` on a
2734
+ * `'reuse'` verdict): REFRESH the existing row instead of creating — apply
2735
+ * the freshly-picked roles + `prepareUser` extras, re-assert
2736
+ * `pendingInvitation`, and leave password/MFA state untouched (a pending
2737
+ * record never had usable credentials). `send-email` downstream then mints
2738
+ * a fresh durable handle, i.e. a new full-TTL magic link. Guarded by a
2739
+ * FRESH `pendingInvitation` read: a `'reuse'` verdict for an accepted
2740
+ * account 409s as a logic error rather than silently re-pending a live
2741
+ * user; a row that vanished since `admin-form` falls through to the normal
2742
+ * create path. The refresh is a deep-merge update: arrays (`roles`)
2743
+ * replace wholesale, but extras keys the current `prepareUser` no longer
2744
+ * returns linger from the original invite.
2499
2745
  */
2500
2746
  createUser(ctx: AuthWfCtx): Promise<undefined>;
2501
2747
  /**
@@ -2790,6 +3036,29 @@ declare class AuthWorkflow {
2790
3036
  * the MFA-form `hidden` expression on `rememberDevice`).
2791
3037
  */
2792
3038
  deviceTrust(ctx: AuthWfCtx): Promise<undefined>;
3039
+ /**
3040
+ * Always-on device RECOGNITION — verify-or-mint the long-lived recognition
3041
+ * cookie against the `seenDevices` ledger. Recognition is a notification
3042
+ * suppressor ONLY (the notify-new-device gate reads `trust.recognized`); it
3043
+ * never skips MFA — that is `deviceTrust`, which stays opt-in and strict.
3044
+ *
3045
+ * Deliberately a SEPARATE step from `check-trusted-device`: that step is
3046
+ * schema-gated on `deviceTrust.enabled && skipsMfa`, so recognition must
3047
+ * not piggyback on it or recognition dies whenever trust is disabled —
3048
+ * exactly the consumers who get the noisiest notify behaviour today.
3049
+ * Verify-or-mint lives in ONE step so `trust.recognized` captures the
3050
+ * PRE-MINT arrival state the notify gate needs (a freshly minted token
3051
+ * must not mark the current login as recognized).
3052
+ *
3053
+ * A valid arriving cookie is verified with `slideTtlMs` (LRU bump) and
3054
+ * re-stashed on `trust.seenDeviceToken` so `issue` re-sets it with a fresh
3055
+ * maxAge. An unrecognized arrival mints + persists a new record (capped at
3056
+ * `deviceRecognition.maxDevices`) and stashes the new token — `recognized`
3057
+ * stays unset so the notification still fires for this login. Degrades
3058
+ * gracefully to a no-op when no `deviceTrust.secret` is configured,
3059
+ * preserving the legacy notify behaviour for those consumers.
3060
+ */
3061
+ deviceRecognition(ctx: AuthWfCtx): Promise<undefined>;
2793
3062
  /**
2794
3063
  * Standalone terms-bump prompt for returning users whose accepted terms
2795
3064
  * version is stale and no carrier form ran. Delegates to
@@ -2850,7 +3119,16 @@ declare class AuthWorkflow {
2850
3119
  /**
2851
3120
  * Notify the user of a login from a new device via the unified `deliver`
2852
3121
  * hook. Gated upstream by
2853
- * `!ctx.isFirstLogin && !!ctx.finalize.notifyNewDevice && !!ctx.trust.newDevice`.
3122
+ * `!ctx.isFirstLogin && !!ctx.finalize.notifyNewDevice && !ctx.trust.recognized`
3123
+ * — "not recognized" (no valid recognition cookie on arrival), NOT "no
3124
+ * valid trust cookie": users who decline remember-me, or whose strict trust
3125
+ * cookie expired / failed IP binding, must not get the email on every
3126
+ * login. Recognition is the loose always-on ledger minted by
3127
+ * `device-recognition`; trust stays strict and drives MFA skip only.
3128
+ *
3129
+ * Recipient is `notice.email` — the security-notice slot owned by the
3130
+ * `credentials` / `seedChannelState` seeding and refreshed by
3131
+ * `verify/email`. No recipient seeded → silently skips.
2854
3132
  */
2855
3133
  notifyNewDevice(ctx: AuthWfCtx): Promise<undefined>;
2856
3134
  /**
@@ -3014,14 +3292,31 @@ declare class AuthWorkflow {
3014
3292
  */
3015
3293
  private finishOAuth;
3016
3294
  /**
3017
- * Seed `ctx.email` / `ctx.channel` from a resolved user's confirmed channels —
3295
+ * Seed `ctx.notice.email` / `ctx.channel` from a resolved user's confirmed channels —
3018
3296
  * shared by `ssoCallback` (linked / created / auto-linked) and `proveControl`
3019
3297
  * (interactively-linked) so the post-success channel shape can't drift between
3020
- * the two federated entry points. Mirrors `credentials`' post-login seeding.
3021
- * `fallbackEmail` (the provider / snapshot email) is a DISPLAY fallback only —
3022
- * never promoted to the unique login handle (a gated, later-phase concern).
3298
+ * the two federated entry points. Mirrors `credentials`' post-login seeding,
3299
+ * including the correspondence fallback (confirmed email-MFA
3300
+ * `users.getCorrespondenceEmail` provider display email).
3301
+ *
3302
+ * Also the single federated capture point for `users.setVerifiedEmail`: a
3303
+ * trusted `profile.email` (per `resolveFederatedEmailTrust`) is recorded as
3304
+ * the proven correspondence address on EVERY federated login — first-time
3305
+ * create, returning link, and interactive link alike (the store write is
3306
+ * skipped when the capture is already current). The profile email is
3307
+ * otherwise a DISPLAY fallback only — never promoted to the unique login
3308
+ * handle (a gated, later-phase concern).
3023
3309
  */
3024
3310
  private seedChannelState;
3311
+ /**
3312
+ * Correspondence tail of the `ctx.notice.email` seeding — shared by
3313
+ * `credentials` (post-login) and `seedChannelState` (federated) so the
3314
+ * fallback chain (`users.getCorrespondenceEmail` → optional display email)
3315
+ * can't drift between the two. Does NOT set `channel.emailConfirmed` — that
3316
+ * flag means "confirmed email-MFA channel" and gates enrolment; a
3317
+ * correspondence address is a notice recipient, not a proven OTP channel.
3318
+ */
3319
+ private seedCorrespondenceEmail;
3025
3320
  /**
3026
3321
  * `needs-link` setup (decision A — password, OTP fallback). Decide how the
3027
3322
  * user will prove control of the matched account, stash the pending-link
@@ -3186,4 +3481,4 @@ interface AuditEmitter {
3186
3481
  emit(event: AuditEvent): Promise<void> | void;
3187
3482
  }
3188
3483
  //#endregion
3189
- export { ADD_MFA_WORKFLOW, AUTHZ_BINDING_COOKIE, AUTH_CODE_STORE_TOKEN, type AuditEmitter, type AuditEvent, type AuthBindings, type AuthContext, AuthController, type AuthDeliveryPayload, type AuthEmailEvent, type AuthEmailKind, type AuthEmailOutletDeps, AuthGuarded, type AuthLoginResponse, type AuthLogoutBody, type AuthOkResponse, type AuthOptions, type AuthRefreshBody, type AuthSmsEvent, type AuthSmsKind, type AuthWfCompletionState, type AuthWfConsentsState, type AuthWfCtx, type AuthWfMfaEnrollState, type AuthWfOAuthState, type AuthWfPasswordUiState, type AuthWfPincodeUiState, AuthWorkflow, type AuthWorkflowOpts, AuthorizeController, AuthorizeRuntime, type BuildMagicLinkUrl, CHANGE_PASSWORD_WORKFLOW, CLIENT_REDIRECT_POLICY_TOKEN, type ConcurrencyLimitOptions, type ConnectedAccount, type ConsentDescriptor, type ConsentDescriptorLike, type ConsentEvent, ConsentStore, DEFAULT_AUTH_WORKFLOWS, type EmailSender, type EnrichedSession, FEDERATED_IDENTITY_STORE_TOKEN, type IssueResult, type LoginRedirect, type MfaSummary, type MfaTransport, OAUTH_CSRF_COOKIE, OAuthController, OAuthRuntime, PENDING_AUTHORIZATION_STORE_TOKEN, Public, RESERVED_USER_KEYS, type ResolvedAuthCookieConfig, type ResolvedAuthOptions, type ResolvedAuthWorkflowOpts, type SessionEnricher, SessionEnricherProvider, type SessionInfo, SessionsController, type SmsSender, type SsoProvider, type TAuthMeta, UserId, WfTrigger, type WfTriggerOpts, WfTriggerProvider, authGuardInterceptor, authzBindingCookieAttrs, buildInviteAlreadyAcceptedEnvelope, createAuthEmailOutlet, deriveWfStateSecret, generateMagicLinkToken, getAuthMate, isSafeRelativeRedirect, oauthCsrfCookieAttrs, parseInviteRoles, resolveOAuthRedirect, stripReservedUserKeys, useAuth };
3484
+ export { ADD_MFA_WORKFLOW, AUTHZ_BINDING_COOKIE, AUTH_CODE_STORE_TOKEN, type AuditEmitter, type AuditEvent, type AuthBindings, type AuthContext, AuthController, type AuthDeliveryPayload, type AuthEmailEvent, type AuthEmailKind, type AuthEmailOutletDeps, AuthGuarded, type AuthLoginResponse, type AuthLogoutBody, type AuthOkResponse, type AuthOptions, type AuthRefreshBody, type AuthSmsEvent, type AuthSmsKind, type AuthWfCompletionState, type AuthWfConsentsState, type AuthWfCtx, type AuthWfMfaEnrollState, type AuthWfOAuthState, type AuthWfPasswordUiState, type AuthWfPincodeUiState, AuthWorkflow, type AuthWorkflowOpts, type AuthorizationServerMetadata, AuthorizeController, AuthorizeRuntime, type BuildAuthorizationServerMetadataOptions, type BuildMagicLinkUrl, type BuildProtectedResourceMetadataOptions, CHANGE_PASSWORD_WORKFLOW, CLIENT_REDIRECT_POLICY_TOKEN, type ConcurrencyLimitOptions, type ConnectedAccount, type ConsentDescriptor, type ConsentDescriptorLike, type ConsentEvent, ConsentStore, DEFAULT_AUTH_WORKFLOWS, DYNAMIC_CLIENT_STORE_TOKEN, DynamicClientRegistration, type DynamicClientRegistrationOptions, type EmailSender, type EnrichedSession, FEDERATED_IDENTITY_STORE_TOKEN, type IssueResult, type LoginRedirect, type MfaSummary, type MfaTransport, OAUTH_CSRF_COOKIE, OAuthController, OAuthRuntime, PENDING_AUTHORIZATION_STORE_TOKEN, type ProtectedResourceMetadata, Public, RESERVED_USER_KEYS, type ResolvedAuthCookieConfig, type ResolvedAuthOptions, type ResolvedAuthWorkflowOpts, type SessionEnricher, SessionEnricherProvider, type SessionInfo, SessionsController, type SmsSender, type SsoProvider, type TAuthMeta, UserId, WfTrigger, type WfTriggerOpts, WfTriggerProvider, type WwwAuthenticateBearerChallengeOptions, authGuardInterceptor, authzBindingCookieAttrs, buildAuthorizationServerMetadata, buildInviteAlreadyAcceptedEnvelope, buildProtectedResourceMetadata, buildWwwAuthenticateBearerChallenge, canonicalizeIssuer, createAuthEmailOutlet, deriveWfStateSecret, generateMagicLinkToken, getAuthMate, haversineKm, humanizeUserAgent, isSafeRelativeRedirect, oauthCsrfCookieAttrs, parseInviteRoles, resolveOAuthRedirect, stripReservedUserKeys, useAuth };