@spfn/auth 0.3.0-beta.23 → 0.3.0-beta.24

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/server.js CHANGED
@@ -4455,13 +4455,14 @@ var init_esm = __esm({
4455
4455
  });
4456
4456
 
4457
4457
  // src/server/types.ts
4458
- var KEY_ALGORITHM, KEY_PLATFORM, KEY_DEVICE_NAME_MAX_LENGTH, INVITATION_STATUSES, USER_STATUSES, SOCIAL_PROVIDERS, ACCOUNT_DELETION_REQUEST_STATUSES, ACCOUNT_DELETION_REQUESTED_BY, PURGE_STRATEGIES;
4458
+ var KEY_ALGORITHM, KEY_PLATFORM, KEY_DEVICE_NAME_MAX_LENGTH, SESSION_BINDINGS, INVITATION_STATUSES, USER_STATUSES, SOCIAL_PROVIDERS, ACCOUNT_DELETION_REQUEST_STATUSES, ACCOUNT_DELETION_REQUESTED_BY, PURGE_STRATEGIES;
4459
4459
  var init_types = __esm({
4460
4460
  "src/server/types.ts"() {
4461
4461
  "use strict";
4462
4462
  KEY_ALGORITHM = ["ES256", "RS256"];
4463
4463
  KEY_PLATFORM = ["ios", "android", "web", "desktop"];
4464
4464
  KEY_DEVICE_NAME_MAX_LENGTH = 64;
4465
+ SESSION_BINDINGS = ["none", "passkey"];
4465
4466
  INVITATION_STATUSES = ["pending", "accepted", "expired", "cancelled"];
4466
4467
  USER_STATUSES = ["active", "inactive", "suspended", "pending_deletion", "deleted"];
4467
4468
  SOCIAL_PROVIDERS = ["google", "apple", "github", "kakao", "naver", "superself"];
@@ -4523,7 +4524,15 @@ var init_schema3 = __esm({
4523
4524
  publicId: Type.String(),
4524
4525
  email: Type.Optional(Type.String()),
4525
4526
  phone: Type.Optional(Type.String()),
4526
- passwordChangeRequired: Type.Boolean()
4527
+ passwordChangeRequired: Type.Boolean(),
4528
+ // The approved branch is a `LoginResult` spread whole, so it carries the
4529
+ // binding fields a sign-in can carry (#97). In practice it never does: a
4530
+ // waiting device polls the backend directly, and a key is bound only on a
4531
+ // request that reached it through the trusted Next.js proxy. Declared
4532
+ // anyway, because the branch is whatever the login answered and the two
4533
+ // must not be able to drift apart.
4534
+ sessionBinding: Type.Optional(Type.Union(SESSION_BINDINGS.map((mode) => Type.Literal(mode)))),
4535
+ keyExpiresAtMillis: Type.Optional(Type.Integer())
4527
4536
  })
4528
4537
  ], {
4529
4538
  description: "Pending, or the login the approval produced"
@@ -4685,6 +4694,20 @@ var init_users = __esm({
4685
4694
  // it was issued against — a link is dead once any other path has already
4686
4695
  // signed every device out
4687
4696
  keyEpoch: integer2("key_epoch").notNull().default(0),
4697
+ // Whether this account's web sessions run on a key bound to a passkey
4698
+ // 'none' (default): the key lives 90 days and a copy of the session
4699
+ // cookie signs with it for as long as it lasts — the behaviour every
4700
+ // account had before this column existed
4701
+ // 'passkey': a web session started through the trusted Next.js proxy gets
4702
+ // a key that expires in hours, and only a fresh WebAuthn assertion can
4703
+ // put a new one in the cookie
4704
+ // Opt-in, and only while the account has a live platform passkey to renew
4705
+ // with. Leaving 'passkey' needs a fresh credential, never key age alone
4706
+ sessionBinding: enumText("session_binding", SESSION_BINDINGS).notNull().default("none"),
4707
+ // When the setting above last changed, so an account timeline can say
4708
+ // when the protection was turned on or off
4709
+ // null: never changed — the account has been on the default since it existed
4710
+ sessionBindingChangedAt: utcTimestamp("session_binding_changed_at"),
4688
4711
  // Metadata
4689
4712
  // Last successful login timestamp
4690
4713
  // Used for: security auditing, dormant account detection
@@ -4849,6 +4872,56 @@ var init_user_public_keys = __esm({
4849
4872
  // Written once at registration, for the same reason and with the same
4850
4873
  // standing as registeredIp above
4851
4874
  registeredUserAgent: text4("registered_user_agent"),
4875
+ // Browser family the registering request's user-agent named — one of the
4876
+ // five badges uaFamily() answers with, never the raw string
4877
+ // null: the request sent no user-agent, or the key predates this column
4878
+ // Written once at registration, like the two columns above. The proxy is
4879
+ // what compares a family against a request (the browser's user-agent does
4880
+ // not survive a server-component hop), so this is the displayable record
4881
+ // of where the key came from rather than the value any check reads
4882
+ registeredUaFamily: text4("registered_ua_family"),
4883
+ // Client address this key was last seen signing from, as getClientIp
4884
+ // resolved it on that request
4885
+ // null: that request resolved no address, or the key predates this column
4886
+ //
4887
+ // Unlike registeredIp above, this MOVES: it is overwritten by the same
4888
+ // throttled UPDATE that stamps lastUsedAt, so it is a new class of stored
4889
+ // PII in this table — an address that follows the device around rather
4890
+ // than one captured once. It exists for one purpose: to notice that one
4891
+ // key was used from two addresses inside a short window, which is what
4892
+ // concurrentUseAt records. Nothing else reads it and nothing is refused
4893
+ // by it, because addresses change legitimately all the time.
4894
+ //
4895
+ // It is NOT exposed. `listKeys` returns concurrentUseAt and never this,
4896
+ // so the account surface can say "this device was in two places at once"
4897
+ // without publishing a trail of where. Retention is the row's: it holds
4898
+ // only the most recent observation, is overwritten on the next one, and
4899
+ // goes with the key when the key is deleted.
4900
+ //
4901
+ // The literal string 'unknown' is never stored — getClientIp's fallback
4902
+ // becomes NULL, which reads as "no observation" everywhere it is compared.
4903
+ lastSeenIp: text4("last_seen_ip"),
4904
+ // When lastSeenIp was written, which is the same moment lastUsedAt was
4905
+ // The pair is what makes "within the window" answerable in one statement
4906
+ lastSeenAt: utcTimestamp2("last_seen_at"),
4907
+ // The last time this key was seen from an address different from the one
4908
+ // recorded, within SPFN_AUTH_CONCURRENT_USE_WINDOW_MS of the previous
4909
+ // sighting
4910
+ // null: never observed, which is the ordinary state for every key
4911
+ // Surfaced on listKeys as concurrentUseAtMillis for the owner to act on.
4912
+ // A signal, never a refusal — a phone moving between wifi and cellular
4913
+ // does this several times an hour, and so does a laptop behind a pool of
4914
+ // egress addresses
4915
+ concurrentUseAt: utcTimestamp2("concurrent_use_at"),
4916
+ // Whether this key is bound to a passkey — decided by the server when the
4917
+ // key was registered, from the owner's session_binding setting and
4918
+ // whether the request came through the trusted Next.js proxy
4919
+ // 'none' (default): a 90-day key, the behaviour that predates #97
4920
+ // 'passkey': a short-lived key; only a fresh WebAuthn assertion renews it,
4921
+ // and a login that re-registers it does not extend its expiry
4922
+ // Never read off a request body, and `platform` is not consulted: that
4923
+ // field is display-only and a native client may declare any value it likes
4924
+ binding: enumText2("binding", SESSION_BINDINGS).notNull().default("none"),
4852
4925
  // What the client said about itself on the last request signed by this key.
4853
4926
  //
4854
4927
  // The three come from x-spfn-client-kind, x-spfn-client-version and
@@ -5274,7 +5347,7 @@ var init_webauthn_challenges = __esm({
5274
5347
  "use strict";
5275
5348
  init_users();
5276
5349
  init_schema4();
5277
- WEBAUTHN_CHALLENGE_KINDS = ["registration", "authentication", "mfa"];
5350
+ WEBAUTHN_CHALLENGE_KINDS = ["registration", "authentication", "mfa", "renewal"];
5278
5351
  webauthnChallenges = authSchema.table(
5279
5352
  "webauthn_challenges",
5280
5353
  {
@@ -6523,6 +6596,333 @@ var init_users_repository = __esm({
6523
6596
  }
6524
6597
  });
6525
6598
 
6599
+ // src/server/lib/key-policy.ts
6600
+ function decideKeyBinding(accountBinding, webProxy) {
6601
+ return accountBinding === "passkey" && webProxy === true ? "passkey" : "none";
6602
+ }
6603
+ var KEY_TTL_DAYS, BOUND_KEY_TTL_HOURS, BOUND_KEY_RENEW_GRACE_HOURS, CONCURRENT_USE_WINDOW_MS;
6604
+ var init_key_policy = __esm({
6605
+ "src/server/lib/key-policy.ts"() {
6606
+ "use strict";
6607
+ KEY_TTL_DAYS = 90;
6608
+ BOUND_KEY_TTL_HOURS = 24;
6609
+ BOUND_KEY_RENEW_GRACE_HOURS = 168;
6610
+ CONCURRENT_USE_WINDOW_MS = 3e5;
6611
+ }
6612
+ });
6613
+
6614
+ // src/server/logger.ts
6615
+ import { logger as rootLogger } from "@spfn/core/logger";
6616
+ var authLogger;
6617
+ var init_logger = __esm({
6618
+ "src/server/logger.ts"() {
6619
+ "use strict";
6620
+ authLogger = {
6621
+ plugin: rootLogger.child("@spfn/auth:plugin"),
6622
+ middleware: rootLogger.child("@spfn/auth:middleware"),
6623
+ interceptor: {
6624
+ general: rootLogger.child("@spfn/auth:interceptor:general"),
6625
+ login: rootLogger.child("@spfn/auth:interceptor:login"),
6626
+ keyRotation: rootLogger.child("@spfn/auth:interceptor:key-rotation"),
6627
+ oauth: rootLogger.child("@spfn/auth:interceptor:oauth"),
6628
+ csrf: rootLogger.child("@spfn/auth:interceptor:csrf")
6629
+ },
6630
+ session: rootLogger.child("@spfn/auth:session"),
6631
+ service: rootLogger.child("@spfn/auth:service"),
6632
+ setup: rootLogger.child("@spfn/auth:setup"),
6633
+ email: rootLogger.child("@spfn/auth:email"),
6634
+ sms: rootLogger.child("@spfn/auth:sms")
6635
+ };
6636
+ }
6637
+ });
6638
+
6639
+ // src/server/lib/config.ts
6640
+ import { env as env3 } from "@spfn/auth/config";
6641
+ import { PasskeyConfigError } from "@spfn/auth/errors";
6642
+ function getCookieSuffix() {
6643
+ const port = process.env.SPFN_PORT;
6644
+ return port ? `_${port}` : "";
6645
+ }
6646
+ function matchOAuthCsrfCookies(cookies) {
6647
+ return Object.entries(cookies).filter(([name]) => /^spfn_oauth_csrf(_\d+)?$/.test(name)).map(([name, value]) => ({ name, value }));
6648
+ }
6649
+ function parseDuration(duration) {
6650
+ if (typeof duration === "number") {
6651
+ return duration;
6652
+ }
6653
+ const match = duration.match(/^(\d+)([dhms]?)$/);
6654
+ if (!match) {
6655
+ throw new Error(`Invalid duration format: ${duration}. Use format like '30d', '12h', '45m', '3600s', or plain number.`);
6656
+ }
6657
+ const value = parseInt(match[1], 10);
6658
+ const unit = match[2] || "s";
6659
+ switch (unit) {
6660
+ case "d":
6661
+ return value * 24 * 60 * 60;
6662
+ case "h":
6663
+ return value * 60 * 60;
6664
+ case "m":
6665
+ return value * 60;
6666
+ case "s":
6667
+ return value;
6668
+ default:
6669
+ throw new Error(`Unknown duration unit: ${unit}`);
6670
+ }
6671
+ }
6672
+ function configureAuth(config4) {
6673
+ globalConfig = {
6674
+ ...globalConfig,
6675
+ ...config4
6676
+ };
6677
+ }
6678
+ function getAuthConfig() {
6679
+ return { ...globalConfig };
6680
+ }
6681
+ async function runBeforeRegister(context) {
6682
+ const { beforeRegister } = globalConfig;
6683
+ if (beforeRegister) {
6684
+ await beforeRegister({ ...context, email: normalizeOptionalEmail(context.email) });
6685
+ }
6686
+ }
6687
+ function getSessionTtl(override) {
6688
+ if (override !== void 0) {
6689
+ return parseDuration(override);
6690
+ }
6691
+ if (globalConfig.sessionTtl !== void 0) {
6692
+ return parseDuration(globalConfig.sessionTtl);
6693
+ }
6694
+ const envTtl = env3.SPFN_AUTH_SESSION_TTL;
6695
+ if (envTtl) {
6696
+ return parseDuration(envTtl);
6697
+ }
6698
+ return 7 * 24 * 60 * 60;
6699
+ }
6700
+ function getCsrfMode() {
6701
+ const configured2 = globalConfig.csrf?.mode ?? env3.SPFN_AUTH_CSRF;
6702
+ if (!configured2) {
6703
+ return "warn";
6704
+ }
6705
+ const normalized = String(configured2).trim().toLowerCase();
6706
+ if (!CSRF_MODES.includes(normalized)) {
6707
+ if (!unrecognizedCsrfModeReported) {
6708
+ unrecognizedCsrfModeReported = true;
6709
+ authLogger.interceptor.csrf.error(
6710
+ `Unrecognized CSRF mode "${configured2}" \u2014 expected off | warn | enforce. Enforcing.`
6711
+ );
6712
+ }
6713
+ return "enforce";
6714
+ }
6715
+ return normalized;
6716
+ }
6717
+ function getCsrfExemptPaths() {
6718
+ return [...PACKAGE_CSRF_EXEMPT_PATHS, ...globalConfig.csrf?.exemptPaths ?? []];
6719
+ }
6720
+ function getBoundKeyTtlMs() {
6721
+ return positiveOr(env3.SPFN_AUTH_BOUND_KEY_TTL_HOURS, BOUND_KEY_TTL_HOURS) * 60 * 60 * 1e3;
6722
+ }
6723
+ function getBoundKeyRenewGraceMs() {
6724
+ return positiveOr(env3.SPFN_AUTH_BOUND_KEY_RENEW_GRACE_HOURS, BOUND_KEY_RENEW_GRACE_HOURS) * 60 * 60 * 1e3;
6725
+ }
6726
+ function getConcurrentUseWindowMs() {
6727
+ return positiveOr(env3.SPFN_AUTH_CONCURRENT_USE_WINDOW_MS, CONCURRENT_USE_WINDOW_MS);
6728
+ }
6729
+ function getSessionRenewPath() {
6730
+ return env3.SPFN_AUTH_SESSION_RENEW_PATH?.trim() || DEFAULT_SESSION_RENEW_PATH;
6731
+ }
6732
+ function positiveOr(configured2, fallback) {
6733
+ return Number.isFinite(configured2) && configured2 > 0 ? configured2 : fallback;
6734
+ }
6735
+ function passkeyEnvSource() {
6736
+ return { ...process.env, SPFN_APP_URL: process.env.SPFN_APP_URL || env3.SPFN_APP_URL };
6737
+ }
6738
+ function passkeyAppUrl(env21) {
6739
+ const configured2 = env21.NEXT_PUBLIC_SPFN_APP_URL || env21.SPFN_APP_URL;
6740
+ if (!configured2) {
6741
+ throw new PasskeyConfigError({
6742
+ message: "Passkeys need a relying party ID. Set SPFN_AUTH_PASSKEY_RP_ID, or set NEXT_PUBLIC_SPFN_APP_URL / SPFN_APP_URL to the app origin it should be derived from."
6743
+ });
6744
+ }
6745
+ try {
6746
+ return new URL(configured2);
6747
+ } catch {
6748
+ throw new PasskeyConfigError({
6749
+ message: `Passkeys cannot derive a relying party ID: "${configured2}" is not a URL. Fix NEXT_PUBLIC_SPFN_APP_URL / SPFN_APP_URL, or set SPFN_AUTH_PASSKEY_RP_ID explicitly.`
6750
+ });
6751
+ }
6752
+ }
6753
+ function isLocalhost(hostname) {
6754
+ return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]";
6755
+ }
6756
+ function isUnderRpId(hostname, rpId) {
6757
+ return hostname === rpId || hostname.endsWith(`.${rpId}`);
6758
+ }
6759
+ function assertOriginServesRpId(origin, rpId) {
6760
+ let url;
6761
+ try {
6762
+ url = new URL(origin);
6763
+ } catch {
6764
+ throw new PasskeyConfigError({
6765
+ message: `SPFN_AUTH_PASSKEY_ORIGINS contains "${origin}", which is not a URL. List full origins, e.g. https://app.example.com.`
6766
+ });
6767
+ }
6768
+ if (url.protocol !== "https:" && !isLocalhost(url.hostname)) {
6769
+ throw new PasskeyConfigError({
6770
+ message: `Passkey origin "${origin}" is not https. WebAuthn runs only in a secure context, and localhost is the only host a browser treats as one over plain http.`
6771
+ });
6772
+ }
6773
+ if (!isUnderRpId(url.hostname, rpId)) {
6774
+ throw new PasskeyConfigError({
6775
+ message: `Passkey origin "${origin}" is not on relying party ID "${rpId}". Each origin must be that host or a subdomain of it, or the browser refuses the ceremony.`
6776
+ });
6777
+ }
6778
+ }
6779
+ function resolveUserVerification(env21) {
6780
+ const configured2 = env21.SPFN_AUTH_PASSKEY_USER_VERIFICATION;
6781
+ if (!configured2) {
6782
+ return "preferred";
6783
+ }
6784
+ const normalized = configured2.trim().toLowerCase();
6785
+ if (!PASSKEY_USER_VERIFICATIONS.includes(normalized)) {
6786
+ throw new PasskeyConfigError({
6787
+ message: `SPFN_AUTH_PASSKEY_USER_VERIFICATION is "${configured2}" \u2014 expected preferred or required. A passkey is the whole credential here, so an assertion that skipped user verification would sign someone in on an unlocked device alone.`
6788
+ });
6789
+ }
6790
+ return normalized;
6791
+ }
6792
+ function resolvePositiveNumber(env21, variable, fallback) {
6793
+ const configured2 = env21[variable];
6794
+ if (!configured2) {
6795
+ return fallback;
6796
+ }
6797
+ const parsed = Number(configured2);
6798
+ if (!Number.isFinite(parsed) || parsed <= 0) {
6799
+ throw new PasskeyConfigError({
6800
+ message: `${variable} is "${configured2}" \u2014 expected a positive number.`
6801
+ });
6802
+ }
6803
+ return parsed;
6804
+ }
6805
+ function getPasskeyConfig(env21 = passkeyEnvSource()) {
6806
+ const rpId = env21.SPFN_AUTH_PASSKEY_RP_ID?.trim() || passkeyAppUrl(env21).hostname;
6807
+ const configuredOrigins = env21.SPFN_AUTH_PASSKEY_ORIGINS?.split(",").map((origin) => origin.trim()).filter(Boolean);
6808
+ const origins = configuredOrigins?.length ? configuredOrigins : [passkeyAppUrl(env21).origin];
6809
+ for (const origin of origins) {
6810
+ assertOriginServesRpId(origin, rpId);
6811
+ }
6812
+ return {
6813
+ rpId,
6814
+ rpName: env21.SPFN_AUTH_PASSKEY_RP_NAME?.trim() || rpId,
6815
+ origins,
6816
+ userVerification: resolveUserVerification(env21),
6817
+ challengeTtlMs: resolvePositiveNumber(
6818
+ env21,
6819
+ "SPFN_AUTH_PASSKEY_CHALLENGE_TTL_SECONDS",
6820
+ DEFAULT_CHALLENGE_TTL_SECONDS
6821
+ ) * 1e3,
6822
+ recentAuthMs: resolvePositiveNumber(
6823
+ env21,
6824
+ "SPFN_AUTH_PASSKEY_RECENT_AUTH_MINUTES",
6825
+ DEFAULT_RECENT_AUTH_MINUTES
6826
+ ) * 6e4
6827
+ };
6828
+ }
6829
+ function assertPasskeyConfig(env21 = passkeyEnvSource()) {
6830
+ if (PASSKEY_VARS.some((variable) => env21[variable])) {
6831
+ getPasskeyConfig(env21);
6832
+ return;
6833
+ }
6834
+ try {
6835
+ getPasskeyConfig(env21);
6836
+ } catch (error) {
6837
+ authLogger.service.info(
6838
+ `Passkeys cannot be served with the configuration derived from the app URL, and no SPFN_AUTH_PASSKEY_* variable is set, so boot continues. ${error.message}`
6839
+ );
6840
+ }
6841
+ }
6842
+ function getMfaConfig() {
6843
+ const configuredMinutes = Number(process.env.SPFN_AUTH_MFA_STEP_UP_MINUTES);
6844
+ const minutes = Number.isFinite(configuredMinutes) && configuredMinutes > 0 ? configuredMinutes : DEFAULT_STEP_UP_MINUTES;
6845
+ return {
6846
+ issuer: process.env.SPFN_AUTH_MFA_ISSUER?.trim() || process.env.SPFN_AUTH_PASSKEY_RP_NAME?.trim() || mfaIssuerFromAppUrl() || DEFAULT_MFA_ISSUER,
6847
+ stepUpWindowMs: minutes * 6e4
6848
+ };
6849
+ }
6850
+ function mfaIssuerFromAppUrl() {
6851
+ const configured2 = process.env.NEXT_PUBLIC_SPFN_APP_URL || process.env.SPFN_APP_URL;
6852
+ if (!configured2) {
6853
+ return null;
6854
+ }
6855
+ try {
6856
+ return new URL(configured2).hostname;
6857
+ } catch {
6858
+ return null;
6859
+ }
6860
+ }
6861
+ var COOKIE_NAMES, globalConfig, CSRF_MODES, unrecognizedCsrfModeReported, PACKAGE_CSRF_EXEMPT_PATHS, DEFAULT_SESSION_RENEW_PATH, PASSKEY_USER_VERIFICATIONS, DEFAULT_CHALLENGE_TTL_SECONDS, DEFAULT_RECENT_AUTH_MINUTES, PASSKEY_VARS, DEFAULT_MFA_ISSUER, DEFAULT_STEP_UP_MINUTES;
6862
+ var init_config = __esm({
6863
+ "src/server/lib/config.ts"() {
6864
+ "use strict";
6865
+ init_key_policy();
6866
+ init_email();
6867
+ init_logger();
6868
+ COOKIE_NAMES = {
6869
+ /** Encrypted session data (userId, privateKey, keyId, algorithm) */
6870
+ get SESSION() {
6871
+ return `spfn_session${getCookieSuffix()}`;
6872
+ },
6873
+ /** Current key ID (for key rotation) */
6874
+ get SESSION_KEY_ID() {
6875
+ return `spfn_session_key_id${getCookieSuffix()}`;
6876
+ },
6877
+ /** Pending OAuth session (privateKey, keyId, algorithm) - temporary during OAuth flow */
6878
+ get OAUTH_PENDING() {
6879
+ return `spfn_oauth_pending${getCookieSuffix()}`;
6880
+ },
6881
+ /** OAuth CSRF nonce — double-submit against the (encrypted) state.nonce at callback */
6882
+ get OAUTH_CSRF() {
6883
+ return `spfn_oauth_csrf${getCookieSuffix()}`;
6884
+ },
6885
+ /** Password-setup session for verified-email signup — temporary, single-purpose */
6886
+ get SIGNUP_SETUP() {
6887
+ return `spfn_signup_setup${getCookieSuffix()}`;
6888
+ },
6889
+ /** Password-setup session for a password reset — temporary, single-purpose */
6890
+ get PASSWORD_RESET_SETUP() {
6891
+ return `spfn_password_reset_setup${getCookieSuffix()}`;
6892
+ },
6893
+ /** CSRF token — the only cookie here the browser can read */
6894
+ get CSRF() {
6895
+ return `spfn_csrf${getCookieSuffix()}`;
6896
+ }
6897
+ };
6898
+ globalConfig = {
6899
+ sessionTtl: "7d"
6900
+ // Default: 7 days
6901
+ };
6902
+ CSRF_MODES = ["off", "warn", "enforce"];
6903
+ unrecognizedCsrfModeReported = false;
6904
+ PACKAGE_CSRF_EXEMPT_PATHS = [
6905
+ "/_auth/oauth2/register",
6906
+ "/_auth/oauth2/token",
6907
+ "/_auth/oauth2/revoke"
6908
+ ];
6909
+ DEFAULT_SESSION_RENEW_PATH = "/auth/renew";
6910
+ PASSKEY_USER_VERIFICATIONS = ["preferred", "required"];
6911
+ DEFAULT_CHALLENGE_TTL_SECONDS = 300;
6912
+ DEFAULT_RECENT_AUTH_MINUTES = 10;
6913
+ PASSKEY_VARS = [
6914
+ "SPFN_AUTH_PASSKEY_RP_ID",
6915
+ "SPFN_AUTH_PASSKEY_RP_NAME",
6916
+ "SPFN_AUTH_PASSKEY_ORIGINS",
6917
+ "SPFN_AUTH_PASSKEY_USER_VERIFICATION",
6918
+ "SPFN_AUTH_PASSKEY_CHALLENGE_TTL_SECONDS",
6919
+ "SPFN_AUTH_PASSKEY_RECENT_AUTH_MINUTES"
6920
+ ];
6921
+ DEFAULT_MFA_ISSUER = "SPFN";
6922
+ DEFAULT_STEP_UP_MINUTES = 10;
6923
+ }
6924
+ });
6925
+
6526
6926
  // src/server/repositories/keys.repository.ts
6527
6927
  import { BaseRepository as BaseRepository2 } from "@spfn/core/db";
6528
6928
  import { eq as eq2, and as and2, or, isNull, lt, desc, sql as sql5 } from "drizzle-orm";
@@ -6532,6 +6932,7 @@ var init_keys_repository = __esm({
6532
6932
  "use strict";
6533
6933
  init_user_public_keys();
6534
6934
  init_users();
6935
+ init_config();
6535
6936
  LAST_USED_THROTTLE_MS = 6e4;
6536
6937
  KeysRepository = class extends BaseRepository2 {
6537
6938
  /**
@@ -6574,6 +6975,10 @@ var init_keys_repository = __esm({
6574
6975
  *
6575
6976
  * `includeRevoked`는 이미 끊은 기기까지 보여준다 — "내가 언제 무엇을 끊었나"를
6576
6977
  * 확인하는 용도라, 폐기 시각과 사유를 함께 고른다.
6978
+ *
6979
+ * `lastSeenIp` is not selected, deliberately. The concurrent-use moment is
6980
+ * what the owner acts on; the trail of addresses behind it is stored PII the
6981
+ * account surface has no use for.
6577
6982
  * Read replica 사용
6578
6983
  */
6579
6984
  async listForUser(userId, includeRevoked = false) {
@@ -6589,7 +6994,9 @@ var init_keys_repository = __esm({
6589
6994
  expiresAt: userPublicKeys.expiresAt,
6590
6995
  revokedAt: userPublicKeys.revokedAt,
6591
6996
  registeredIp: userPublicKeys.registeredIp,
6592
- registeredUserAgent: userPublicKeys.registeredUserAgent
6997
+ registeredUserAgent: userPublicKeys.registeredUserAgent,
6998
+ binding: userPublicKeys.binding,
6999
+ concurrentUseAt: userPublicKeys.concurrentUseAt
6593
7000
  }).from(userPublicKeys).where(
6594
7001
  includeRevoked ? eq2(userPublicKeys.userId, userId) : and2(
6595
7002
  eq2(userPublicKeys.userId, userId),
@@ -6737,19 +7144,55 @@ var init_keys_repository = __esm({
6737
7144
  return result[0] ?? null;
6738
7145
  }
6739
7146
  /**
6740
- * Key ID로 공개키 조회 — 활성 여부 무관 (clientProofV1 admission의 revocation 판정용)
7147
+ * Bind one live key to the account's passkey, and give it the short life
7148
+ * that goes with it.
6741
7149
  *
6742
- * 폐기(isActive=false)·만료(expiresAt 경과)를 SESSION_REVOKED로, 미등록을
6743
- * PROOF_INVALID로 구분해야 하므로 활성 필터 없이 조회한다. keyId는 UNIQUE.
6744
- * Read replica 사용
7150
+ * Scoped by user and by `isActive`, like every other targeted update here, so
7151
+ * the answer is "this call bound something" rather than "a row exists".
7152
+ * Write primary 사용
6745
7153
  */
6746
- async findByKeyId(keyId) {
6747
- const result = await this.readDb.select().from(userPublicKeys).where(eq2(userPublicKeys.keyId, keyId)).limit(1);
6748
- return result[0] ?? null;
6749
- }
6750
- /**
6751
- * Key ID로 활성 공개키 조회 (authenticate용)
6752
- * Read replica 사용
7154
+ async bindByKeyIdAndUserId(keyId, userId, expiresAt) {
7155
+ const result = await this.db.update(userPublicKeys).set({ binding: "passkey", expiresAt }).where(
7156
+ and2(
7157
+ eq2(userPublicKeys.keyId, keyId),
7158
+ eq2(userPublicKeys.userId, userId),
7159
+ eq2(userPublicKeys.isActive, true)
7160
+ )
7161
+ ).returning();
7162
+ return result[0] ?? null;
7163
+ }
7164
+ /**
7165
+ * Return every one of a user's bound keys to an ordinary long-lived key.
7166
+ *
7167
+ * One statement, because turning the setting off has to leave no key behind:
7168
+ * a row still marked `'passkey'` would keep expiring in hours with nothing
7169
+ * left to renew it, and its owner has just said they do not want that.
7170
+ * Write primary 사용
7171
+ */
7172
+ async unbindActiveByUserId(userId, expiresAt) {
7173
+ const result = await this.db.update(userPublicKeys).set({ binding: "none", expiresAt }).where(
7174
+ and2(
7175
+ eq2(userPublicKeys.userId, userId),
7176
+ eq2(userPublicKeys.isActive, true),
7177
+ eq2(userPublicKeys.binding, "passkey")
7178
+ )
7179
+ ).returning();
7180
+ return result.length;
7181
+ }
7182
+ /**
7183
+ * Key ID로 공개키 조회 — 활성 여부 무관 (clientProofV1 admission의 revocation 판정용)
7184
+ *
7185
+ * 폐기(isActive=false)·만료(expiresAt 경과)를 SESSION_REVOKED로, 미등록을
7186
+ * PROOF_INVALID로 구분해야 하므로 활성 필터 없이 조회한다. keyId는 UNIQUE.
7187
+ * Read replica 사용
7188
+ */
7189
+ async findByKeyId(keyId) {
7190
+ const result = await this.readDb.select().from(userPublicKeys).where(eq2(userPublicKeys.keyId, keyId)).limit(1);
7191
+ return result[0] ?? null;
7192
+ }
7193
+ /**
7194
+ * Key ID로 활성 공개키 조회 (authenticate용)
7195
+ * Read replica 사용
6753
7196
  */
6754
7197
  async findActiveByKeyId(keyId) {
6755
7198
  const result = await this.readDb.select().from(userPublicKeys).where(
@@ -6779,35 +7222,75 @@ var init_keys_repository = __esm({
6779
7222
  * `clientSeenAt` moves only when one of the three values differs from what is
6780
7223
  * stored, so it answers "since when has this device been on this release"
6781
7224
  * rather than "when was it last seen", which lastUsedAt already answers.
7225
+ *
7226
+ * `ip` is the client address this request resolved to, or null when none did.
7227
+ * It joins this statement rather than getting one of its own: the rule the
7228
+ * concurrent-use signal needs — write the address, and move
7229
+ * `concurrentUseAt` when it differs from the stored one inside the window —
7230
+ * is the rule already implemented here, and a second fire-and-forget write
7231
+ * beside it would double the per-request write on the authenticated path. It
7232
+ * is also why the comparison is a `CASE` over the stored value rather than a
7233
+ * read followed by a write: the row comes off the read replica, and a
7234
+ * replica-lagged address compared in application code both misses real
7235
+ * switches and invents ones that did not happen.
6782
7236
  */
6783
- async updateLastUsedById(id25, identity) {
7237
+ async updateLastUsedById(id25, identity, ip) {
7238
+ const now = /* @__PURE__ */ new Date();
7239
+ const nowParam = sql5`${now.toISOString()}::timestamptz`;
6784
7240
  const staleBefore = new Date(Date.now() - LAST_USED_THROTTLE_MS);
6785
7241
  const lastUsedIsStale = or(
6786
7242
  isNull(userPublicKeys.lastUsedAt),
6787
7243
  lt(userPublicKeys.lastUsedAt, staleBefore)
6788
7244
  );
6789
- if (!identity) {
6790
- await this.db.update(userPublicKeys).set({ lastUsedAt: /* @__PURE__ */ new Date() }).where(and2(eq2(userPublicKeys.id, id25), lastUsedIsStale));
6791
- return;
6792
- }
6793
- const identityChanged = sql5`(
6794
- ${userPublicKeys.clientKind} IS DISTINCT FROM ${identity.kind}
6795
- OR ${userPublicKeys.clientVersion} IS DISTINCT FROM ${identity.version}
6796
- OR ${userPublicKeys.clientContractVersion} IS DISTINCT FROM ${identity.contractVersion}
7245
+ const ipChanged = sql5`(
7246
+ ${ip ?? null}::text IS NOT NULL
7247
+ AND ${userPublicKeys.lastSeenIp} IS DISTINCT FROM ${ip ?? null}::text
6797
7248
  )`;
6798
- const now = /* @__PURE__ */ new Date();
6799
- const nowParam = sql5`${now.toISOString()}::timestamptz`;
7249
+ const notStampedThisWindow = sql5`(
7250
+ ${userPublicKeys.concurrentUseAt} IS NULL
7251
+ OR ${userPublicKeys.concurrentUseAt} < ${this.concurrentUseSince(now)}
7252
+ )`;
7253
+ const identityChanged = identity ? sql5`(
7254
+ ${userPublicKeys.clientKind} IS DISTINCT FROM ${identity.kind}
7255
+ OR ${userPublicKeys.clientVersion} IS DISTINCT FROM ${identity.version}
7256
+ OR ${userPublicKeys.clientContractVersion} IS DISTINCT FROM ${identity.contractVersion}
7257
+ )` : sql5`false`;
6800
7258
  await this.db.update(userPublicKeys).set({
6801
7259
  lastUsedAt: now,
6802
- clientKind: identity.kind,
6803
- clientVersion: identity.version,
6804
- clientContractVersion: identity.contractVersion,
6805
- clientSeenAt: sql5`CASE WHEN ${identityChanged} THEN ${nowParam} ELSE ${userPublicKeys.clientSeenAt} END`
7260
+ lastSeenIp: ip ?? null,
7261
+ lastSeenAt: now,
7262
+ // `last_seen_ip IS NOT NULL` is the second observation this needs:
7263
+ // a request whose address did not resolve stores NULL and stamps
7264
+ // `last_seen_at`, so without it the next ordinary request would
7265
+ // read as an address change and raise the signal from one device
7266
+ // that never moved.
7267
+ concurrentUseAt: sql5`CASE WHEN ${ipChanged}
7268
+ AND ${userPublicKeys.lastSeenIp} IS NOT NULL
7269
+ AND ${userPublicKeys.lastSeenAt} > ${this.concurrentUseSince(now)}
7270
+ THEN ${nowParam} ELSE ${userPublicKeys.concurrentUseAt} END`,
7271
+ ...identity ? {
7272
+ clientKind: identity.kind,
7273
+ clientVersion: identity.version,
7274
+ clientContractVersion: identity.contractVersion,
7275
+ clientSeenAt: sql5`CASE WHEN ${identityChanged} THEN ${nowParam} ELSE ${userPublicKeys.clientSeenAt} END`
7276
+ } : {}
6806
7277
  }).where(and2(
6807
7278
  eq2(userPublicKeys.id, id25),
6808
- or(lastUsedIsStale, identityChanged)
7279
+ or(lastUsedIsStale, identityChanged, sql5`(${ipChanged} AND ${notStampedThisWindow})`)
6809
7280
  ));
6810
7281
  }
7282
+ /**
7283
+ * The boundary a previous sighting has to be newer than to count as concurrent.
7284
+ *
7285
+ * Computed here rather than written as `now() - interval` so that the moment
7286
+ * the row is stamped with and the moment the window is measured from are the
7287
+ * same one — a statement that used the database clock for the boundary and
7288
+ * ours for the value would disagree with itself by the round trip.
7289
+ */
7290
+ concurrentUseSince(now) {
7291
+ const since = new Date(now.getTime() - getConcurrentUseWindowMs());
7292
+ return sql5`${since.toISOString()}::timestamptz`;
7293
+ }
6811
7294
  };
6812
7295
  keysRepository = new KeysRepository();
6813
7296
  }
@@ -8884,6 +9367,43 @@ var init_schema5 = __esm({
8884
9367
  })
8885
9368
  },
8886
9369
  // ============================================================================
9370
+ // Session binding (#97)
9371
+ // ============================================================================
9372
+ SPFN_AUTH_BOUND_KEY_TTL_HOURS: {
9373
+ ...envNumber({
9374
+ description: "How long a session key bound to a passkey lives. This is the window in which a copied session cookie is still indistinguishable from the original, so it is hours rather than days; past it the browser runs one WebAuthn ceremony and gets a new key. Only applies to accounts that turned session binding on.",
9375
+ default: 24,
9376
+ required: false,
9377
+ examples: [8, 24, 72]
9378
+ })
9379
+ },
9380
+ SPFN_AUTH_BOUND_KEY_RENEW_GRACE_HOURS: {
9381
+ ...envNumber({
9382
+ description: "How long after a bound key expires a passkey renewal is still offered. Past it the account signs in again. Open-ended grace would make an expired key a long-lived key with extra steps.",
9383
+ default: 168,
9384
+ required: false,
9385
+ examples: [24, 168, 720]
9386
+ })
9387
+ },
9388
+ SPFN_AUTH_CONCURRENT_USE_WINDOW_MS: {
9389
+ ...envNumber({
9390
+ description: "How far apart two sightings of one device key from two client addresses still count as concurrent use, surfaced as `concurrentUseAtMillis` on the key list. A signal for the owner to read, never a refusal \u2014 addresses change legitimately. Meaningful only where proxy-guard is configured, since without it every web request carries the Next.js server's address.",
9391
+ default: 3e5,
9392
+ required: false,
9393
+ examples: [6e4, 3e5, 9e5]
9394
+ })
9395
+ },
9396
+ SPFN_AUTH_SESSION_RENEW_PATH: {
9397
+ ...envString({
9398
+ description: "Page in your app that runs the renewal ceremony. `RequireAuth` redirects a bound session whose key expired here instead of to the sign-in page; the page calls `renewSession(api)` and returns the user to where they were. Override per guard with the `renewalPath` prop.",
9399
+ default: "/auth/renew",
9400
+ required: false,
9401
+ nextjs: true,
9402
+ // Read by RequireAuth, which renders in the Next.js runtime
9403
+ examples: ["/auth/renew", "/session/renew"]
9404
+ })
9405
+ },
9406
+ // ============================================================================
8887
9407
  // API Configuration
8888
9408
  // ============================================================================
8889
9409
  SPFN_API_URL: {
@@ -9142,21 +9662,21 @@ var init_schema5 = __esm({
9142
9662
 
9143
9663
  // src/config/index.ts
9144
9664
  import { createEnvRegistry } from "@spfn/core/env";
9145
- var registry, env3;
9146
- var init_config = __esm({
9665
+ var registry, env4;
9666
+ var init_config2 = __esm({
9147
9667
  "src/config/index.ts"() {
9148
9668
  "use strict";
9149
9669
  init_schema5();
9150
9670
  init_schema5();
9151
9671
  registry = createEnvRegistry(authEnvSchema);
9152
- env3 = registry.validate();
9672
+ env4 = registry.validate();
9153
9673
  }
9154
9674
  });
9155
9675
 
9156
9676
  // src/server/lib/oauth/token-cipher.ts
9157
9677
  import crypto3 from "crypto";
9158
9678
  function getEncryptionKeyring() {
9159
- const raw = env3.SPFN_AUTH_TOKEN_ENCRYPTION_KEYS;
9679
+ const raw = env4.SPFN_AUTH_TOKEN_ENCRYPTION_KEYS;
9160
9680
  if (!raw) {
9161
9681
  return [];
9162
9682
  }
@@ -9226,7 +9746,7 @@ function decryptAesGcm(payload, encoding, key, aad) {
9226
9746
  return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString("utf8");
9227
9747
  }
9228
9748
  function getLegacyV1Key() {
9229
- return crypto3.createHash("sha256").update(`social-token:${env3.SPFN_AUTH_SESSION_SECRET}`).digest();
9749
+ return crypto3.createHash("sha256").update(`social-token:${env4.SPFN_AUTH_SESSION_SECRET}`).digest();
9230
9750
  }
9231
9751
  function configureOAuthTokenCipher(cipher) {
9232
9752
  configuredCipher = cipher;
@@ -9244,7 +9764,7 @@ var V1_PREFIX, V2_PREFIX, ENCRYPTED_PREFIX, IV_BYTES, TAG_BYTES, KEY_BYTES, KEY_
9244
9764
  var init_token_cipher = __esm({
9245
9765
  "src/server/lib/oauth/token-cipher.ts"() {
9246
9766
  "use strict";
9247
- init_config();
9767
+ init_config2();
9248
9768
  V1_PREFIX = "enc:v1:";
9249
9769
  V2_PREFIX = "enc:v2:";
9250
9770
  ENCRYPTED_PREFIX = "enc:";
@@ -10344,7 +10864,7 @@ function getKeyId(c) {
10344
10864
  // src/server/routes/auth/index.ts
10345
10865
  init_types();
10346
10866
  import { KeyNotFoundError } from "@spfn/auth/errors";
10347
- import { ValidationError as ValidationError14 } from "@spfn/core/errors";
10867
+ import { ValidationError as ValidationError15 } from "@spfn/core/errors";
10348
10868
 
10349
10869
  // src/server/services/auth.service.ts
10350
10870
  init_repositories();
@@ -10361,28 +10881,8 @@ import {
10361
10881
 
10362
10882
  // src/server/services/oauth2-grant.service.ts
10363
10883
  init_oauth2_grants_repository();
10884
+ init_logger();
10364
10885
  import { OAuth2GrantNotFoundError } from "@spfn/auth/errors";
10365
-
10366
- // src/server/logger.ts
10367
- import { logger as rootLogger } from "@spfn/core/logger";
10368
- var authLogger = {
10369
- plugin: rootLogger.child("@spfn/auth:plugin"),
10370
- middleware: rootLogger.child("@spfn/auth:middleware"),
10371
- interceptor: {
10372
- general: rootLogger.child("@spfn/auth:interceptor:general"),
10373
- login: rootLogger.child("@spfn/auth:interceptor:login"),
10374
- keyRotation: rootLogger.child("@spfn/auth:interceptor:key-rotation"),
10375
- oauth: rootLogger.child("@spfn/auth:interceptor:oauth"),
10376
- csrf: rootLogger.child("@spfn/auth:interceptor:csrf")
10377
- },
10378
- session: rootLogger.child("@spfn/auth:session"),
10379
- service: rootLogger.child("@spfn/auth:service"),
10380
- setup: rootLogger.child("@spfn/auth:setup"),
10381
- email: rootLogger.child("@spfn/auth:email"),
10382
- sms: rootLogger.child("@spfn/auth:sms")
10383
- };
10384
-
10385
- // src/server/services/oauth2-grant.service.ts
10386
10886
  async function listOAuth2GrantsService(userId) {
10387
10887
  const rows = await oauth2GrantsRepository.listActiveByUserId(userId);
10388
10888
  return rows.map(({ grant, client }) => ({
@@ -10395,299 +10895,41 @@ async function listOAuth2GrantsService(userId) {
10395
10895
  lastUsedAtMillis: client.lastUsedAt?.getTime()
10396
10896
  }));
10397
10897
  }
10398
- async function revokeOAuth2GrantService(id25, userId) {
10399
- const revoked = await oauth2GrantsRepository.revokeByIdForUser(id25, userId);
10400
- if (!revoked) {
10401
- throw new OAuth2GrantNotFoundError();
10402
- }
10403
- await oauth2GrantsRepository.revokeTokensOfGrants([revoked.id]);
10404
- }
10405
- async function revokeAllOAuth2GrantsForUser(userId) {
10406
- const grantIds = await oauth2GrantsRepository.revokeAllActiveByUserId(userId);
10407
- if (grantIds.length === 0) {
10408
- return;
10409
- }
10410
- const tokens = await oauth2GrantsRepository.revokeTokensOfGrants(grantIds);
10411
- authLogger.service.info("OAuth2 grants revoked for a user", {
10412
- userId,
10413
- grants: grantIds.length,
10414
- tokens
10415
- });
10416
- }
10417
-
10418
- // src/server/lib/config.ts
10419
- init_email();
10420
- import { env as env4 } from "@spfn/auth/config";
10421
- import { PasskeyConfigError } from "@spfn/auth/errors";
10422
- function getCookieSuffix() {
10423
- const port = process.env.SPFN_PORT;
10424
- return port ? `_${port}` : "";
10425
- }
10426
- var COOKIE_NAMES = {
10427
- /** Encrypted session data (userId, privateKey, keyId, algorithm) */
10428
- get SESSION() {
10429
- return `spfn_session${getCookieSuffix()}`;
10430
- },
10431
- /** Current key ID (for key rotation) */
10432
- get SESSION_KEY_ID() {
10433
- return `spfn_session_key_id${getCookieSuffix()}`;
10434
- },
10435
- /** Pending OAuth session (privateKey, keyId, algorithm) - temporary during OAuth flow */
10436
- get OAUTH_PENDING() {
10437
- return `spfn_oauth_pending${getCookieSuffix()}`;
10438
- },
10439
- /** OAuth CSRF nonce — double-submit against the (encrypted) state.nonce at callback */
10440
- get OAUTH_CSRF() {
10441
- return `spfn_oauth_csrf${getCookieSuffix()}`;
10442
- },
10443
- /** Password-setup session for verified-email signup — temporary, single-purpose */
10444
- get SIGNUP_SETUP() {
10445
- return `spfn_signup_setup${getCookieSuffix()}`;
10446
- },
10447
- /** Password-setup session for a password reset — temporary, single-purpose */
10448
- get PASSWORD_RESET_SETUP() {
10449
- return `spfn_password_reset_setup${getCookieSuffix()}`;
10450
- },
10451
- /** CSRF token — the only cookie here the browser can read */
10452
- get CSRF() {
10453
- return `spfn_csrf${getCookieSuffix()}`;
10454
- }
10455
- };
10456
- function matchOAuthCsrfCookies(cookies) {
10457
- return Object.entries(cookies).filter(([name]) => /^spfn_oauth_csrf(_\d+)?$/.test(name)).map(([name, value]) => ({ name, value }));
10458
- }
10459
- function parseDuration(duration) {
10460
- if (typeof duration === "number") {
10461
- return duration;
10462
- }
10463
- const match = duration.match(/^(\d+)([dhms]?)$/);
10464
- if (!match) {
10465
- throw new Error(`Invalid duration format: ${duration}. Use format like '30d', '12h', '45m', '3600s', or plain number.`);
10466
- }
10467
- const value = parseInt(match[1], 10);
10468
- const unit = match[2] || "s";
10469
- switch (unit) {
10470
- case "d":
10471
- return value * 24 * 60 * 60;
10472
- case "h":
10473
- return value * 60 * 60;
10474
- case "m":
10475
- return value * 60;
10476
- case "s":
10477
- return value;
10478
- default:
10479
- throw new Error(`Unknown duration unit: ${unit}`);
10480
- }
10481
- }
10482
- var globalConfig = {
10483
- sessionTtl: "7d"
10484
- // Default: 7 days
10485
- };
10486
- function configureAuth(config4) {
10487
- globalConfig = {
10488
- ...globalConfig,
10489
- ...config4
10490
- };
10491
- }
10492
- function getAuthConfig() {
10493
- return { ...globalConfig };
10494
- }
10495
- async function runBeforeRegister(context) {
10496
- const { beforeRegister } = globalConfig;
10497
- if (beforeRegister) {
10498
- await beforeRegister({ ...context, email: normalizeOptionalEmail(context.email) });
10499
- }
10500
- }
10501
- function getSessionTtl(override) {
10502
- if (override !== void 0) {
10503
- return parseDuration(override);
10504
- }
10505
- if (globalConfig.sessionTtl !== void 0) {
10506
- return parseDuration(globalConfig.sessionTtl);
10507
- }
10508
- const envTtl = env4.SPFN_AUTH_SESSION_TTL;
10509
- if (envTtl) {
10510
- return parseDuration(envTtl);
10511
- }
10512
- return 7 * 24 * 60 * 60;
10513
- }
10514
- var CSRF_MODES = ["off", "warn", "enforce"];
10515
- var unrecognizedCsrfModeReported = false;
10516
- function getCsrfMode() {
10517
- const configured2 = globalConfig.csrf?.mode ?? env4.SPFN_AUTH_CSRF;
10518
- if (!configured2) {
10519
- return "warn";
10520
- }
10521
- const normalized = String(configured2).trim().toLowerCase();
10522
- if (!CSRF_MODES.includes(normalized)) {
10523
- if (!unrecognizedCsrfModeReported) {
10524
- unrecognizedCsrfModeReported = true;
10525
- authLogger.interceptor.csrf.error(
10526
- `Unrecognized CSRF mode "${configured2}" \u2014 expected off | warn | enforce. Enforcing.`
10527
- );
10528
- }
10529
- return "enforce";
10530
- }
10531
- return normalized;
10532
- }
10533
- var PACKAGE_CSRF_EXEMPT_PATHS = [
10534
- "/_auth/oauth2/register",
10535
- "/_auth/oauth2/token",
10536
- "/_auth/oauth2/revoke"
10537
- ];
10538
- function getCsrfExemptPaths() {
10539
- return [...PACKAGE_CSRF_EXEMPT_PATHS, ...globalConfig.csrf?.exemptPaths ?? []];
10540
- }
10541
- var PASSKEY_USER_VERIFICATIONS = ["preferred", "required"];
10542
- var DEFAULT_CHALLENGE_TTL_SECONDS = 300;
10543
- var DEFAULT_RECENT_AUTH_MINUTES = 10;
10544
- function passkeyEnvSource() {
10545
- return { ...process.env, SPFN_APP_URL: process.env.SPFN_APP_URL || env4.SPFN_APP_URL };
10546
- }
10547
- function passkeyAppUrl(env21) {
10548
- const configured2 = env21.NEXT_PUBLIC_SPFN_APP_URL || env21.SPFN_APP_URL;
10549
- if (!configured2) {
10550
- throw new PasskeyConfigError({
10551
- message: "Passkeys need a relying party ID. Set SPFN_AUTH_PASSKEY_RP_ID, or set NEXT_PUBLIC_SPFN_APP_URL / SPFN_APP_URL to the app origin it should be derived from."
10552
- });
10553
- }
10554
- try {
10555
- return new URL(configured2);
10556
- } catch {
10557
- throw new PasskeyConfigError({
10558
- message: `Passkeys cannot derive a relying party ID: "${configured2}" is not a URL. Fix NEXT_PUBLIC_SPFN_APP_URL / SPFN_APP_URL, or set SPFN_AUTH_PASSKEY_RP_ID explicitly.`
10559
- });
10560
- }
10561
- }
10562
- function isLocalhost(hostname) {
10563
- return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]";
10564
- }
10565
- function isUnderRpId(hostname, rpId) {
10566
- return hostname === rpId || hostname.endsWith(`.${rpId}`);
10567
- }
10568
- function assertOriginServesRpId(origin, rpId) {
10569
- let url;
10570
- try {
10571
- url = new URL(origin);
10572
- } catch {
10573
- throw new PasskeyConfigError({
10574
- message: `SPFN_AUTH_PASSKEY_ORIGINS contains "${origin}", which is not a URL. List full origins, e.g. https://app.example.com.`
10575
- });
10576
- }
10577
- if (url.protocol !== "https:" && !isLocalhost(url.hostname)) {
10578
- throw new PasskeyConfigError({
10579
- message: `Passkey origin "${origin}" is not https. WebAuthn runs only in a secure context, and localhost is the only host a browser treats as one over plain http.`
10580
- });
10581
- }
10582
- if (!isUnderRpId(url.hostname, rpId)) {
10583
- throw new PasskeyConfigError({
10584
- message: `Passkey origin "${origin}" is not on relying party ID "${rpId}". Each origin must be that host or a subdomain of it, or the browser refuses the ceremony.`
10585
- });
10586
- }
10587
- }
10588
- function resolveUserVerification(env21) {
10589
- const configured2 = env21.SPFN_AUTH_PASSKEY_USER_VERIFICATION;
10590
- if (!configured2) {
10591
- return "preferred";
10592
- }
10593
- const normalized = configured2.trim().toLowerCase();
10594
- if (!PASSKEY_USER_VERIFICATIONS.includes(normalized)) {
10595
- throw new PasskeyConfigError({
10596
- message: `SPFN_AUTH_PASSKEY_USER_VERIFICATION is "${configured2}" \u2014 expected preferred or required. A passkey is the whole credential here, so an assertion that skipped user verification would sign someone in on an unlocked device alone.`
10597
- });
10598
- }
10599
- return normalized;
10600
- }
10601
- function resolvePositiveNumber(env21, variable, fallback) {
10602
- const configured2 = env21[variable];
10603
- if (!configured2) {
10604
- return fallback;
10605
- }
10606
- const parsed = Number(configured2);
10607
- if (!Number.isFinite(parsed) || parsed <= 0) {
10608
- throw new PasskeyConfigError({
10609
- message: `${variable} is "${configured2}" \u2014 expected a positive number.`
10610
- });
10611
- }
10612
- return parsed;
10613
- }
10614
- function getPasskeyConfig(env21 = passkeyEnvSource()) {
10615
- const rpId = env21.SPFN_AUTH_PASSKEY_RP_ID?.trim() || passkeyAppUrl(env21).hostname;
10616
- const configuredOrigins = env21.SPFN_AUTH_PASSKEY_ORIGINS?.split(",").map((origin) => origin.trim()).filter(Boolean);
10617
- const origins = configuredOrigins?.length ? configuredOrigins : [passkeyAppUrl(env21).origin];
10618
- for (const origin of origins) {
10619
- assertOriginServesRpId(origin, rpId);
10620
- }
10621
- return {
10622
- rpId,
10623
- rpName: env21.SPFN_AUTH_PASSKEY_RP_NAME?.trim() || rpId,
10624
- origins,
10625
- userVerification: resolveUserVerification(env21),
10626
- challengeTtlMs: resolvePositiveNumber(
10627
- env21,
10628
- "SPFN_AUTH_PASSKEY_CHALLENGE_TTL_SECONDS",
10629
- DEFAULT_CHALLENGE_TTL_SECONDS
10630
- ) * 1e3,
10631
- recentAuthMs: resolvePositiveNumber(
10632
- env21,
10633
- "SPFN_AUTH_PASSKEY_RECENT_AUTH_MINUTES",
10634
- DEFAULT_RECENT_AUTH_MINUTES
10635
- ) * 6e4
10636
- };
10637
- }
10638
- var PASSKEY_VARS = [
10639
- "SPFN_AUTH_PASSKEY_RP_ID",
10640
- "SPFN_AUTH_PASSKEY_RP_NAME",
10641
- "SPFN_AUTH_PASSKEY_ORIGINS",
10642
- "SPFN_AUTH_PASSKEY_USER_VERIFICATION",
10643
- "SPFN_AUTH_PASSKEY_CHALLENGE_TTL_SECONDS",
10644
- "SPFN_AUTH_PASSKEY_RECENT_AUTH_MINUTES"
10645
- ];
10646
- function assertPasskeyConfig(env21 = passkeyEnvSource()) {
10647
- if (PASSKEY_VARS.some((variable) => env21[variable])) {
10648
- getPasskeyConfig(env21);
10649
- return;
10650
- }
10651
- try {
10652
- getPasskeyConfig(env21);
10653
- } catch (error) {
10654
- authLogger.service.info(
10655
- `Passkeys cannot be served with the configuration derived from the app URL, and no SPFN_AUTH_PASSKEY_* variable is set, so boot continues. ${error.message}`
10656
- );
10657
- }
10658
- }
10659
- var DEFAULT_MFA_ISSUER = "SPFN";
10660
- var DEFAULT_STEP_UP_MINUTES = 10;
10661
- function getMfaConfig() {
10662
- const configuredMinutes = Number(process.env.SPFN_AUTH_MFA_STEP_UP_MINUTES);
10663
- const minutes = Number.isFinite(configuredMinutes) && configuredMinutes > 0 ? configuredMinutes : DEFAULT_STEP_UP_MINUTES;
10664
- return {
10665
- issuer: process.env.SPFN_AUTH_MFA_ISSUER?.trim() || process.env.SPFN_AUTH_PASSKEY_RP_NAME?.trim() || mfaIssuerFromAppUrl() || DEFAULT_MFA_ISSUER,
10666
- stepUpWindowMs: minutes * 6e4
10667
- };
10668
- }
10669
- function mfaIssuerFromAppUrl() {
10670
- const configured2 = process.env.NEXT_PUBLIC_SPFN_APP_URL || process.env.SPFN_APP_URL;
10671
- if (!configured2) {
10672
- return null;
10898
+ async function revokeOAuth2GrantService(id25, userId) {
10899
+ const revoked = await oauth2GrantsRepository.revokeByIdForUser(id25, userId);
10900
+ if (!revoked) {
10901
+ throw new OAuth2GrantNotFoundError();
10673
10902
  }
10674
- try {
10675
- return new URL(configured2).hostname;
10676
- } catch {
10677
- return null;
10903
+ await oauth2GrantsRepository.revokeTokensOfGrants([revoked.id]);
10904
+ }
10905
+ async function revokeAllOAuth2GrantsForUser(userId) {
10906
+ const grantIds = await oauth2GrantsRepository.revokeAllActiveByUserId(userId);
10907
+ if (grantIds.length === 0) {
10908
+ return;
10678
10909
  }
10910
+ const tokens = await oauth2GrantsRepository.revokeTokensOfGrants(grantIds);
10911
+ authLogger.service.info("OAuth2 grants revoked for a user", {
10912
+ userId,
10913
+ grants: grantIds.length,
10914
+ tokens
10915
+ });
10679
10916
  }
10680
10917
 
10918
+ // src/server/services/auth.service.ts
10919
+ init_config();
10920
+
10681
10921
  // src/server/services/verification.service.ts
10922
+ init_logger();
10923
+ init_email();
10924
+ init_repositories();
10682
10925
  import crypto5 from "crypto";
10683
10926
  import { env as env8 } from "@spfn/auth/config";
10684
10927
  import { InvalidVerificationCodeError } from "@spfn/auth/errors";
10685
10928
  import jwt2 from "jsonwebtoken";
10686
10929
  import { sendEmail as sendEmail2, sendSMS as sendSMS2 } from "@spfn/notification/server";
10687
- init_email();
10688
- init_repositories();
10689
10930
 
10690
10931
  // src/server/lib/link-mail-delivery.ts
10932
+ init_logger();
10691
10933
  import { env as env7 } from "@spfn/auth/config";
10692
10934
  import { getBoss } from "@spfn/core/job";
10693
10935
 
@@ -10697,6 +10939,7 @@ init_schema3();
10697
10939
  import { job } from "@spfn/core/job";
10698
10940
 
10699
10941
  // src/server/services/link-mail.service.ts
10942
+ init_logger();
10700
10943
  import { env as env6 } from "@spfn/auth/config";
10701
10944
  import { sendEmail, sendSMS } from "@spfn/notification/server";
10702
10945
 
@@ -11019,8 +11262,23 @@ async function verifyCodeService(params) {
11019
11262
  };
11020
11263
  }
11021
11264
 
11022
- // src/server/lib/key-policy.ts
11023
- var KEY_TTL_DAYS = 90;
11265
+ // src/server/services/key.service.ts
11266
+ init_key_policy();
11267
+ init_config();
11268
+
11269
+ // src/server/lib/ua-family.ts
11270
+ var FAMILY_MARKERS = [
11271
+ { family: "edge", marker: /\bEdg(?:A|iOS)?\// },
11272
+ { family: "chrome", marker: /\b(?:Chrome|CriOS)\// },
11273
+ { family: "firefox", marker: /\b(?:Firefox|FxiOS)\// },
11274
+ { family: "safari", marker: /\bSafari\// }
11275
+ ];
11276
+ function uaFamily(userAgent) {
11277
+ if (!userAgent) {
11278
+ return "other";
11279
+ }
11280
+ return FAMILY_MARKERS.find((entry) => entry.marker.test(userAgent))?.family ?? "other";
11281
+ }
11024
11282
 
11025
11283
  // src/server/services/key.service.ts
11026
11284
  init_repositories();
@@ -11065,7 +11323,8 @@ var DeviceRegistrationChannelSchema = Type.Union([
11065
11323
  Type.Literal("oauth-native"),
11066
11324
  Type.Literal("device-code"),
11067
11325
  Type.Literal("password-reset"),
11068
- Type.Literal("passkey")
11326
+ Type.Literal("passkey"),
11327
+ Type.Literal("renewal")
11069
11328
  ]);
11070
11329
  var authDeviceRegisteredEvent = defineEvent(
11071
11330
  "auth.device.registered",
@@ -11175,6 +11434,8 @@ var authPasswordResetEvent = defineEvent(
11175
11434
  );
11176
11435
 
11177
11436
  // src/server/services/mfa.service.ts
11437
+ init_logger();
11438
+ init_config();
11178
11439
  import { onAfterCommit, runInTransaction as runInTransaction2 } from "@spfn/core/db";
11179
11440
  import { ValidationError } from "@spfn/core/errors";
11180
11441
  import {
@@ -11466,8 +11727,9 @@ init_repositories();
11466
11727
  init_mfa_totp();
11467
11728
 
11468
11729
  // src/server/services/webauthn-challenge.service.ts
11469
- import crypto9 from "crypto";
11730
+ init_config();
11470
11731
  init_repositories();
11732
+ import crypto9 from "crypto";
11471
11733
  var CHALLENGE_BYTES = 32;
11472
11734
  async function mintChallenge(kind, userId) {
11473
11735
  const challenge = crypto9.randomBytes(CHALLENGE_BYTES).toString("base64url");
@@ -11715,7 +11977,10 @@ async function emitDeviceRegistered(row, channel) {
11715
11977
  // src/server/services/key.service.ts
11716
11978
  var KEY_FINGERPRINT_PREFIX_LENGTH = 8;
11717
11979
  var DEFAULT_KEY_ALGORITHM = "ES256";
11718
- function getKeyExpiryDate() {
11980
+ function getKeyExpiryDate(binding) {
11981
+ if (binding === "passkey") {
11982
+ return new Date(Date.now() + getBoundKeyTtlMs());
11983
+ }
11719
11984
  const expiresAt = /* @__PURE__ */ new Date();
11720
11985
  expiresAt.setDate(expiresAt.getDate() + KEY_TTL_DAYS);
11721
11986
  return expiresAt;
@@ -11725,13 +11990,11 @@ function isExpired(expiresAt) {
11725
11990
  }
11726
11991
  async function registerPublicKeyService(params) {
11727
11992
  const { userId, keyId, publicKey, fingerprint, algorithm = DEFAULT_KEY_ALGORITHM, deviceName, platform } = params;
11993
+ const binding = params.binding ?? "none";
11728
11994
  const existing = await keysRepository.findByKeyId(keyId);
11729
11995
  if (existing) {
11730
11996
  if (existing.userId === userId && existing.isActive) {
11731
- if (isExpired(existing.expiresAt)) {
11732
- await keysRepository.extendExpiry(keyId, userId, getKeyExpiryDate());
11733
- }
11734
- return;
11997
+ return await reRegisterOwnActiveKey(existing, userId);
11735
11998
  }
11736
11999
  throw new KeyIdAlreadyRegisteredError();
11737
12000
  }
@@ -11748,16 +12011,27 @@ async function registerPublicKeyService(params) {
11748
12011
  fingerprint,
11749
12012
  deviceName,
11750
12013
  platform,
12014
+ binding,
11751
12015
  registeredIp: params.ip ?? null,
11752
12016
  registeredUserAgent: params.userAgent ?? null,
12017
+ registeredUaFamily: params.userAgent ? uaFamily(params.userAgent) : null,
11753
12018
  isActive: true,
11754
- expiresAt: getKeyExpiryDate()
12019
+ expiresAt: getKeyExpiryDate(binding)
11755
12020
  });
11756
- if (!params.replacesKeyId) {
12021
+ if (params.replacesKeyId) {
12022
+ await carryStepUpVerification(userId, params.replacesKeyId, keyId);
12023
+ } else {
11757
12024
  await emitDeviceRegistered(row, params.channel);
11758
- return;
11759
12025
  }
11760
- await carryStepUpVerification(userId, params.replacesKeyId, keyId);
12026
+ return { binding, expiresAt: row.expiresAt };
12027
+ }
12028
+ async function reRegisterOwnActiveKey(existing, userId) {
12029
+ if (existing.binding !== "passkey" && isExpired(existing.expiresAt)) {
12030
+ const extended = getKeyExpiryDate("none");
12031
+ await keysRepository.extendExpiry(existing.keyId, userId, extended);
12032
+ return { binding: "none", expiresAt: extended };
12033
+ }
12034
+ return { binding: existing.binding, expiresAt: existing.expiresAt };
11761
12035
  }
11762
12036
  async function rotateKeyService(params) {
11763
12037
  const { userId, oldKeyId, newKeyId, newPublicKey, fingerprint, algorithm = DEFAULT_KEY_ALGORITHM } = params;
@@ -11780,8 +12054,12 @@ async function rotateKeyService(params) {
11780
12054
  fingerprint,
11781
12055
  deviceName: params.deviceName ?? replaced?.deviceName ?? void 0,
11782
12056
  platform: params.platform ?? replaced?.platform ?? void 0,
12057
+ binding: replaced?.binding ?? "none",
12058
+ registeredIp: replaced?.registeredIp ?? null,
12059
+ registeredUserAgent: replaced?.registeredUserAgent ?? null,
12060
+ registeredUaFamily: replaced?.registeredUaFamily ?? null,
11783
12061
  isActive: true,
11784
- expiresAt: getKeyExpiryDate()
12062
+ expiresAt: replaced?.binding === "passkey" ? replaced.expiresAt : getKeyExpiryDate("none")
11785
12063
  });
11786
12064
  await carryStepUpVerification(userId, oldKeyId, newKeyId);
11787
12065
  return {
@@ -11809,7 +12087,9 @@ async function listKeysService(params) {
11809
12087
  isActive: row.isActive,
11810
12088
  revokedAtMillis: row.revokedAt?.getTime(),
11811
12089
  registeredIp: row.registeredIp ?? void 0,
11812
- registeredUserAgent: row.registeredUserAgent ?? void 0
12090
+ registeredUserAgent: row.registeredUserAgent ?? void 0,
12091
+ binding: row.binding === "passkey" ? row.binding : void 0,
12092
+ concurrentUseAtMillis: row.concurrentUseAt?.getTime()
11813
12093
  }));
11814
12094
  }
11815
12095
  async function revokeAllKeysService(params) {
@@ -11826,6 +12106,9 @@ async function revokeAllKeysService(params) {
11826
12106
  return { revokedCount: revoked.length, currentKeyRevoked: includeCurrent };
11827
12107
  }
11828
12108
 
12109
+ // src/server/services/auth.service.ts
12110
+ init_key_policy();
12111
+
11829
12112
  // src/server/services/user.service.ts
11830
12113
  init_repositories();
11831
12114
  import { ValidationError as ValidationError3 } from "@spfn/core/errors";
@@ -11940,6 +12223,7 @@ function getDeletionConfig() {
11940
12223
  }
11941
12224
 
11942
12225
  // src/server/services/account-deletion.service.ts
12226
+ init_logger();
11943
12227
  var POSTGRES_UNIQUE_VIOLATION = "23505";
11944
12228
  function isUniqueViolation(error) {
11945
12229
  return typeof error === "object" && error !== null && error.code === POSTGRES_UNIQUE_VIOLATION;
@@ -12224,6 +12508,12 @@ async function sweepDuePurges(now = /* @__PURE__ */ new Date()) {
12224
12508
  }
12225
12509
 
12226
12510
  // src/server/services/auth.service.ts
12511
+ function loginBindingFields(registered) {
12512
+ if (registered.binding !== "passkey" || !registered.expiresAt) {
12513
+ return {};
12514
+ }
12515
+ return { sessionBinding: "passkey", keyExpiresAtMillis: registered.expiresAt.getTime() };
12516
+ }
12227
12517
  async function registerService(params) {
12228
12518
  const { email, verificationToken } = params;
12229
12519
  const phone = params.phone?.trim();
@@ -12327,7 +12617,7 @@ async function loginService(params) {
12327
12617
  });
12328
12618
  replacesKeyId = revoked ? oldKeyId : void 0;
12329
12619
  }
12330
- await registerPublicKeyService({
12620
+ const registered = await registerPublicKeyService({
12331
12621
  userId: user.id,
12332
12622
  keyId,
12333
12623
  publicKey,
@@ -12338,6 +12628,7 @@ async function loginService(params) {
12338
12628
  channel: "password",
12339
12629
  ip: params.ip,
12340
12630
  userAgent: params.userAgent,
12631
+ binding: decideKeyBinding(user.sessionBinding, params.webProxy),
12341
12632
  replacesKeyId
12342
12633
  });
12343
12634
  await updateLastLoginService(user.id);
@@ -12346,7 +12637,8 @@ async function loginService(params) {
12346
12637
  publicId: user.publicId,
12347
12638
  email: user.email || void 0,
12348
12639
  phone: user.phone || void 0,
12349
- passwordChangeRequired: user.passwordChangeRequired
12640
+ passwordChangeRequired: user.passwordChangeRequired,
12641
+ ...loginBindingFields(registered)
12350
12642
  };
12351
12643
  await authLoginEvent.emit({
12352
12644
  userId: result.userId,
@@ -12395,9 +12687,10 @@ async function changePasswordService(params) {
12395
12687
  }
12396
12688
 
12397
12689
  // src/server/services/signup-link.service.ts
12690
+ init_logger();
12691
+ init_repositories();
12398
12692
  import { env as env10 } from "@spfn/auth/config";
12399
12693
  import { InvalidSignupLinkError, InvalidSignupSetupSessionError } from "@spfn/auth/errors";
12400
- init_repositories();
12401
12694
 
12402
12695
  // src/lib/return-path.ts
12403
12696
  var URL_STRIPPED_CHARACTER = /[\t\n\r]/;
@@ -12524,11 +12817,13 @@ async function completeSignupService(params) {
12524
12817
  }
12525
12818
 
12526
12819
  // src/server/services/password-reset.service.ts
12820
+ init_logger();
12821
+ init_repositories();
12527
12822
  import { env as env11 } from "@spfn/auth/config";
12528
12823
  import { PasswordResetLinkError, PasswordResetSessionError } from "@spfn/auth/errors";
12529
12824
  import { ValidationError as ValidationError6 } from "@spfn/core/errors";
12530
12825
  import { onAfterCommit as onAfterCommit4 } from "@spfn/core/db";
12531
- init_repositories();
12826
+ init_key_policy();
12532
12827
  async function activeUserOf(userId) {
12533
12828
  const user = await usersRepository.findByIdOnPrimary(userId);
12534
12829
  return user?.status === "active" ? user : null;
@@ -12591,7 +12886,7 @@ async function replaceCredentials(row, user, params) {
12591
12886
  await deviceAuthorizationsRepository.denyAllActiveByUserId(user.id);
12592
12887
  await revokeAllOAuth2GrantsForUser(user.id);
12593
12888
  await keysRepository.revokeAllActiveByUserId(user.id, "Revoked by password reset");
12594
- await registerPublicKeyService({
12889
+ const registered = await registerPublicKeyService({
12595
12890
  userId: user.id,
12596
12891
  keyId: params.keyId,
12597
12892
  publicKey: params.publicKey,
@@ -12601,13 +12896,15 @@ async function replaceCredentials(row, user, params) {
12601
12896
  platform: params.platform,
12602
12897
  channel: "password-reset",
12603
12898
  ip: params.ip,
12604
- userAgent: params.userAgent
12899
+ userAgent: params.userAgent,
12900
+ binding: decideKeyBinding(user.sessionBinding, params.webProxy)
12605
12901
  });
12606
12902
  await updateLastLoginService(user.id);
12607
12903
  if (!await passwordResetTokensRepository.complete(row.id)) {
12608
12904
  authLogger.service.warn("Password reset session refused", { reason: "lost the claim race" });
12609
12905
  throw new PasswordResetSessionError();
12610
12906
  }
12907
+ return loginBindingFields(registered);
12611
12908
  }
12612
12909
  async function completePasswordResetService(params) {
12613
12910
  if (!params.publicKey || !params.keyId || !params.fingerprint) {
@@ -12626,7 +12923,7 @@ async function completePasswordResetService(params) {
12626
12923
  authLogger.service.warn("Password reset session refused", { reason: "account is no longer active" });
12627
12924
  throw new PasswordResetSessionError();
12628
12925
  }
12629
- await replaceCredentials(row, user, params);
12926
+ const binding = await replaceCredentials(row, user, params);
12630
12927
  onAfterCommit4(() => authPasswordResetEvent.emit({
12631
12928
  userId: String(user.id),
12632
12929
  email: row.email
@@ -12635,11 +12932,13 @@ async function completePasswordResetService(params) {
12635
12932
  userId: String(user.id),
12636
12933
  publicId: user.publicId,
12637
12934
  email: user.email || void 0,
12638
- phone: user.phone || void 0
12935
+ phone: user.phone || void 0,
12936
+ ...binding
12639
12937
  };
12640
12938
  }
12641
12939
 
12642
12940
  // src/server/services/revoke-all-link.service.ts
12941
+ init_logger();
12643
12942
  import { env as env12 } from "@spfn/auth/config";
12644
12943
  import { NotFoundError as NotFoundError3, ValidationError as ValidationError7 } from "@spfn/core/errors";
12645
12944
  import { RevokeAllLinkError } from "@spfn/auth/errors";
@@ -12766,6 +13065,7 @@ function hashDeviceCode(deviceCode) {
12766
13065
  }
12767
13066
 
12768
13067
  // src/server/services/device-auth.service.ts
13068
+ init_key_policy();
12769
13069
  var USER_CODE_ATTEMPTS = 3;
12770
13070
  function assertActionable(record) {
12771
13071
  if (!record || record.status === "consumed") {
@@ -12904,7 +13204,7 @@ async function completeDeviceLogin(record, provenance) {
12904
13204
  }
12905
13205
  throw new AccountDisabledError2({ status: user.status });
12906
13206
  }
12907
- await registerPublicKeyService({
13207
+ const registered = await registerPublicKeyService({
12908
13208
  userId: user.id,
12909
13209
  keyId: record.keyId,
12910
13210
  publicKey: record.publicKey,
@@ -12914,7 +13214,8 @@ async function completeDeviceLogin(record, provenance) {
12914
13214
  platform: record.platform ?? void 0,
12915
13215
  channel: "device-code",
12916
13216
  ip: provenance.ip,
12917
- userAgent: provenance.userAgent
13217
+ userAgent: provenance.userAgent,
13218
+ binding: decideKeyBinding(user.sessionBinding, provenance.webProxy)
12918
13219
  });
12919
13220
  await updateLastLoginService(user.id);
12920
13221
  const result = {
@@ -12922,7 +13223,8 @@ async function completeDeviceLogin(record, provenance) {
12922
13223
  publicId: user.publicId,
12923
13224
  email: user.email || void 0,
12924
13225
  phone: user.phone || void 0,
12925
- passwordChangeRequired: user.passwordChangeRequired
13226
+ passwordChangeRequired: user.passwordChangeRequired,
13227
+ ...loginBindingFields(registered)
12926
13228
  };
12927
13229
  const mfaEnrolled = await mfaEnrolledForUser(Number(result.userId));
12928
13230
  onAfterCommit5(() => authLoginEvent.emit({
@@ -12936,6 +13238,8 @@ async function completeDeviceLogin(record, provenance) {
12936
13238
  }
12937
13239
 
12938
13240
  // src/server/services/passkey.service.ts
13241
+ init_logger();
13242
+ init_config();
12939
13243
  import { onAfterCommit as onAfterCommit6, runInTransaction as runInTransaction4 } from "@spfn/core/db";
12940
13244
  import { ValidationError as ValidationError8 } from "@spfn/core/errors";
12941
13245
  import {
@@ -12949,6 +13253,7 @@ import {
12949
13253
  RecentAuthenticationRequiredError
12950
13254
  } from "@spfn/auth/errors";
12951
13255
  init_repositories();
13256
+ init_key_policy();
12952
13257
  var NO_PASSWORD_PRESENTED = "spfn-passkey-no-password-presented";
12953
13258
  function toSummary(row) {
12954
13259
  return {
@@ -13066,6 +13371,42 @@ async function startPasskeyLoginService() {
13066
13371
  challenge: await mintChallenge("authentication", null)
13067
13372
  });
13068
13373
  }
13374
+ async function startRenewalCeremonyService(userId) {
13375
+ return await buildAuthenticationOptions({
13376
+ config: getPasskeyConfig(),
13377
+ challenge: await mintChallenge("renewal", userId)
13378
+ });
13379
+ }
13380
+ async function verifyRenewalAssertionService(userId, response) {
13381
+ const challenge = response?.response?.clientDataJSON ? presentedChallenge(response.response.clientDataJSON) : "";
13382
+ if (!await spendChallenge(challenge, "renewal", userId)) {
13383
+ authLogger.service.warn("Renewal challenge refused");
13384
+ return false;
13385
+ }
13386
+ return await assertionMatchesLivePasskey(userId, response, challenge);
13387
+ }
13388
+ async function assertionMatchesLivePasskey(userId, response, challenge) {
13389
+ const passkey = await passkeysRepository.findLiveByCredentialId(response.id);
13390
+ if (!passkey || passkey.userId !== userId) {
13391
+ return false;
13392
+ }
13393
+ const outcome = await verifyAuthentication({
13394
+ config: getPasskeyConfig(),
13395
+ response,
13396
+ expectedChallenge: challenge,
13397
+ credential: {
13398
+ credentialId: passkey.credentialId,
13399
+ publicKey: passkey.publicKey,
13400
+ counter: passkey.counter,
13401
+ transports: passkey.transports
13402
+ }
13403
+ });
13404
+ if (!outcome.verified) {
13405
+ return false;
13406
+ }
13407
+ await passkeysRepository.recordUse(passkey.id, outcome.newCounter);
13408
+ return true;
13409
+ }
13069
13410
  async function finishPasskeyLoginService(params) {
13070
13411
  assertDeviceKeyPresent(params);
13071
13412
  return runInTransaction4(async () => {
@@ -13090,7 +13431,7 @@ async function startSession(user, params) {
13090
13431
  });
13091
13432
  replacesKeyId = revoked ? params.oldKeyId : void 0;
13092
13433
  }
13093
- await registerPublicKeyService({
13434
+ const registered = await registerPublicKeyService({
13094
13435
  userId: user.id,
13095
13436
  keyId: params.keyId,
13096
13437
  publicKey: params.publicKey,
@@ -13101,6 +13442,7 @@ async function startSession(user, params) {
13101
13442
  channel: "passkey",
13102
13443
  ip: params.ip,
13103
13444
  userAgent: params.userAgent,
13445
+ binding: decideKeyBinding(user.sessionBinding, params.webProxy),
13104
13446
  replacesKeyId
13105
13447
  });
13106
13448
  await updateLastLoginService(user.id);
@@ -13109,7 +13451,8 @@ async function startSession(user, params) {
13109
13451
  publicId: user.publicId,
13110
13452
  email: user.email || void 0,
13111
13453
  phone: user.phone || void 0,
13112
- passwordChangeRequired: user.passwordChangeRequired
13454
+ passwordChangeRequired: user.passwordChangeRequired,
13455
+ ...loginBindingFields(registered)
13113
13456
  };
13114
13457
  const mfaEnrolled = await mfaEnrolledForUser(user.id);
13115
13458
  onAfterCommit6(() => authLoginEvent.emit({
@@ -13206,6 +13549,8 @@ async function revokePasskeyService(params) {
13206
13549
  // src/server/services/rbac.service.ts
13207
13550
  init_repositories();
13208
13551
  init_rbac();
13552
+ init_config();
13553
+ init_logger();
13209
13554
  import { createHash as createHash2 } from "crypto";
13210
13555
  var RBAC_HASH_KEY = "rbac_config_hash";
13211
13556
  function computeConfigHash(allRoles, allPermissions, allMappings) {
@@ -13345,6 +13690,7 @@ async function syncMappings(allMappings, rolesByName, permsByName) {
13345
13690
  }
13346
13691
 
13347
13692
  // src/server/services/email-normalization.service.ts
13693
+ init_logger();
13348
13694
  init_repositories();
13349
13695
  var BACKFILL_KEY = "auth:email_normalization";
13350
13696
  async function normalizeStoredEmails() {
@@ -13456,6 +13802,7 @@ init_role_service();
13456
13802
 
13457
13803
  // src/server/services/invitation.service.ts
13458
13804
  init_repositories();
13805
+ init_config();
13459
13806
  import crypto10 from "crypto";
13460
13807
  import { BadRequestError, NotFoundError as NotFoundError4, ConflictError } from "@spfn/core/errors";
13461
13808
  function generateInvitationToken() {
@@ -13783,6 +14130,7 @@ async function updateUserProfileService(userId, params) {
13783
14130
 
13784
14131
  // src/server/services/oauth.service.ts
13785
14132
  init_repositories();
14133
+ init_logger();
13786
14134
  import { env as env17 } from "@spfn/auth/config";
13787
14135
  import { ValidationError as ValidationError13 } from "@spfn/core/errors";
13788
14136
  import {
@@ -13790,6 +14138,7 @@ import {
13790
14138
  AccountPendingDeletionError as AccountPendingDeletionError4,
13791
14139
  UnverifiedEmailLinkError
13792
14140
  } from "@spfn/auth/errors";
14141
+ init_config();
13793
14142
 
13794
14143
  // src/server/lib/oauth/google.ts
13795
14144
  import { env as env13 } from "@spfn/auth/config";
@@ -13951,6 +14300,7 @@ function getRegisteredProviders() {
13951
14300
  }
13952
14301
 
13953
14302
  // src/server/lib/oauth/jwks-verify.ts
14303
+ init_logger();
13954
14304
  import { createRemoteJWKSet, jwtVerify } from "jose";
13955
14305
  import { InvalidSocialTokenError } from "@spfn/auth/errors";
13956
14306
  var CLOCK_TOLERANCE_SECONDS = 30;
@@ -14121,7 +14471,7 @@ var appleProvider = {
14121
14471
  registerOAuthProvider(appleProvider);
14122
14472
 
14123
14473
  // src/server/lib/oauth/github-provider.ts
14124
- init_config();
14474
+ init_config2();
14125
14475
  import { ValidationError as ValidationError10 } from "@spfn/core/errors";
14126
14476
  var GITHUB_AUTH_URL = "https://github.com/login/oauth/authorize";
14127
14477
  var GITHUB_TOKEN_URL = "https://github.com/login/oauth/access_token";
@@ -14130,22 +14480,22 @@ var GITHUB_EMAILS_URL = "https://api.github.com/user/emails";
14130
14480
  var GITHUB_USER_AGENT = "spfn-auth";
14131
14481
  var GITHUB_DEFAULT_EXPIRES_IN = 60 * 60 * 24 * 365;
14132
14482
  function getGithubConfig() {
14133
- const clientId = env3.SPFN_AUTH_GITHUB_CLIENT_ID;
14134
- const clientSecret = env3.SPFN_AUTH_GITHUB_CLIENT_SECRET;
14483
+ const clientId = env4.SPFN_AUTH_GITHUB_CLIENT_ID;
14484
+ const clientSecret = env4.SPFN_AUTH_GITHUB_CLIENT_SECRET;
14135
14485
  if (!clientId || !clientSecret) {
14136
14486
  throw new ValidationError10({
14137
14487
  message: "GitHub OAuth is not configured. Set SPFN_AUTH_GITHUB_CLIENT_ID and SPFN_AUTH_GITHUB_CLIENT_SECRET."
14138
14488
  });
14139
14489
  }
14140
- const baseUrl = env3.NEXT_PUBLIC_SPFN_APP_URL || env3.SPFN_APP_URL;
14490
+ const baseUrl = env4.NEXT_PUBLIC_SPFN_APP_URL || env4.SPFN_APP_URL;
14141
14491
  return {
14142
14492
  clientId,
14143
14493
  clientSecret,
14144
- redirectUri: env3.SPFN_AUTH_GITHUB_REDIRECT_URI || `${baseUrl}/_auth/oauth/github/callback`
14494
+ redirectUri: env4.SPFN_AUTH_GITHUB_REDIRECT_URI || `${baseUrl}/_auth/oauth/github/callback`
14145
14495
  };
14146
14496
  }
14147
14497
  function getGithubScopes() {
14148
- const configured2 = env3.SPFN_AUTH_GITHUB_SCOPES;
14498
+ const configured2 = env4.SPFN_AUTH_GITHUB_SCOPES;
14149
14499
  return configured2 ? configured2.split(",").map((scope) => scope.trim()).filter(Boolean) : ["read:user", "user:email"];
14150
14500
  }
14151
14501
  async function requestGithubTokens(params) {
@@ -14195,7 +14545,7 @@ async function fetchPrimaryEmail(accessToken) {
14195
14545
  var githubProvider = {
14196
14546
  id: "github",
14197
14547
  isEnabled() {
14198
- return !!(env3.SPFN_AUTH_GITHUB_CLIENT_ID && env3.SPFN_AUTH_GITHUB_CLIENT_SECRET);
14548
+ return !!(env4.SPFN_AUTH_GITHUB_CLIENT_ID && env4.SPFN_AUTH_GITHUB_CLIENT_SECRET);
14199
14549
  },
14200
14550
  getAuthUrl(state, scopes) {
14201
14551
  const config4 = getGithubConfig();
@@ -14254,7 +14604,8 @@ var githubProvider = {
14254
14604
  registerOAuthProvider(githubProvider);
14255
14605
 
14256
14606
  // src/server/lib/oauth/kakao-provider.ts
14257
- init_config();
14607
+ init_config2();
14608
+ init_logger();
14258
14609
  import { ValidationError as ValidationError11 } from "@spfn/core/errors";
14259
14610
  import { NativeSignInUnsupportedError as NativeSignInUnsupportedError3 } from "@spfn/auth/errors";
14260
14611
  import { timingSafeEqual } from "crypto";
@@ -14265,28 +14616,28 @@ var KAKAO_ISSUER = "https://kauth.kakao.com";
14265
14616
  var KAKAO_JWKS_URI = "https://kauth.kakao.com/.well-known/jwks.json";
14266
14617
  var USERINFO_TIMEOUT_MS = 5e3;
14267
14618
  function getKakaoConfig() {
14268
- const clientId = env3.SPFN_AUTH_KAKAO_CLIENT_ID;
14269
- const clientSecret = env3.SPFN_AUTH_KAKAO_CLIENT_SECRET;
14619
+ const clientId = env4.SPFN_AUTH_KAKAO_CLIENT_ID;
14620
+ const clientSecret = env4.SPFN_AUTH_KAKAO_CLIENT_SECRET;
14270
14621
  if (!clientId) {
14271
14622
  throw new ValidationError11({
14272
14623
  message: "Kakao OAuth is not configured. Set SPFN_AUTH_KAKAO_CLIENT_ID."
14273
14624
  });
14274
14625
  }
14275
- const baseUrl = env3.NEXT_PUBLIC_SPFN_APP_URL || env3.SPFN_APP_URL;
14626
+ const baseUrl = env4.NEXT_PUBLIC_SPFN_APP_URL || env4.SPFN_APP_URL;
14276
14627
  return {
14277
14628
  clientId,
14278
14629
  clientSecret,
14279
- redirectUri: env3.SPFN_AUTH_KAKAO_REDIRECT_URI || `${baseUrl}/_auth/oauth/kakao/callback`
14630
+ redirectUri: env4.SPFN_AUTH_KAKAO_REDIRECT_URI || `${baseUrl}/_auth/oauth/kakao/callback`
14280
14631
  };
14281
14632
  }
14282
14633
  function getKakaoScopes() {
14283
- const configured2 = env3.SPFN_AUTH_KAKAO_SCOPES;
14634
+ const configured2 = env4.SPFN_AUTH_KAKAO_SCOPES;
14284
14635
  return configured2 ? configured2.split(",").map((scope) => scope.trim()).filter(Boolean) : ["account_email"];
14285
14636
  }
14286
14637
  function getKakaoNativeAudiences() {
14287
- const ids = (env3.SPFN_AUTH_KAKAO_NATIVE_CLIENT_IDS || "").split(",").map((s) => s.trim()).filter(Boolean);
14288
- if (env3.SPFN_AUTH_KAKAO_CLIENT_ID) {
14289
- ids.push(env3.SPFN_AUTH_KAKAO_CLIENT_ID);
14638
+ const ids = (env4.SPFN_AUTH_KAKAO_NATIVE_CLIENT_IDS || "").split(",").map((s) => s.trim()).filter(Boolean);
14639
+ if (env4.SPFN_AUTH_KAKAO_CLIENT_ID) {
14640
+ ids.push(env4.SPFN_AUTH_KAKAO_CLIENT_ID);
14290
14641
  }
14291
14642
  return ids;
14292
14643
  }
@@ -14366,7 +14717,7 @@ var kakaoProvider = {
14366
14717
  * 실패하므로, 판정을 웹 키에 그대로 둔다.
14367
14718
  */
14368
14719
  isEnabled() {
14369
- return !!env3.SPFN_AUTH_KAKAO_CLIENT_ID;
14720
+ return !!env4.SPFN_AUTH_KAKAO_CLIENT_ID;
14370
14721
  },
14371
14722
  getAuthUrl(state, scopes) {
14372
14723
  const config4 = getKakaoConfig();
@@ -14449,7 +14800,7 @@ var kakaoProvider = {
14449
14800
  * 본문 필드는 app_id · user_id · referrer_type.
14450
14801
  */
14451
14802
  async verifyUnlinkNotification(request) {
14452
- const adminKey = env3.SPFN_AUTH_KAKAO_ADMIN_KEY;
14803
+ const adminKey = env4.SPFN_AUTH_KAKAO_ADMIN_KEY;
14453
14804
  if (!adminKey) {
14454
14805
  throw new UnlinkNotifyRejection(401, "SPFN_AUTH_KAKAO_ADMIN_KEY is not configured");
14455
14806
  }
@@ -14471,7 +14822,8 @@ var kakaoProvider = {
14471
14822
  registerOAuthProvider(kakaoProvider);
14472
14823
 
14473
14824
  // src/server/lib/oauth/naver-provider.ts
14474
- init_config();
14825
+ init_config2();
14826
+ init_logger();
14475
14827
  import { ValidationError as ValidationError12 } from "@spfn/core/errors";
14476
14828
  import { NativeSignInUnsupportedError as NativeSignInUnsupportedError4 } from "@spfn/auth/errors";
14477
14829
  import { createDecipheriv, createHash as createHash4, createHmac, timingSafeEqual as timingSafeEqual2 } from "crypto";
@@ -14482,24 +14834,24 @@ var NAVER_ISSUER = "https://nid.naver.com";
14482
14834
  var NAVER_JWKS_URI = "https://nid.naver.com/oauth2/jwks";
14483
14835
  var USERINFO_TIMEOUT_MS2 = 5e3;
14484
14836
  function getNaverConfig() {
14485
- const clientId = env3.SPFN_AUTH_NAVER_CLIENT_ID;
14486
- const clientSecret = env3.SPFN_AUTH_NAVER_CLIENT_SECRET;
14837
+ const clientId = env4.SPFN_AUTH_NAVER_CLIENT_ID;
14838
+ const clientSecret = env4.SPFN_AUTH_NAVER_CLIENT_SECRET;
14487
14839
  if (!clientId || !clientSecret) {
14488
14840
  throw new ValidationError12({
14489
14841
  message: "Naver OAuth is not configured. Set SPFN_AUTH_NAVER_CLIENT_ID and SPFN_AUTH_NAVER_CLIENT_SECRET."
14490
14842
  });
14491
14843
  }
14492
- const baseUrl = env3.NEXT_PUBLIC_SPFN_APP_URL || env3.SPFN_APP_URL;
14844
+ const baseUrl = env4.NEXT_PUBLIC_SPFN_APP_URL || env4.SPFN_APP_URL;
14493
14845
  return {
14494
14846
  clientId,
14495
14847
  clientSecret,
14496
- redirectUri: env3.SPFN_AUTH_NAVER_REDIRECT_URI || `${baseUrl}/_auth/oauth/naver/callback`
14848
+ redirectUri: env4.SPFN_AUTH_NAVER_REDIRECT_URI || `${baseUrl}/_auth/oauth/naver/callback`
14497
14849
  };
14498
14850
  }
14499
14851
  function getNaverNativeAudiences() {
14500
- const ids = (env3.SPFN_AUTH_NAVER_NATIVE_CLIENT_IDS || "").split(",").map((s) => s.trim()).filter(Boolean);
14501
- if (env3.SPFN_AUTH_NAVER_CLIENT_ID) {
14502
- ids.push(env3.SPFN_AUTH_NAVER_CLIENT_ID);
14852
+ const ids = (env4.SPFN_AUTH_NAVER_NATIVE_CLIENT_IDS || "").split(",").map((s) => s.trim()).filter(Boolean);
14853
+ if (env4.SPFN_AUTH_NAVER_CLIENT_ID) {
14854
+ ids.push(env4.SPFN_AUTH_NAVER_CLIENT_ID);
14503
14855
  }
14504
14856
  return ids;
14505
14857
  }
@@ -14592,7 +14944,7 @@ async function withNaverProfile(identity, accessToken) {
14592
14944
  var naverProvider = {
14593
14945
  id: "naver",
14594
14946
  isEnabled() {
14595
- return !!(env3.SPFN_AUTH_NAVER_CLIENT_ID && env3.SPFN_AUTH_NAVER_CLIENT_SECRET);
14947
+ return !!(env4.SPFN_AUTH_NAVER_CLIENT_ID && env4.SPFN_AUTH_NAVER_CLIENT_SECRET);
14596
14948
  },
14597
14949
  getAuthUrl(state) {
14598
14950
  const config4 = getNaverConfig();
@@ -14674,8 +15026,8 @@ var naverProvider = {
14674
15026
  * 삭제가 멱등이라는 점에 의존한다.
14675
15027
  */
14676
15028
  async verifyUnlinkNotification(request) {
14677
- const clientId = env3.SPFN_AUTH_NAVER_CLIENT_ID;
14678
- const clientSecret = env3.SPFN_AUTH_NAVER_CLIENT_SECRET;
15029
+ const clientId = env4.SPFN_AUTH_NAVER_CLIENT_ID;
15030
+ const clientSecret = env4.SPFN_AUTH_NAVER_CLIENT_SECRET;
14679
15031
  if (!clientId || !clientSecret) {
14680
15032
  throw new UnlinkNotifyRejection(403, "Naver OAuth is not configured");
14681
15033
  }
@@ -14703,6 +15055,7 @@ var naverProvider = {
14703
15055
  registerOAuthProvider(naverProvider);
14704
15056
 
14705
15057
  // src/server/services/oauth.service.ts
15058
+ init_key_policy();
14706
15059
  function requireEnabledProvider(provider) {
14707
15060
  const oauthProvider = getOAuthProvider(provider);
14708
15061
  if (!oauthProvider) {
@@ -14777,7 +15130,8 @@ async function oauthCallbackService(params) {
14777
15130
  isNewUser = result.isNewUser;
14778
15131
  }
14779
15132
  await assertActiveForOAuthSession(userId);
14780
- await registerPublicKeyService({
15133
+ const user = await usersRepository.findById(userId);
15134
+ const registered = await registerPublicKeyService({
14781
15135
  userId,
14782
15136
  keyId: stateData.keyId,
14783
15137
  publicKey: stateData.publicKey,
@@ -14785,7 +15139,8 @@ async function oauthCallbackService(params) {
14785
15139
  algorithm: stateData.algorithm,
14786
15140
  channel: "oauth",
14787
15141
  ip: params.ip,
14788
- userAgent: params.userAgent
15142
+ userAgent: params.userAgent,
15143
+ binding: decideKeyBinding(user?.sessionBinding ?? "none", params.webProxy)
14789
15144
  });
14790
15145
  await updateLastLoginService(userId);
14791
15146
  const appUrl = env17.NEXT_PUBLIC_SPFN_APP_URL || env17.SPFN_APP_URL;
@@ -14799,9 +15154,15 @@ async function oauthCallbackService(params) {
14799
15154
  // destination that leaves the app out of the callback URL no matter which
14800
15155
  // seam sealed it.
14801
15156
  returnUrl: isSafeReturnPath(stateData.returnUrl) ? stateData.returnUrl : "/",
14802
- isNewUser: String(isNewUser)
15157
+ isNewUser: String(isNewUser),
15158
+ // The callback page and `createOAuthCallbackHandler` are the two seams
15159
+ // that seal a session out of this redirect, and neither calls a route
15160
+ // that could tell them the key is bound. The two values ride the query
15161
+ // for the same reason `userId` and `keyId` do — and, like those, nothing
15162
+ // is authorized by them: the key row already expires when it says it
15163
+ // does, whatever a tampered query claims the cookie should believe.
15164
+ ...bindingRedirectParams(registered)
14803
15165
  });
14804
- const user = await usersRepository.findById(userId);
14805
15166
  const eventPayload = {
14806
15167
  userId: String(userId),
14807
15168
  provider,
@@ -14910,6 +15271,15 @@ async function createOrLinkUser(provider, identity, tokens, metadata) {
14910
15271
  });
14911
15272
  return { userId, isNewUser };
14912
15273
  }
15274
+ function bindingRedirectParams(registered) {
15275
+ if (registered.binding !== "passkey" || !registered.expiresAt) {
15276
+ return {};
15277
+ }
15278
+ return {
15279
+ sessionBinding: registered.binding,
15280
+ keyExpiresAtMillis: String(registered.expiresAt.getTime())
15281
+ };
15282
+ }
14913
15283
  function buildRedirectUrl(baseUrl, params) {
14914
15284
  const url = new URL(baseUrl, "http://placeholder");
14915
15285
  for (const [key, value] of Object.entries(params)) {
@@ -15052,6 +15422,7 @@ async function persistNativeLogin(identity, params) {
15052
15422
 
15053
15423
  // src/server/services/ops-token.service.ts
15054
15424
  init_ops_tokens_repository();
15425
+ init_logger();
15055
15426
  import { createHash as createHash5, randomBytes as randomBytes2 } from "crypto";
15056
15427
  var OPS_TOKEN_PREFIX = "spfn_ops_";
15057
15428
  function isOpsToken(bearer) {
@@ -15106,6 +15477,7 @@ init_oauth2_clients_repository();
15106
15477
  import { randomBytes as randomBytes4 } from "crypto";
15107
15478
 
15108
15479
  // src/server/lib/oauth2/config.ts
15480
+ init_logger();
15109
15481
  var DEFAULT_ACCESS_TOKEN_TTL_MS = 8 * 60 * 60 * 1e3;
15110
15482
  var DEFAULT_REFRESH_TOKEN_TTL_MS = 30 * 24 * 60 * 60 * 1e3;
15111
15483
  var DEFAULT_CODE_TTL_MS = 60 * 1e3;
@@ -15367,6 +15739,7 @@ function secondsUntil(at, from = /* @__PURE__ */ new Date()) {
15367
15739
  }
15368
15740
 
15369
15741
  // src/server/services/oauth2-client.service.ts
15742
+ init_logger();
15370
15743
  var MAX_UNGRANTED_CLIENTS_PER_IP = 20;
15371
15744
  var UNGRANTED_CLIENT_WINDOW_MS = 60 * 60 * 1e3;
15372
15745
  var STALE_CLIENT_MAX_AGE_MS = 24 * 60 * 60 * 1e3;
@@ -15615,6 +15988,7 @@ init_oauth2_grants_repository();
15615
15988
  init_oauth2_authorization_codes_repository();
15616
15989
  init_oauth2_tokens_repository();
15617
15990
  import { runInTransaction as runInTransaction6 } from "@spfn/core/db";
15991
+ init_logger();
15618
15992
  function invalidGrant() {
15619
15993
  return {
15620
15994
  ok: false,
@@ -15805,6 +16179,7 @@ async function issuedToClient(grantId, clientId) {
15805
16179
  // src/server/services/oauth2-access-token.service.ts
15806
16180
  init_oauth2_grants_repository();
15807
16181
  init_oauth2_tokens_repository();
16182
+ init_logger();
15808
16183
  async function verifyAccessToken(token, resource) {
15809
16184
  if (!isAccessTokenShaped(token)) {
15810
16185
  return null;
@@ -15830,6 +16205,149 @@ function isUsable(record) {
15830
16205
  return record.kind === "access" && record.revokedAt === null && record.expiresAt.getTime() > Date.now();
15831
16206
  }
15832
16207
 
16208
+ // src/server/services/session-binding.service.ts
16209
+ init_config();
16210
+ init_repositories();
16211
+ init_key_policy();
16212
+ import { RecentAuthenticationRequiredError as RecentAuthenticationRequiredError2, SessionBindingUnavailableError } from "@spfn/auth/errors";
16213
+ import { ValidationError as ValidationError14 } from "@spfn/core/errors";
16214
+ async function getSessionBindingService(params) {
16215
+ const user = await usersRepository.findById(params.userId);
16216
+ const key = await keysRepository.findByKeyIdAndUserId(params.keyId, params.userId);
16217
+ return {
16218
+ mode: user?.sessionBinding ?? "none",
16219
+ keyExpiresAtMillis: key?.binding === "passkey" ? key.expiresAt?.getTime() : void 0
16220
+ };
16221
+ }
16222
+ async function keySessionBindingService(keyId) {
16223
+ const key = await keysRepository.findByKeyId(keyId);
16224
+ if (key?.binding !== "passkey" || !key.expiresAt) {
16225
+ return {};
16226
+ }
16227
+ return { sessionBinding: "passkey", keyExpiresAtMillis: key.expiresAt.getTime() };
16228
+ }
16229
+ async function enableSessionBindingService(params) {
16230
+ if (!params.webProxy) {
16231
+ throw new SessionBindingUnavailableError();
16232
+ }
16233
+ const live = await passkeysRepository.listLiveByUserId(params.userId);
16234
+ if (live.length === 0) {
16235
+ throw new ValidationError14({
16236
+ message: "Session binding needs a passkey to renew with. Enroll one first."
16237
+ });
16238
+ }
16239
+ await assertRecentAuthentication({ userId: params.userId, keyId: params.keyId });
16240
+ const user = await usersRepository.findById(params.userId);
16241
+ if (user?.sessionBinding === "passkey") {
16242
+ return await getSessionBindingService(params);
16243
+ }
16244
+ await usersRepository.updateById(params.userId, {
16245
+ sessionBinding: "passkey",
16246
+ sessionBindingChangedAt: /* @__PURE__ */ new Date()
16247
+ });
16248
+ const bound = await keysRepository.bindByKeyIdAndUserId(
16249
+ params.keyId,
16250
+ params.userId,
16251
+ new Date(Date.now() + getBoundKeyTtlMs())
16252
+ );
16253
+ return { mode: "passkey", keyExpiresAtMillis: bound?.expiresAt?.getTime() };
16254
+ }
16255
+ async function disableSessionBindingService(params) {
16256
+ if (!await provedOwnership(params)) {
16257
+ throw new RecentAuthenticationRequiredError2({
16258
+ message: "Turning session binding off needs a passkey or your current password."
16259
+ });
16260
+ }
16261
+ await usersRepository.updateById(params.userId, {
16262
+ sessionBinding: "none",
16263
+ sessionBindingChangedAt: /* @__PURE__ */ new Date()
16264
+ });
16265
+ const unbound = /* @__PURE__ */ new Date();
16266
+ unbound.setDate(unbound.getDate() + KEY_TTL_DAYS);
16267
+ await keysRepository.unbindActiveByUserId(params.userId, unbound);
16268
+ return { mode: "none" };
16269
+ }
16270
+ async function startSessionBindingDisableService(userId) {
16271
+ return await startRenewalCeremonyService(userId);
16272
+ }
16273
+ async function provedOwnership(params) {
16274
+ if (params.response) {
16275
+ return await verifyRenewalAssertionService(params.userId, params.response);
16276
+ }
16277
+ const user = await usersRepository.findById(params.userId);
16278
+ const storedHash = user?.passwordHash;
16279
+ const matched = await verifyPassword(
16280
+ params.currentPassword || NO_PASSWORD_PRESENTED2,
16281
+ storedHash ?? await getDummyPasswordHash()
16282
+ );
16283
+ return Boolean(storedHash) && matched;
16284
+ }
16285
+ var NO_PASSWORD_PRESENTED2 = "spfn-session-binding-no-password-presented";
16286
+
16287
+ // src/server/services/session-renew.service.ts
16288
+ init_config();
16289
+ init_repositories();
16290
+ import { SessionRenewalRefusedError } from "@spfn/auth/errors";
16291
+ var RENEWAL_REVOCATION_REASON = "Replaced by bound-session renewal";
16292
+ async function startSessionRenewService(params) {
16293
+ const { key } = await admitForRenewal(params.expiredKeyId);
16294
+ return await startRenewalCeremonyService(key.userId);
16295
+ }
16296
+ async function finishSessionRenewService(params) {
16297
+ const { key, user } = await admitForRenewal(params.expiredKeyId);
16298
+ if (!await verifyRenewalAssertionService(key.userId, params.response)) {
16299
+ throw new SessionRenewalRefusedError();
16300
+ }
16301
+ const revoked = await revokeKeyService({
16302
+ userId: key.userId,
16303
+ keyId: key.keyId,
16304
+ reason: RENEWAL_REVOCATION_REASON
16305
+ });
16306
+ if (!revoked) {
16307
+ throw new SessionRenewalRefusedError();
16308
+ }
16309
+ const registered = await registerPublicKeyService({
16310
+ userId: key.userId,
16311
+ keyId: params.keyId,
16312
+ publicKey: params.publicKey,
16313
+ fingerprint: params.fingerprint,
16314
+ algorithm: params.algorithm,
16315
+ deviceName: key.deviceName ?? void 0,
16316
+ platform: key.platform ?? void 0,
16317
+ channel: "renewal",
16318
+ // The old row's provenance, not this request's: renewing is not appearing
16319
+ // for the first time. Passing the recorded `user-agent` also re-derives
16320
+ // the same family, so `registered_ua_family` carries over unchanged.
16321
+ ip: key.registeredIp ?? void 0,
16322
+ userAgent: key.registeredUserAgent ?? void 0,
16323
+ binding: "passkey",
16324
+ replacesKeyId: key.keyId
16325
+ });
16326
+ return {
16327
+ keyId: params.keyId,
16328
+ userId: String(user.id),
16329
+ publicId: user.publicId,
16330
+ email: user.email || void 0,
16331
+ phone: user.phone || void 0,
16332
+ passwordChangeRequired: user.passwordChangeRequired,
16333
+ ...loginBindingFields(registered)
16334
+ };
16335
+ }
16336
+ async function admitForRenewal(expiredKeyId) {
16337
+ const key = expiredKeyId ? await keysRepository.findByKeyId(expiredKeyId) : null;
16338
+ if (!key || !key.isActive || key.binding !== "passkey" || !key.expiresAt) {
16339
+ throw new SessionRenewalRefusedError();
16340
+ }
16341
+ if (Date.now() > key.expiresAt.getTime() + getBoundKeyRenewGraceMs()) {
16342
+ throw new SessionRenewalRefusedError();
16343
+ }
16344
+ const user = await usersRepository.findById(key.userId);
16345
+ if (!user || user.status !== "active") {
16346
+ throw new SessionRenewalRefusedError();
16347
+ }
16348
+ return { key, user };
16349
+ }
16350
+
15833
16351
  // src/server/routes/auth/index.ts
15834
16352
  init_esm();
15835
16353
  import { Transactional } from "@spfn/core/db";
@@ -15919,9 +16437,14 @@ function deviceProvenance(c) {
15919
16437
  const userAgent = c.req.header("user-agent");
15920
16438
  return {
15921
16439
  ip: ip === "unknown" ? void 0 : ip,
15922
- userAgent: userAgent?.slice(0, REGISTERED_USER_AGENT_MAX_LENGTH)
16440
+ userAgent: userAgent?.slice(0, REGISTERED_USER_AGENT_MAX_LENGTH),
16441
+ webProxy: c.get("clientType") === "web"
15923
16442
  };
15924
16443
  }
16444
+ function attestedClientIp(c) {
16445
+ const provenance = deviceProvenance(c);
16446
+ return provenance.webProxy ? provenance.ip ?? null : null;
16447
+ }
15925
16448
 
15926
16449
  // src/server/routes/auth/index.ts
15927
16450
  var sendVerificationCode = route.post("/_auth/codes").input({
@@ -15994,7 +16517,7 @@ var requestSignupLink = route.post("/_auth/signup/email").input({
15994
16517
  }).use([rateLimitPolicy("auth-signup-link", { limit: 5, windowMs: 6e4, by: byIpAndAccount({ ipLimit: 20 }) })]).skip(["auth"]).handler(async (c) => {
15995
16518
  const { body } = await c.data();
15996
16519
  if (body.returnPath !== void 0 && !isSafeReturnPath(body.returnPath)) {
15997
- throw new ValidationError14({ message: "returnPath must be a relative path within the app" });
16520
+ throw new ValidationError15({ message: "returnPath must be a relative path within the app" });
15998
16521
  }
15999
16522
  return await requestSignupLinkService(body);
16000
16523
  });
@@ -16251,7 +16774,7 @@ init_esm();
16251
16774
  init_schema3();
16252
16775
  init_types();
16253
16776
  import { Transactional as Transactional2 } from "@spfn/core/db";
16254
- import { ValidationError as ValidationError15 } from "@spfn/core/errors";
16777
+ import { ValidationError as ValidationError16 } from "@spfn/core/errors";
16255
16778
  import { rateLimitPolicy as rateLimitPolicy2 } from "@spfn/core/middleware";
16256
16779
  import { route as route2 } from "@spfn/core/route";
16257
16780
  var requestPasswordReset = route2.post("/_auth/password/reset").input({
@@ -16265,7 +16788,7 @@ var requestPasswordReset = route2.post("/_auth/password/reset").input({
16265
16788
  }).use([rateLimitPolicy2("auth-password-reset", { limit: 5, windowMs: 6e4, by: byIpAndAccount({ ipLimit: 20 }) })]).skip(["auth"]).handler(async (c) => {
16266
16789
  const { body } = await c.data();
16267
16790
  if (body.returnPath !== void 0 && !isSafeReturnPath(body.returnPath)) {
16268
- throw new ValidationError15({ message: "returnPath must be a relative path within the app" });
16791
+ throw new ValidationError16({ message: "returnPath must be a relative path within the app" });
16269
16792
  }
16270
16793
  return await requestPasswordResetService(body);
16271
16794
  });
@@ -16418,130 +16941,75 @@ var mfaStepUpOptions = route4.post("/_auth/mfa/step-up/options").input({
16418
16941
  return await startStepUpService(Number(userId));
16419
16942
  });
16420
16943
 
16421
- // src/server/routes/auth/passkeys.ts
16944
+ // src/server/routes/auth/session-binding.ts
16422
16945
  init_esm();
16423
16946
  import { Transactional as Transactional4 } from "@spfn/core/db";
16424
16947
  import { rateLimitPolicy as rateLimitPolicy5 } from "@spfn/core/middleware";
16425
16948
  import { route as route5 } from "@spfn/core/route";
16426
- init_types();
16427
- init_passkeys();
16428
- init_schema3();
16429
16949
  var CredentialResponseSchema = Type.Unknown({
16430
16950
  description: "The credential from @simplewebauthn/browser, passed through unchanged"
16431
16951
  });
16432
- var PasskeyIdSchema2 = Type.String({
16433
- pattern: "^[0-9]{1,19}$",
16434
- description: "Passkey identifier, as returned by list"
16435
- });
16436
- var PasskeyLabelSchema = Type.String({
16437
- minLength: 1,
16438
- maxLength: PASSKEY_LABEL_MAX_LENGTH,
16439
- description: `Owner-facing name in the passkey list (1-${PASSKEY_LABEL_MAX_LENGTH} chars)`
16440
- });
16441
16952
  var CurrentPasswordSchema = Type.String({
16442
16953
  minLength: 1,
16443
- description: "Account password, needed when the session proved itself longer ago than the recent-authentication window"
16444
- });
16445
- var passkeyRegisterOptions = route5.post("/_auth/passkeys/register/options").input({
16446
- body: Type.Object({
16447
- currentPassword: Type.Optional(CurrentPasswordSchema)
16448
- })
16449
- }).use([rateLimitPolicy5("auth-passkey-register-options", {
16450
- limit: 10,
16451
- windowMs: 6e4,
16452
- by: byIpAndCaller({ ipLimit: 100 })
16453
- })]).handler(async (c) => {
16454
- const { body } = await c.data();
16455
- const { userId, keyId } = getAuth(c);
16456
- return await startPasskeyEnrollmentService({
16457
- userId: Number(userId),
16458
- keyId,
16459
- currentPassword: body.currentPassword
16460
- });
16461
- });
16462
- var passkeyRegisterVerify = route5.post("/_auth/passkeys/register/verify").input({
16463
- body: Type.Object({
16464
- response: CredentialResponseSchema,
16465
- label: Type.Optional(PasskeyLabelSchema)
16466
- })
16467
- }).use([
16468
- rateLimitPolicy5("auth-passkey-register-verify", {
16469
- limit: 10,
16470
- windowMs: 6e4,
16471
- by: byIpAndCaller({ ipLimit: 100 })
16472
- }),
16473
- Transactional4()
16474
- ]).handler(async (c) => {
16475
- const { body } = await c.data();
16476
- const { userId } = getAuth(c);
16477
- return await finishPasskeyEnrollmentService({
16478
- userId: Number(userId),
16479
- response: body.response,
16480
- label: body.label
16481
- });
16482
- });
16483
- var passkeyLoginOptions = route5.post("/_auth/passkeys/login/options").input({
16484
- body: Type.Object({}, {
16485
- additionalProperties: false,
16486
- description: "No identifier is accepted \u2014 sign-in is discoverable"
16487
- })
16488
- }).use([rateLimitPolicy5("auth-passkey-login-options", { limit: 20, windowMs: 6e4 })]).skip(["auth"]).handler(async () => {
16489
- return await startPasskeyLoginService();
16490
- });
16491
- var passkeyLoginVerify = route5.post("/_auth/passkeys/login/verify").input({
16492
- body: Type.Object({
16493
- response: CredentialResponseSchema
16494
- })
16495
- }).interceptor({
16496
- body: Type.Object({
16497
- publicKey: Type.String({ description: "Client public key" }),
16498
- keyId: Type.String({ description: "Key identifier" }),
16499
- fingerprint: Type.String({ description: "Key fingerprint" }),
16500
- algorithm: Type.Union(KEY_ALGORITHM.map((algo) => Type.Literal(algo)), { description: "Signature algorithm" }),
16501
- oldKeyId: Type.Optional(Type.String({ description: "Previous key ID for rotation" })),
16502
- deviceName: Type.Optional(DeviceNameSchema),
16503
- platform: Type.Optional(PlatformSchema)
16504
- })
16505
- }).use([rateLimitPolicy5("auth-passkey-login-verify", { limit: 10, windowMs: 6e4 }), Transactional4()]).skip(["auth"]).handler(async (c) => {
16506
- const { body } = await c.data();
16507
- return await finishPasskeyLoginService({
16508
- ...body,
16509
- ...deviceProvenance(c.raw),
16510
- response: body.response
16511
- });
16512
- });
16513
- var listPasskeys = route5.post("/_auth/passkeys/list").input({
16514
- body: Type.Object({})
16515
- }).handler(async (c) => {
16516
- const { userId } = getAuth(c);
16517
- return { passkeys: await listPasskeysService(Number(userId)) };
16518
- });
16519
- var renamePasskey = route5.post("/_auth/passkeys/rename").input({
16520
- body: Type.Object({
16521
- passkeyId: PasskeyIdSchema2,
16522
- label: PasskeyLabelSchema
16523
- })
16524
- }).handler(async (c) => {
16525
- const { body } = await c.data();
16526
- const { userId } = getAuth(c);
16527
- return await renamePasskeyService({ userId: Number(userId), ...body });
16954
+ description: "Account password \u2014 one of the two ways to prove ownership when turning binding off"
16528
16955
  });
16529
- var revokePasskey = route5.post("/_auth/passkeys/revoke").input({
16956
+ var setSessionBinding = route5.post("/_auth/session/binding").input({
16530
16957
  body: Type.Object({
16531
- passkeyId: PasskeyIdSchema2,
16958
+ mode: Type.Union([Type.Literal("passkey"), Type.Literal("none")], {
16959
+ description: "passkey binds this account's web sessions; none returns them to ordinary long-lived keys"
16960
+ }),
16961
+ response: Type.Optional(CredentialResponseSchema),
16532
16962
  currentPassword: Type.Optional(CurrentPasswordSchema)
16533
16963
  })
16534
16964
  }).use([
16535
- rateLimitPolicy5("auth-passkey-revoke", { limit: 10, windowMs: 6e4, by: byIpAndCaller({ ipLimit: 100 }) }),
16965
+ rateLimitPolicy5("auth-session-binding", {
16966
+ limit: 10,
16967
+ windowMs: 6e4,
16968
+ by: byIpAndCaller({ ipLimit: 100 })
16969
+ }),
16536
16970
  Transactional4()
16537
16971
  ]).handler(async (c) => {
16538
16972
  const { body } = await c.data();
16539
16973
  const { userId, keyId } = getAuth(c);
16540
- return await revokePasskeyService({ userId: Number(userId), keyId, ...body });
16974
+ const params = { userId: Number(userId), keyId, webProxy: deviceProvenance(c.raw).webProxy };
16975
+ if (body.mode === "none") {
16976
+ return await disableSessionBindingService({
16977
+ ...params,
16978
+ response: body.response,
16979
+ currentPassword: body.currentPassword
16980
+ });
16981
+ }
16982
+ return await enableSessionBindingService(params);
16983
+ });
16984
+ var getSessionBinding = route5.get("/_auth/session/binding").handler(async (c) => {
16985
+ const { userId, keyId } = getAuth(c);
16986
+ return await getSessionBindingService({ userId: Number(userId), keyId });
16987
+ });
16988
+ var sessionBindingDisableOptions = route5.post("/_auth/session/binding/disable/options").input({
16989
+ body: Type.Object({}, {
16990
+ additionalProperties: false,
16991
+ description: "No input \u2014 the session names the account"
16992
+ })
16993
+ }).use([rateLimitPolicy5("auth-session-binding-disable-options", {
16994
+ limit: 10,
16995
+ windowMs: 6e4,
16996
+ by: byIpAndCaller({ ipLimit: 100 })
16997
+ })]).handler(async (c) => {
16998
+ const { userId } = getAuth(c);
16999
+ return await startSessionBindingDisableService(Number(userId));
16541
17000
  });
16542
17001
 
16543
- // src/server/routes/invitations/index.ts
16544
- import { EMAIL_PATTERN as EMAIL_PATTERN2, UUID_PATTERN } from "@spfn/auth";
17002
+ // src/server/routes/auth/session-renew.ts
17003
+ init_esm();
17004
+ import { Transactional as Transactional5 } from "@spfn/core/db";
17005
+ import { rateLimitPolicy as rateLimitPolicy6 } from "@spfn/core/middleware";
17006
+ import { route as route6 } from "@spfn/core/route";
17007
+
17008
+ // src/server/middleware/authenticate-for-renewal.ts
17009
+ init_config();
17010
+ init_repositories();
17011
+ import { defineMiddleware as defineMiddleware3 } from "@spfn/core/route";
17012
+ import { SessionRenewalRefusedError as SessionRenewalRefusedError2 } from "@spfn/auth/errors";
16545
17013
 
16546
17014
  // src/server/middleware/authenticate.ts
16547
17015
  import { defineMiddleware as defineMiddleware2 } from "@spfn/core/route";
@@ -17014,12 +17482,13 @@ function contractViolation(message) {
17014
17482
  }
17015
17483
 
17016
17484
  // src/server/client-proof/contract-bundle.ts
17485
+ init_key_policy();
17486
+ init_types();
17017
17487
  import { createHash as createHash9 } from "crypto";
17018
17488
  import {
17019
17489
  CORE_TIME_OPERATION_ID as CORE_TIME_OPERATION_ID2,
17020
17490
  ServerTimeResponseSchema
17021
17491
  } from "@spfn/core/server";
17022
- init_types();
17023
17492
 
17024
17493
  // src/server/client-proof/proof.ts
17025
17494
  import { createHash as createHash8, createPrivateKey, createPublicKey, sign, verify as verify2 } from "crypto";
@@ -17184,8 +17653,8 @@ var CORE_PREREQUISITE_OPERATIONS = [
17184
17653
 
17185
17654
  // src/server/client-proof/contract-bundle.ts
17186
17655
  init_wire_headers();
17187
- var CONTRACT_VERSION = "0.11.0";
17188
- var CONTRACT_SUPPORTED_RANGE = ">=0.11.0 <0.12.0";
17656
+ var CONTRACT_VERSION = "0.12.0";
17657
+ var CONTRACT_SUPPORTED_RANGE = ">=0.12.0 <0.13.0";
17189
17658
  function required(name, type) {
17190
17659
  return { name, type, optional: false };
17191
17660
  }
@@ -17306,7 +17775,9 @@ var CONTRACT_TYPES = [
17306
17775
  required("publicId", "string"),
17307
17776
  optional("email", "string"),
17308
17777
  optional("phone", "string"),
17309
- required("passwordChangeRequired", "boolean")
17778
+ required("passwordChangeRequired", "boolean"),
17779
+ optional("sessionBinding", "KeyBinding"),
17780
+ optional("keyExpiresAtMillis", "integer")
17310
17781
  ]
17311
17782
  },
17312
17783
  {
@@ -17366,7 +17837,9 @@ var CONTRACT_TYPES = [
17366
17837
  required("isActive", "boolean"),
17367
17838
  optional("revokedAtMillis", "integer"),
17368
17839
  optional("registeredIp", "string"),
17369
- optional("registeredUserAgent", "string")
17840
+ optional("registeredUserAgent", "string"),
17841
+ optional("binding", "KeyBinding"),
17842
+ optional("concurrentUseAtMillis", "integer")
17370
17843
  ]
17371
17844
  },
17372
17845
  {
@@ -17446,7 +17919,9 @@ var CONTRACT_TYPES = [
17446
17919
  optional("publicId", "string"),
17447
17920
  optional("email", "string"),
17448
17921
  optional("phone", "string"),
17449
- optional("passwordChangeRequired", "boolean")
17922
+ optional("passwordChangeRequired", "boolean"),
17923
+ optional("sessionBinding", "KeyBinding"),
17924
+ optional("keyExpiresAtMillis", "integer")
17450
17925
  ]
17451
17926
  },
17452
17927
  /**
@@ -17487,6 +17962,7 @@ var CONTRACT_TYPES = [
17487
17962
  var CONTRACT_ENUMS = [
17488
17963
  { name: "KeyAlgorithm", values: [...KEY_ALGORITHM] },
17489
17964
  { name: "KeyPlatform", values: [...KEY_PLATFORM] },
17965
+ { name: "KeyBinding", values: [...SESSION_BINDINGS] },
17490
17966
  { name: "DeviceAuthPollStatus", values: ["pending", "approved"] }
17491
17967
  ];
17492
17968
  var BUNDLE_FILENAME = "spfn-mobile-contract.json";
@@ -17716,7 +18192,7 @@ async function verifyClientProofProfile(c) {
17716
18192
  throw refusalError(ClientProofRefusal.proofReplayed());
17717
18193
  }
17718
18194
  const { user, role, locale } = await resolveAuthenticatedUser(keyRecord.userId);
17719
- keysRepository2.updateLastUsedById(keyRecord.id, readContextClientIdentity(c)).catch((err) => authLogger2.middleware.error("Failed to update lastUsedAt", err));
18195
+ keysRepository2.updateLastUsedById(keyRecord.id, readContextClientIdentity(c), attestedClientIp(c)).catch((err) => authLogger2.middleware.error("Failed to update lastUsedAt", err));
17720
18196
  authLogger2.middleware.info("API access", {
17721
18197
  userId: user.id,
17722
18198
  email: user.email,
@@ -17789,6 +18265,7 @@ function registerAuthProfile(profileId, verifier) {
17789
18265
  import { decodeProtectedHeader } from "jose";
17790
18266
  import { defineMiddleware } from "@spfn/core/route";
17791
18267
  import { ForbiddenError as ForbiddenError2, UnauthorizedError as UnauthorizedError2 } from "@spfn/core/errors";
18268
+ init_logger();
17792
18269
  function getMachinePrincipal(c) {
17793
18270
  return c.get("machinePrincipal") ?? null;
17794
18271
  }
@@ -17950,69 +18427,100 @@ function extractBearer(header) {
17950
18427
 
17951
18428
  // src/server/middleware/authenticate.ts
17952
18429
  var INVALID_TOKEN_MESSAGE = "Invalid token: missing keyId";
17953
- var authenticate = defineMiddleware2("auth", async (c, next) => {
17954
- const profile = await runAuthProfile(c);
17955
- if (profile.kind === "refused") {
17956
- return profile.response;
17957
- }
17958
- if (profile.kind === "authenticated") {
17959
- c.set("auth", profile.auth);
17960
- await next();
17961
- return void 0;
17962
- }
18430
+ async function admitBearerKey(c, admitsExpired) {
17963
18431
  const authHeader = c.req.header("Authorization");
17964
18432
  if (!authHeader || !authHeader.startsWith("Bearer ")) {
17965
- authLogger3.middleware.error("Missing or invalid authorization header. If using Next.js API routes, ensure you have imported '@spfn/auth/nextjs/api' in your API route handler (e.g., src/app/api/actions/[[...path]]/route.ts) to enable automatic authentication header forwarding from client to backend.", {
17966
- headers: c.req.header(),
17967
- path: c.req.path
17968
- });
17969
- throw new UnauthorizedError3({ message: "Authentication header missing or invalid: Bearer {token}" });
18433
+ return { refused: "absent" };
17970
18434
  }
17971
18435
  const token = authHeader.substring(7);
17972
18436
  if (matchesMachineDiscriminator(token)) {
17973
18437
  authLogger3.middleware.warn("Machine credential presented to the user path \u2014 refused", { path: c.req.path });
17974
- throw new UnauthorizedError3({ message: INVALID_TOKEN_MESSAGE });
18438
+ return { refused: "machine" };
17975
18439
  }
17976
18440
  const decoded = decodeToken2(token);
17977
18441
  if (!decoded || !decoded.keyId) {
17978
- throw new UnauthorizedError3({ message: INVALID_TOKEN_MESSAGE });
18442
+ return { refused: "undecodable" };
17979
18443
  }
17980
- const keyId = decoded.keyId;
17981
- const keyRecord = await keysRepository3.findActiveByKeyId(keyId);
17982
- if (!keyRecord) {
17983
- throw new UnauthorizedError3({ message: "Invalid or revoked key" });
18444
+ const key = await keysRepository3.findActiveByKeyId(decoded.keyId);
18445
+ if (!key) {
18446
+ return { refused: "unknown" };
17984
18447
  }
17985
- if (keyRecord.expiresAt && /* @__PURE__ */ new Date() > keyRecord.expiresAt) {
17986
- throw new KeyExpiredError();
18448
+ if (key.expiresAt && /* @__PURE__ */ new Date() > key.expiresAt && !admitsExpired(key)) {
18449
+ return { refused: "expired" };
17987
18450
  }
18451
+ return signatureOutcome(token, key);
18452
+ }
18453
+ function signatureOutcome(token, key) {
17988
18454
  try {
17989
18455
  verifyClientToken2(
17990
18456
  token,
17991
- keyRecord.publicKey,
17992
- keyRecord.algorithm
18457
+ key.publicKey,
18458
+ key.algorithm
17993
18459
  // entity.algorithm is always defined
17994
18460
  );
17995
18461
  } catch (err) {
17996
- if (err instanceof Error) {
17997
- if (err.name === "TokenExpiredError") {
17998
- throw new TokenExpiredError();
17999
- }
18000
- if (err.name === "JsonWebTokenError") {
18001
- throw new InvalidTokenError({ message: "Invalid token signature" });
18002
- }
18462
+ const name = err instanceof Error ? err.name : "";
18463
+ if (name === "TokenExpiredError") {
18464
+ return { refused: "token-expired" };
18003
18465
  }
18004
- throw new UnauthorizedError3({ message: "Authentication failed" });
18466
+ return { refused: name === "JsonWebTokenError" ? "bad-signature" : "unverifiable" };
18005
18467
  }
18006
- const { user, role, locale } = await resolveAuthenticatedUser(keyRecord.userId);
18007
- keysRepository3.updateLastUsedById(keyRecord.id, readContextClientIdentity(c)).catch((err) => authLogger3.middleware.error("Failed to update lastUsedAt", err));
18008
- c.set("auth", {
18009
- user,
18010
- userId: String(user.id),
18468
+ return { key };
18469
+ }
18470
+ function bearerRefusal(c, refused) {
18471
+ if (refused === "absent") {
18472
+ authLogger3.middleware.error("Missing or invalid authorization header. If using Next.js API routes, ensure you have imported '@spfn/auth/nextjs/api' in your API route handler (e.g., src/app/api/actions/[[...path]]/route.ts) to enable automatic authentication header forwarding from client to backend.", {
18473
+ headers: c.req.header(),
18474
+ path: c.req.path
18475
+ });
18476
+ return new UnauthorizedError3({ message: "Authentication header missing or invalid: Bearer {token}" });
18477
+ }
18478
+ switch (refused) {
18479
+ case "machine":
18480
+ case "undecodable":
18481
+ return new UnauthorizedError3({ message: INVALID_TOKEN_MESSAGE });
18482
+ case "unknown":
18483
+ return new UnauthorizedError3({ message: "Invalid or revoked key" });
18484
+ case "expired":
18485
+ return new KeyExpiredError();
18486
+ case "token-expired":
18487
+ return new TokenExpiredError();
18488
+ case "bad-signature":
18489
+ return new InvalidTokenError({ message: "Invalid token signature" });
18490
+ default:
18491
+ return new UnauthorizedError3({ message: "Authentication failed" });
18492
+ }
18493
+ }
18494
+ function bearerAuthContext(keyId, resolved) {
18495
+ return {
18496
+ user: resolved.user,
18497
+ userId: String(resolved.user.id),
18011
18498
  keyId,
18012
- role,
18013
- locale,
18499
+ role: resolved.role,
18500
+ locale: resolved.locale,
18014
18501
  scheme: "bearer"
18015
- });
18502
+ };
18503
+ }
18504
+ var authenticate = defineMiddleware2("auth", async (c, next) => {
18505
+ const profile = await runAuthProfile(c);
18506
+ if (profile.kind === "refused") {
18507
+ return profile.response;
18508
+ }
18509
+ if (profile.kind === "authenticated") {
18510
+ c.set("auth", profile.auth);
18511
+ await next();
18512
+ return void 0;
18513
+ }
18514
+ const outcome = await admitBearerKey(c, () => false);
18515
+ if ("refused" in outcome) {
18516
+ throw bearerRefusal(c, outcome.refused);
18517
+ }
18518
+ const keyRecord = outcome.key;
18519
+ const keyId = keyRecord.keyId;
18520
+ const resolved = await resolveAuthenticatedUser(keyRecord.userId);
18521
+ const { user } = resolved;
18522
+ keysRepository3.updateLastUsedById(keyRecord.id, readContextClientIdentity(c), attestedClientIp(c)).catch((err) => authLogger3.middleware.error("Failed to update lastUsedAt", err));
18523
+ c.set("auth", bearerAuthContext(keyId, resolved));
18016
18524
  const method = c.req.method;
18017
18525
  const path = c.req.path;
18018
18526
  authLogger3.middleware.info("API access", {
@@ -18077,7 +18585,7 @@ var optionalAuth = defineMiddleware2("optionalAuth", async (c, next) => {
18077
18585
  return void 0;
18078
18586
  }
18079
18587
  const { user, role } = result;
18080
- keysRepository3.updateLastUsedById(keyRecord.id, readContextClientIdentity(c)).catch((err) => authLogger3.middleware.error("Failed to update lastUsedAt", err));
18588
+ keysRepository3.updateLastUsedById(keyRecord.id, readContextClientIdentity(c), attestedClientIp(c)).catch((err) => authLogger3.middleware.error("Failed to update lastUsedAt", err));
18081
18589
  c.set("auth", {
18082
18590
  user,
18083
18591
  userId: String(user.id),
@@ -18092,12 +18600,199 @@ var optionalAuth = defineMiddleware2("optionalAuth", async (c, next) => {
18092
18600
  return void 0;
18093
18601
  }, { skips: ["auth"] });
18094
18602
 
18603
+ // src/server/middleware/authenticate-for-renewal.ts
18604
+ function admitsForRenewal(key) {
18605
+ return key.binding === "passkey" && key.expiresAt !== null && Date.now() < key.expiresAt.getTime() + getBoundKeyRenewGraceMs();
18606
+ }
18607
+ var authenticateForRenewal = defineMiddleware3("authForRenewal", async (c, next) => {
18608
+ const outcome = await admitBearerKey(c, admitsForRenewal);
18609
+ if ("refused" in outcome || !admitsForRenewal(outcome.key)) {
18610
+ throw new SessionRenewalRefusedError2();
18611
+ }
18612
+ const resolved = await activeAccount(outcome.key.userId);
18613
+ if (!resolved) {
18614
+ throw new SessionRenewalRefusedError2();
18615
+ }
18616
+ c.set("auth", bearerAuthContext(outcome.key.keyId, resolved));
18617
+ await next();
18618
+ return void 0;
18619
+ }, { skips: ["auth"] });
18620
+ async function activeAccount(userId) {
18621
+ const [result, locale] = await Promise.all([
18622
+ usersRepository.findByIdWithRole(userId),
18623
+ userProfilesRepository.findLocaleByUserId(userId)
18624
+ ]);
18625
+ if (!result || result.user.status !== "active") {
18626
+ return null;
18627
+ }
18628
+ return { user: result.user, role: result.role?.name ?? null, locale };
18629
+ }
18630
+
18631
+ // src/server/routes/auth/session-renew.ts
18632
+ init_types();
18633
+ var CredentialResponseSchema2 = Type.Unknown({
18634
+ description: "The credential from @simplewebauthn/browser, passed through unchanged"
18635
+ });
18636
+ var RENEW_RATE_LIMIT = { limit: 10, windowMs: 6e4 };
18637
+ var sessionRenewOptions = route6.post("/_auth/session/renew/options").input({
18638
+ body: Type.Object({})
18639
+ }).use([rateLimitPolicy6("auth-session-renew", RENEW_RATE_LIMIT), authenticateForRenewal]).handler(async (c) => {
18640
+ return await startSessionRenewService({ expiredKeyId: getAuth(c).keyId });
18641
+ });
18642
+ var sessionRenewVerify = route6.post("/_auth/session/renew/verify").input({
18643
+ body: Type.Object({
18644
+ response: CredentialResponseSchema2
18645
+ })
18646
+ }).interceptor({
18647
+ body: Type.Object({
18648
+ publicKey: Type.String({ description: "Client public key" }),
18649
+ keyId: Type.String({ description: "Key identifier" }),
18650
+ fingerprint: Type.String({ description: "Key fingerprint" }),
18651
+ algorithm: Type.Optional(Type.Union(
18652
+ KEY_ALGORITHM.map((algo) => Type.Literal(algo)),
18653
+ { description: "Signature algorithm \u2014 the service default when absent" }
18654
+ ))
18655
+ })
18656
+ }).use([rateLimitPolicy6("auth-session-renew", RENEW_RATE_LIMIT), authenticateForRenewal, Transactional5()]).handler(async (c) => {
18657
+ const { body } = await c.data();
18658
+ return await finishSessionRenewService({
18659
+ ...body,
18660
+ expiredKeyId: getAuth(c).keyId,
18661
+ response: body.response
18662
+ });
18663
+ });
18664
+
18665
+ // src/server/routes/auth/passkeys.ts
18666
+ init_esm();
18667
+ import { Transactional as Transactional6 } from "@spfn/core/db";
18668
+ import { rateLimitPolicy as rateLimitPolicy7 } from "@spfn/core/middleware";
18669
+ import { route as route7 } from "@spfn/core/route";
18670
+ init_types();
18671
+ init_passkeys();
18672
+ init_schema3();
18673
+ var CredentialResponseSchema3 = Type.Unknown({
18674
+ description: "The credential from @simplewebauthn/browser, passed through unchanged"
18675
+ });
18676
+ var PasskeyIdSchema2 = Type.String({
18677
+ pattern: "^[0-9]{1,19}$",
18678
+ description: "Passkey identifier, as returned by list"
18679
+ });
18680
+ var PasskeyLabelSchema = Type.String({
18681
+ minLength: 1,
18682
+ maxLength: PASSKEY_LABEL_MAX_LENGTH,
18683
+ description: `Owner-facing name in the passkey list (1-${PASSKEY_LABEL_MAX_LENGTH} chars)`
18684
+ });
18685
+ var CurrentPasswordSchema2 = Type.String({
18686
+ minLength: 1,
18687
+ description: "Account password, needed when the session proved itself longer ago than the recent-authentication window"
18688
+ });
18689
+ var passkeyRegisterOptions = route7.post("/_auth/passkeys/register/options").input({
18690
+ body: Type.Object({
18691
+ currentPassword: Type.Optional(CurrentPasswordSchema2)
18692
+ })
18693
+ }).use([rateLimitPolicy7("auth-passkey-register-options", {
18694
+ limit: 10,
18695
+ windowMs: 6e4,
18696
+ by: byIpAndCaller({ ipLimit: 100 })
18697
+ })]).handler(async (c) => {
18698
+ const { body } = await c.data();
18699
+ const { userId, keyId } = getAuth(c);
18700
+ return await startPasskeyEnrollmentService({
18701
+ userId: Number(userId),
18702
+ keyId,
18703
+ currentPassword: body.currentPassword
18704
+ });
18705
+ });
18706
+ var passkeyRegisterVerify = route7.post("/_auth/passkeys/register/verify").input({
18707
+ body: Type.Object({
18708
+ response: CredentialResponseSchema3,
18709
+ label: Type.Optional(PasskeyLabelSchema)
18710
+ })
18711
+ }).use([
18712
+ rateLimitPolicy7("auth-passkey-register-verify", {
18713
+ limit: 10,
18714
+ windowMs: 6e4,
18715
+ by: byIpAndCaller({ ipLimit: 100 })
18716
+ }),
18717
+ Transactional6()
18718
+ ]).handler(async (c) => {
18719
+ const { body } = await c.data();
18720
+ const { userId } = getAuth(c);
18721
+ return await finishPasskeyEnrollmentService({
18722
+ userId: Number(userId),
18723
+ response: body.response,
18724
+ label: body.label
18725
+ });
18726
+ });
18727
+ var passkeyLoginOptions = route7.post("/_auth/passkeys/login/options").input({
18728
+ body: Type.Object({}, {
18729
+ additionalProperties: false,
18730
+ description: "No identifier is accepted \u2014 sign-in is discoverable"
18731
+ })
18732
+ }).use([rateLimitPolicy7("auth-passkey-login-options", { limit: 20, windowMs: 6e4 })]).skip(["auth"]).handler(async () => {
18733
+ return await startPasskeyLoginService();
18734
+ });
18735
+ var passkeyLoginVerify = route7.post("/_auth/passkeys/login/verify").input({
18736
+ body: Type.Object({
18737
+ response: CredentialResponseSchema3
18738
+ })
18739
+ }).interceptor({
18740
+ body: Type.Object({
18741
+ publicKey: Type.String({ description: "Client public key" }),
18742
+ keyId: Type.String({ description: "Key identifier" }),
18743
+ fingerprint: Type.String({ description: "Key fingerprint" }),
18744
+ algorithm: Type.Union(KEY_ALGORITHM.map((algo) => Type.Literal(algo)), { description: "Signature algorithm" }),
18745
+ oldKeyId: Type.Optional(Type.String({ description: "Previous key ID for rotation" })),
18746
+ deviceName: Type.Optional(DeviceNameSchema),
18747
+ platform: Type.Optional(PlatformSchema)
18748
+ })
18749
+ }).use([rateLimitPolicy7("auth-passkey-login-verify", { limit: 10, windowMs: 6e4 }), Transactional6()]).skip(["auth"]).handler(async (c) => {
18750
+ const { body } = await c.data();
18751
+ return await finishPasskeyLoginService({
18752
+ ...body,
18753
+ ...deviceProvenance(c.raw),
18754
+ response: body.response
18755
+ });
18756
+ });
18757
+ var listPasskeys = route7.post("/_auth/passkeys/list").input({
18758
+ body: Type.Object({})
18759
+ }).handler(async (c) => {
18760
+ const { userId } = getAuth(c);
18761
+ return { passkeys: await listPasskeysService(Number(userId)) };
18762
+ });
18763
+ var renamePasskey = route7.post("/_auth/passkeys/rename").input({
18764
+ body: Type.Object({
18765
+ passkeyId: PasskeyIdSchema2,
18766
+ label: PasskeyLabelSchema
18767
+ })
18768
+ }).handler(async (c) => {
18769
+ const { body } = await c.data();
18770
+ const { userId } = getAuth(c);
18771
+ return await renamePasskeyService({ userId: Number(userId), ...body });
18772
+ });
18773
+ var revokePasskey = route7.post("/_auth/passkeys/revoke").input({
18774
+ body: Type.Object({
18775
+ passkeyId: PasskeyIdSchema2,
18776
+ currentPassword: Type.Optional(CurrentPasswordSchema2)
18777
+ })
18778
+ }).use([
18779
+ rateLimitPolicy7("auth-passkey-revoke", { limit: 10, windowMs: 6e4, by: byIpAndCaller({ ipLimit: 100 }) }),
18780
+ Transactional6()
18781
+ ]).handler(async (c) => {
18782
+ const { body } = await c.data();
18783
+ const { userId, keyId } = getAuth(c);
18784
+ return await revokePasskeyService({ userId: Number(userId), keyId, ...body });
18785
+ });
18786
+
18787
+ // src/server/routes/invitations/index.ts
18788
+ import { EMAIL_PATTERN as EMAIL_PATTERN2, UUID_PATTERN } from "@spfn/auth";
18789
+
18095
18790
  // src/server/middleware/require-permission.ts
18096
- import { defineMiddleware as defineMiddleware3 } from "@spfn/core/route";
18791
+ import { defineMiddleware as defineMiddleware4 } from "@spfn/core/route";
18097
18792
  import { ForbiddenError as ForbiddenError3 } from "@spfn/core/errors";
18098
18793
  import { InsufficientPermissionsError } from "@spfn/auth/errors";
18099
18794
  import { getAuth as getAuth2, hasAllPermissions as hasAllPermissions2, hasAnyPermission as hasAnyPermission2, authLogger as authLogger4 } from "@spfn/auth/server";
18100
- var requirePermissions = defineMiddleware3(
18795
+ var requirePermissions = defineMiddleware4(
18101
18796
  "permission",
18102
18797
  (...permissionNames) => async (c, next) => {
18103
18798
  const auth = getAuth2(c);
@@ -18125,7 +18820,7 @@ var requirePermissions = defineMiddleware3(
18125
18820
  await next();
18126
18821
  }
18127
18822
  );
18128
- var requireAnyPermission = defineMiddleware3(
18823
+ var requireAnyPermission = defineMiddleware4(
18129
18824
  "anyPermission",
18130
18825
  (...permissionNames) => async (c, next) => {
18131
18826
  const auth = getAuth2(c);
@@ -18155,11 +18850,11 @@ var requireAnyPermission = defineMiddleware3(
18155
18850
  );
18156
18851
 
18157
18852
  // src/server/middleware/require-role.ts
18158
- import { defineMiddleware as defineMiddleware4 } from "@spfn/core/route";
18853
+ import { defineMiddleware as defineMiddleware5 } from "@spfn/core/route";
18159
18854
  import { getAuth as getAuth3, authLogger as authLogger5 } from "@spfn/auth/server";
18160
18855
  import { ForbiddenError as ForbiddenError4 } from "@spfn/core/errors";
18161
18856
  import { InsufficientRoleError } from "@spfn/auth/errors";
18162
- var requireRole = defineMiddleware4(
18857
+ var requireRole = defineMiddleware5(
18163
18858
  "role",
18164
18859
  (...roleNames) => async (c, next) => {
18165
18860
  const auth = getAuth3(c);
@@ -18190,11 +18885,11 @@ var requireRole = defineMiddleware4(
18190
18885
  );
18191
18886
 
18192
18887
  // src/server/middleware/role-guard.ts
18193
- import { defineMiddleware as defineMiddleware5 } from "@spfn/core/route";
18888
+ import { defineMiddleware as defineMiddleware6 } from "@spfn/core/route";
18194
18889
  import { getAuth as getAuth4, authLogger as authLogger6 } from "@spfn/auth/server";
18195
18890
  import { ForbiddenError as ForbiddenError5 } from "@spfn/core/errors";
18196
18891
  import { InsufficientRoleError as InsufficientRoleError2 } from "@spfn/auth/errors";
18197
- var roleGuard = defineMiddleware5(
18892
+ var roleGuard = defineMiddleware6(
18198
18893
  "roleGuard",
18199
18894
  (options) => async (c, next) => {
18200
18895
  const { allow, deny } = options;
@@ -18242,10 +18937,10 @@ var roleGuard = defineMiddleware5(
18242
18937
  );
18243
18938
 
18244
18939
  // src/server/middleware/one-time-token-auth.ts
18245
- import { defineMiddleware as defineMiddleware6 } from "@spfn/core/route";
18940
+ import { defineMiddleware as defineMiddleware7 } from "@spfn/core/route";
18246
18941
  import { UnauthorizedError as UnauthorizedError4 } from "@spfn/core/errors";
18247
18942
  import { usersRepository as usersRepository4, userProfilesRepository as userProfilesRepository4 } from "@spfn/auth/server";
18248
- var oneTimeTokenAuth = defineMiddleware6("oneTimeTokenAuth", async (c, next) => {
18943
+ var oneTimeTokenAuth = defineMiddleware7("oneTimeTokenAuth", async (c, next) => {
18249
18944
  const token = c.req.query("token") ?? extractOTTHeader(c.req.header("Authorization"));
18250
18945
  if (!token) {
18251
18946
  throw new UnauthorizedError4({ message: "One-time token required: ?token=xxx or Authorization: OTT xxx" });
@@ -18284,12 +18979,12 @@ function extractOTTHeader(header) {
18284
18979
  }
18285
18980
 
18286
18981
  // src/server/middleware/ops-token-auth.ts
18287
- import { defineMiddleware as defineMiddleware7 } from "@spfn/core/route";
18982
+ import { defineMiddleware as defineMiddleware8 } from "@spfn/core/route";
18288
18983
  import { ForbiddenError as ForbiddenError6, UnauthorizedError as UnauthorizedError5 } from "@spfn/core/errors";
18289
18984
  function getOpsToken(c) {
18290
18985
  return c.get("opsToken") ?? null;
18291
18986
  }
18292
- var opsTokenAuth = defineMiddleware7("opsTokenAuth", async (c, next) => {
18987
+ var opsTokenAuth = defineMiddleware8("opsTokenAuth", async (c, next) => {
18293
18988
  const token = extractBearer2(c.req.header("Authorization"));
18294
18989
  if (!token) {
18295
18990
  throw new UnauthorizedError5({ message: "Ops token required: Authorization: Bearer <token>" });
@@ -18301,7 +18996,7 @@ var opsTokenAuth = defineMiddleware7("opsTokenAuth", async (c, next) => {
18301
18996
  c.set("opsToken", verified);
18302
18997
  await next();
18303
18998
  }, { skips: ["auth"] });
18304
- var requireOpsScope = defineMiddleware7(
18999
+ var requireOpsScope = defineMiddleware8(
18305
19000
  "opsScope",
18306
19001
  (...scopes) => async (c, next) => {
18307
19002
  const token = getOpsToken(c);
@@ -18362,18 +19057,18 @@ function chain(handlers) {
18362
19057
  // src/server/routes/invitations/index.ts
18363
19058
  init_types();
18364
19059
  init_esm();
18365
- import { Transactional as Transactional5 } from "@spfn/core/db";
18366
- import { rateLimitPolicy as rateLimitPolicy6 } from "@spfn/core/middleware";
18367
- import { defineRouter as defineRouter2, route as route6 } from "@spfn/core/route";
19060
+ import { Transactional as Transactional7 } from "@spfn/core/db";
19061
+ import { rateLimitPolicy as rateLimitPolicy8 } from "@spfn/core/middleware";
19062
+ import { defineRouter as defineRouter2, route as route8 } from "@spfn/core/route";
18368
19063
  var INVITATION_STATUSES2 = ["pending", "accepted", "expired", "cancelled"];
18369
- var getInvitation = route6.get("/_auth/invitations/:token").input({
19064
+ var getInvitation = route8.get("/_auth/invitations/:token").input({
18370
19065
  params: Type.Object({
18371
19066
  token: Type.String({
18372
19067
  pattern: UUID_PATTERN,
18373
19068
  description: "Invitation token (UUID v4)"
18374
19069
  })
18375
19070
  })
18376
- }).use([rateLimitPolicy6("auth-invitation-lookup", { limit: 30, windowMs: 6e4 })]).skip(["auth"]).handler(async (c) => {
19071
+ }).use([rateLimitPolicy8("auth-invitation-lookup", { limit: 30, windowMs: 6e4 })]).skip(["auth"]).handler(async (c) => {
18377
19072
  const { params } = await c.data();
18378
19073
  const token = params.token;
18379
19074
  const validation = await validateInvitation(token);
@@ -18393,7 +19088,7 @@ var getInvitation = route6.get("/_auth/invitations/:token").input({
18393
19088
  metadata: invitation.metadata || void 0
18394
19089
  };
18395
19090
  });
18396
- var acceptInvitation2 = route6.post("/_auth/invitations/accept").input({
19091
+ var acceptInvitation2 = route8.post("/_auth/invitations/accept").input({
18397
19092
  body: Type.Object({
18398
19093
  token: Type.String({
18399
19094
  pattern: UUID_PATTERN,
@@ -18412,7 +19107,7 @@ var acceptInvitation2 = route6.post("/_auth/invitations/accept").input({
18412
19107
  fingerprint: Type.String({ description: "Key fingerprint" }),
18413
19108
  algorithm: Type.Union(KEY_ALGORITHM.map((algo) => Type.Literal(algo)), { description: "Signature algorithm" })
18414
19109
  })
18415
- }).use([rateLimitPolicy6("auth-invitation-accept", { limit: 10, windowMs: 6e4 }), Transactional5()]).skip(["auth"]).handler(async (c) => {
19110
+ }).use([rateLimitPolicy8("auth-invitation-accept", { limit: 10, windowMs: 6e4 }), Transactional7()]).skip(["auth"]).handler(async (c) => {
18416
19111
  const { body } = await c.data();
18417
19112
  return await acceptInvitation({
18418
19113
  token: body.token,
@@ -18424,7 +19119,7 @@ var acceptInvitation2 = route6.post("/_auth/invitations/accept").input({
18424
19119
  ...deviceProvenance(c.raw)
18425
19120
  });
18426
19121
  });
18427
- var createInvitation2 = route6.post("/_auth/invitations").input({
19122
+ var createInvitation2 = route8.post("/_auth/invitations").input({
18428
19123
  body: Type.Object({
18429
19124
  email: Type.String({
18430
19125
  pattern: EMAIL_PATTERN2,
@@ -18469,7 +19164,7 @@ var createInvitation2 = route6.post("/_auth/invitations").input({
18469
19164
  invitationUrl
18470
19165
  };
18471
19166
  });
18472
- var listInvitations2 = route6.get("/_auth/invitations").input({
19167
+ var listInvitations2 = route8.get("/_auth/invitations").input({
18473
19168
  query: Type.Object({
18474
19169
  status: Type.Optional(Type.Union(
18475
19170
  INVITATION_STATUSES2.map((s) => Type.Literal(s)),
@@ -18498,7 +19193,7 @@ var listInvitations2 = route6.get("/_auth/invitations").input({
18498
19193
  invitations: formattedInvitations
18499
19194
  };
18500
19195
  });
18501
- var cancelInvitation2 = route6.post("/_auth/invitations/cancel").input({
19196
+ var cancelInvitation2 = route8.post("/_auth/invitations/cancel").input({
18502
19197
  body: Type.Object({
18503
19198
  id: Type.Number({
18504
19199
  description: "Invitation ID"
@@ -18519,7 +19214,7 @@ var cancelInvitation2 = route6.post("/_auth/invitations/cancel").input({
18519
19214
  cancelledAt: (/* @__PURE__ */ new Date()).toISOString()
18520
19215
  };
18521
19216
  });
18522
- var resendInvitation2 = route6.post("/_auth/invitations/resend").input({
19217
+ var resendInvitation2 = route8.post("/_auth/invitations/resend").input({
18523
19218
  body: Type.Object({
18524
19219
  id: Type.Number({
18525
19220
  description: "Invitation ID"
@@ -18540,7 +19235,7 @@ var resendInvitation2 = route6.post("/_auth/invitations/resend").input({
18540
19235
  expiresAt: updated.expiresAt.toISOString()
18541
19236
  };
18542
19237
  });
18543
- var deleteInvitation2 = route6.post("/_auth/invitations/delete").input({
19238
+ var deleteInvitation2 = route8.post("/_auth/invitations/delete").input({
18544
19239
  body: Type.Object({
18545
19240
  id: Type.Number({
18546
19241
  description: "Invitation ID"
@@ -18563,13 +19258,13 @@ var invitationRouter = defineRouter2({
18563
19258
 
18564
19259
  // src/server/routes/users/index.ts
18565
19260
  init_esm();
18566
- import { rateLimitPolicy as rateLimitPolicy7 } from "@spfn/core/middleware";
18567
- import { defineRouter as defineRouter3, route as route7 } from "@spfn/core/route";
18568
- var getUserProfile = route7.get("/_auth/users/profile").handler(async (c) => {
19261
+ import { rateLimitPolicy as rateLimitPolicy9 } from "@spfn/core/middleware";
19262
+ import { defineRouter as defineRouter3, route as route9 } from "@spfn/core/route";
19263
+ var getUserProfile = route9.get("/_auth/users/profile").handler(async (c) => {
18569
19264
  const { userId } = getAuth(c);
18570
19265
  return await getUserProfileService(userId);
18571
19266
  });
18572
- var updateUserProfile = route7.patch("/_auth/users/profile").input({
19267
+ var updateUserProfile = route9.patch("/_auth/users/profile").input({
18573
19268
  body: Type.Object({
18574
19269
  displayName: Type.Optional(Type.String({ description: "Display name shown in UI" })),
18575
19270
  firstName: Type.Optional(Type.String({ description: "First name" })),
@@ -18591,15 +19286,15 @@ var updateUserProfile = route7.patch("/_auth/users/profile").input({
18591
19286
  const { body } = await c.data();
18592
19287
  return await updateUserProfileService(userId, body);
18593
19288
  });
18594
- var checkUsername = route7.get("/_auth/users/username/check").input({
19289
+ var checkUsername = route9.get("/_auth/users/username/check").input({
18595
19290
  query: Type.Object({
18596
19291
  username: Type.String({ minLength: 1 })
18597
19292
  })
18598
- }).use([rateLimitPolicy7("auth-username-check", { limit: 30, windowMs: 6e4 })]).handler(async (c) => {
19293
+ }).use([rateLimitPolicy9("auth-username-check", { limit: 30, windowMs: 6e4 })]).handler(async (c) => {
18599
19294
  const { query } = await c.data();
18600
19295
  return { available: await checkUsernameAvailableService(query.username) };
18601
19296
  });
18602
- var updateUsername = route7.patch("/_auth/users/username").input({
19297
+ var updateUsername = route9.patch("/_auth/users/username").input({
18603
19298
  body: Type.Object({
18604
19299
  username: Type.Union([
18605
19300
  Type.String({ minLength: 1 }),
@@ -18611,7 +19306,7 @@ var updateUsername = route7.patch("/_auth/users/username").input({
18611
19306
  const { body } = await c.data();
18612
19307
  return await updateUsernameService(userId, body.username);
18613
19308
  });
18614
- var updateLocale = route7.patch("/_auth/users/locale").input({
19309
+ var updateLocale = route9.patch("/_auth/users/locale").input({
18615
19310
  body: Type.Object({
18616
19311
  locale: Type.String({ minLength: 1, description: "Locale code (e.g., en, ko, ja)" })
18617
19312
  })
@@ -18821,22 +19516,23 @@ var deleteCookie = (c, name, opt) => {
18821
19516
  // src/server/routes/oauth/index.ts
18822
19517
  init_types();
18823
19518
  init_schema3();
18824
- import { Transactional as Transactional6 } from "@spfn/core/db";
18825
- import { ValidationError as ValidationError16 } from "@spfn/core/errors";
18826
- import { rateLimitPolicy as rateLimitPolicy8 } from "@spfn/core/middleware";
18827
- import { defineRouter as defineRouter4, route as route8 } from "@spfn/core/route";
19519
+ init_config();
19520
+ import { Transactional as Transactional8 } from "@spfn/core/db";
19521
+ import { ValidationError as ValidationError17 } from "@spfn/core/errors";
19522
+ import { rateLimitPolicy as rateLimitPolicy10 } from "@spfn/core/middleware";
19523
+ import { defineRouter as defineRouter4, route as route10 } from "@spfn/core/route";
18828
19524
  var providerParams = Type.Object({
18829
19525
  provider: Type.Union(SOCIAL_PROVIDERS.map((p) => Type.Literal(p)), {
18830
19526
  description: "OAuth provider id (google, github, kakao, naver, superself)"
18831
19527
  })
18832
19528
  });
18833
- var oauthGoogleStart = route8.get("/_auth/oauth/google").input({
19529
+ var oauthGoogleStart = route10.get("/_auth/oauth/google").input({
18834
19530
  query: Type.Object({
18835
19531
  state: Type.String({
18836
19532
  description: "Encrypted OAuth state (returnUrl, publicKey, keyId, fingerprint, algorithm)"
18837
19533
  })
18838
19534
  })
18839
- }).use([rateLimitPolicy8("oauth-start", { limit: 20, windowMs: 6e4 })]).skip(["auth"]).handler(async (c) => {
19535
+ }).use([rateLimitPolicy10("oauth-start", { limit: 20, windowMs: 6e4 })]).skip(["auth"]).handler(async (c) => {
18840
19536
  const { query } = await c.data();
18841
19537
  if (!isGoogleOAuthEnabled()) {
18842
19538
  return c.redirect(buildOAuthErrorUrl("Google OAuth is not configured"));
@@ -18844,7 +19540,7 @@ var oauthGoogleStart = route8.get("/_auth/oauth/google").input({
18844
19540
  const authUrl = getGoogleAuthUrl(query.state);
18845
19541
  return c.redirect(authUrl);
18846
19542
  });
18847
- var oauthGoogleCallback = route8.get("/_auth/oauth/google/callback").input({
19543
+ var oauthGoogleCallback = route10.get("/_auth/oauth/google/callback").input({
18848
19544
  query: Type.Object({
18849
19545
  code: Type.Optional(Type.String({
18850
19546
  description: "Authorization code from Google"
@@ -18859,7 +19555,7 @@ var oauthGoogleCallback = route8.get("/_auth/oauth/google/callback").input({
18859
19555
  description: "Error description from Google"
18860
19556
  }))
18861
19557
  })
18862
- }).use([rateLimitPolicy8("oauth-callback", { limit: 30, windowMs: 6e4 }), Transactional6()]).skip(["auth"]).handler(async (c) => {
19558
+ }).use([rateLimitPolicy10("oauth-callback", { limit: 30, windowMs: 6e4 }), Transactional8()]).skip(["auth"]).handler(async (c) => {
18863
19559
  const { query } = await c.data();
18864
19560
  if (query.error) {
18865
19561
  const errorMessage = query.error_description || query.error;
@@ -18886,7 +19582,7 @@ var oauthGoogleCallback = route8.get("/_auth/oauth/google/callback").input({
18886
19582
  return c.redirect(buildOAuthErrorUrl(message));
18887
19583
  }
18888
19584
  });
18889
- var oauthStart = route8.post("/_auth/oauth/start").input({
19585
+ var oauthStart = route10.post("/_auth/oauth/start").input({
18890
19586
  body: Type.Object({
18891
19587
  provider: Type.Union(SOCIAL_PROVIDERS.map((p) => Type.Literal(p)), {
18892
19588
  description: "OAuth provider (google, github, kakao, naver)"
@@ -18910,10 +19606,10 @@ var oauthStart = route8.post("/_auth/oauth/start").input({
18910
19606
  description: "Custom metadata passed to authRegisterEvent (e.g. referral code, UTM params)"
18911
19607
  }))
18912
19608
  })
18913
- }).use([rateLimitPolicy8("oauth-start", { limit: 20, windowMs: 6e4 })]).skip(["auth"]).handler(async (c) => {
19609
+ }).use([rateLimitPolicy10("oauth-start", { limit: 20, windowMs: 6e4 })]).skip(["auth"]).handler(async (c) => {
18914
19610
  const { body } = await c.data();
18915
19611
  if (!isSafeReturnPath(body.returnUrl)) {
18916
- throw new ValidationError16({ message: "returnUrl must be a relative path within the app" });
19612
+ throw new ValidationError17({ message: "returnUrl must be a relative path within the app" });
18917
19613
  }
18918
19614
  const nonce = generateOAuthNonce();
18919
19615
  setCookie(c.raw, COOKIE_NAMES.OAUTH_CSRF, nonce, {
@@ -18926,12 +19622,12 @@ var oauthStart = route8.post("/_auth/oauth/start").input({
18926
19622
  const result = await oauthStartService({ ...body, nonce });
18927
19623
  return result;
18928
19624
  });
18929
- var oauthProviders = route8.get("/_auth/oauth/providers").skip(["auth"]).handler(async () => {
19625
+ var oauthProviders = route10.get("/_auth/oauth/providers").skip(["auth"]).handler(async () => {
18930
19626
  return {
18931
19627
  providers: getEnabledOAuthProviders()
18932
19628
  };
18933
19629
  });
18934
- var getGoogleOAuthUrl = route8.post("/_auth/oauth/google/url").input({
19630
+ var getGoogleOAuthUrl = route10.post("/_auth/oauth/google/url").input({
18935
19631
  body: Type.Object({
18936
19632
  returnUrl: Type.Optional(Type.String({
18937
19633
  description: "URL to redirect after OAuth success"
@@ -18943,44 +19639,45 @@ var getGoogleOAuthUrl = route8.post("/_auth/oauth/google/url").input({
18943
19639
  description: "Encrypted OAuth state (injected by interceptor)"
18944
19640
  }))
18945
19641
  })
18946
- }).use([rateLimitPolicy8("oauth-start", { limit: 20, windowMs: 6e4 })]).skip(["auth"]).handler(async (c) => {
19642
+ }).use([rateLimitPolicy10("oauth-start", { limit: 20, windowMs: 6e4 })]).skip(["auth"]).handler(async (c) => {
18947
19643
  const { body } = await c.data();
18948
19644
  if (!isGoogleOAuthEnabled()) {
18949
- throw new ValidationError16({ message: "Google OAuth is not configured" });
19645
+ throw new ValidationError17({ message: "Google OAuth is not configured" });
18950
19646
  }
18951
19647
  if (!body.state) {
18952
- throw new ValidationError16({
19648
+ throw new ValidationError17({
18953
19649
  message: "OAuth state is required. Ensure the OAuth interceptor is configured."
18954
19650
  });
18955
19651
  }
18956
19652
  return { authUrl: getGoogleAuthUrl(body.state) };
18957
19653
  });
18958
- var oauthFinalize = route8.post("/_auth/oauth/finalize").input({
19654
+ var oauthFinalize = route10.post("/_auth/oauth/finalize").input({
18959
19655
  body: Type.Object({
18960
19656
  userId: Type.String({ description: "User ID from OAuth callback" }),
18961
19657
  keyId: Type.String({ description: "Key ID from OAuth state" }),
18962
19658
  returnUrl: Type.Optional(Type.String({ description: "URL to redirect after login" }))
18963
19659
  })
18964
- }).use([rateLimitPolicy8("oauth-start", { limit: 20, windowMs: 6e4 })]).skip(["auth"]).handler(async (c) => {
19660
+ }).use([rateLimitPolicy10("oauth-start", { limit: 20, windowMs: 6e4 })]).skip(["auth"]).handler(async (c) => {
18965
19661
  const { body } = await c.data();
18966
19662
  if (body.returnUrl && !isSafeReturnPath(body.returnUrl)) {
18967
- throw new ValidationError16({ message: "returnUrl must be a relative path within the app" });
19663
+ throw new ValidationError17({ message: "returnUrl must be a relative path within the app" });
18968
19664
  }
18969
19665
  return {
18970
19666
  success: true,
18971
19667
  userId: body.userId,
18972
19668
  keyId: body.keyId,
18973
- returnUrl: body.returnUrl || "/"
19669
+ returnUrl: body.returnUrl || "/",
19670
+ ...await keySessionBindingService(body.keyId)
18974
19671
  };
18975
19672
  });
18976
- var oauthProviderStart = route8.get("/_auth/oauth/:provider").input({
19673
+ var oauthProviderStart = route10.get("/_auth/oauth/:provider").input({
18977
19674
  params: providerParams,
18978
19675
  query: Type.Object({
18979
19676
  state: Type.String({
18980
19677
  description: "Encrypted OAuth state (returnUrl, publicKey, keyId, fingerprint, algorithm)"
18981
19678
  })
18982
19679
  })
18983
- }).use([rateLimitPolicy8("oauth-start", { limit: 20, windowMs: 6e4 })]).skip(["auth"]).handler(async (c) => {
19680
+ }).use([rateLimitPolicy10("oauth-start", { limit: 20, windowMs: 6e4 })]).skip(["auth"]).handler(async (c) => {
18984
19681
  const { params, query } = await c.data();
18985
19682
  const provider = getOAuthProvider(params.provider);
18986
19683
  if (!provider?.isEnabled()) {
@@ -18988,7 +19685,7 @@ var oauthProviderStart = route8.get("/_auth/oauth/:provider").input({
18988
19685
  }
18989
19686
  return c.redirect(provider.getAuthUrl(query.state));
18990
19687
  });
18991
- var oauthProviderCallback = route8.get("/_auth/oauth/:provider/callback").input({
19688
+ var oauthProviderCallback = route10.get("/_auth/oauth/:provider/callback").input({
18992
19689
  params: providerParams,
18993
19690
  query: Type.Object({
18994
19691
  code: Type.Optional(Type.String({
@@ -19004,7 +19701,7 @@ var oauthProviderCallback = route8.get("/_auth/oauth/:provider/callback").input(
19004
19701
  description: "Error description from provider"
19005
19702
  }))
19006
19703
  })
19007
- }).use([rateLimitPolicy8("oauth-callback", { limit: 30, windowMs: 6e4 }), Transactional6()]).skip(["auth"]).handler(async (c) => {
19704
+ }).use([rateLimitPolicy10("oauth-callback", { limit: 30, windowMs: 6e4 }), Transactional8()]).skip(["auth"]).handler(async (c) => {
19008
19705
  const { params, query } = await c.data();
19009
19706
  if (query.error) {
19010
19707
  const errorMessage = query.error_description || query.error;
@@ -19031,7 +19728,7 @@ var oauthProviderCallback = route8.get("/_auth/oauth/:provider/callback").input(
19031
19728
  return c.redirect(buildOAuthErrorUrl(message));
19032
19729
  }
19033
19730
  });
19034
- var getProviderOAuthUrl = route8.post("/_auth/oauth/:provider/url").input({
19731
+ var getProviderOAuthUrl = route10.post("/_auth/oauth/:provider/url").input({
19035
19732
  params: providerParams,
19036
19733
  body: Type.Object({
19037
19734
  returnUrl: Type.Optional(Type.String({
@@ -19044,17 +19741,17 @@ var getProviderOAuthUrl = route8.post("/_auth/oauth/:provider/url").input({
19044
19741
  description: "Encrypted OAuth state (injected by interceptor)"
19045
19742
  }))
19046
19743
  })
19047
- }).use([rateLimitPolicy8("oauth-start", { limit: 20, windowMs: 6e4 })]).skip(["auth"]).handler(async (c) => {
19744
+ }).use([rateLimitPolicy10("oauth-start", { limit: 20, windowMs: 6e4 })]).skip(["auth"]).handler(async (c) => {
19048
19745
  const { params, body } = await c.data();
19049
19746
  const provider = requireEnabledProvider(params.provider);
19050
19747
  if (!body.state) {
19051
- throw new ValidationError16({
19748
+ throw new ValidationError17({
19052
19749
  message: "OAuth state is required. Ensure the OAuth interceptor is configured."
19053
19750
  });
19054
19751
  }
19055
19752
  return { authUrl: provider.getAuthUrl(body.state) };
19056
19753
  });
19057
- var oauthNative = route8.post("/_auth/oauth/:provider/native").input({
19754
+ var oauthNative = route10.post("/_auth/oauth/:provider/native").input({
19058
19755
  params: providerParams,
19059
19756
  body: Type.Object({
19060
19757
  idToken: Type.String({ description: "id_token from native/web social SDK" }),
@@ -19081,7 +19778,7 @@ var oauthNative = route8.post("/_auth/oauth/:provider/native").input({
19081
19778
  description: "Custom metadata passed to auth events (e.g. referral code, UTM params)"
19082
19779
  }))
19083
19780
  })
19084
- }).use([rateLimitPolicy8("oauth-native", {
19781
+ }).use([rateLimitPolicy10("oauth-native", {
19085
19782
  limit: 5,
19086
19783
  windowMs: 6e4,
19087
19784
  by: byIpAndIdToken({ ipLimit: 20 })
@@ -19129,12 +19826,12 @@ async function processUnlinkNotify(provider, raw) {
19129
19826
  return error instanceof UnlinkNotifyRejection ? error.status : 400;
19130
19827
  }
19131
19828
  }
19132
- var oauthUnlinkNotify = route8.post("/_auth/oauth/:provider/unlink-notify").input({ params: providerParams }).use([rateLimitPolicy8("oauth-unlink-notify", { limit: 300, windowMs: 6e4 })]).skip(["auth"]).handler(async (c) => {
19829
+ var oauthUnlinkNotify = route10.post("/_auth/oauth/:provider/unlink-notify").input({ params: providerParams }).use([rateLimitPolicy10("oauth-unlink-notify", { limit: 300, windowMs: 6e4 })]).skip(["auth"]).handler(async (c) => {
19133
19830
  const { params } = await c.data();
19134
19831
  const status = await processUnlinkNotify(params.provider, c.raw);
19135
19832
  return status === 204 ? c.noContent() : c.json({}, status);
19136
19833
  });
19137
- var oauthUnlinkNotifyGet = route8.get("/_auth/oauth/:provider/unlink-notify").input({ params: providerParams }).use([rateLimitPolicy8("oauth-unlink-notify", { limit: 300, windowMs: 6e4 })]).skip(["auth"]).handler(async (c) => {
19834
+ var oauthUnlinkNotifyGet = route10.get("/_auth/oauth/:provider/unlink-notify").input({ params: providerParams }).use([rateLimitPolicy10("oauth-unlink-notify", { limit: 300, windowMs: 6e4 })]).skip(["auth"]).handler(async (c) => {
19138
19835
  const { params } = await c.data();
19139
19836
  const status = await processUnlinkNotify(params.provider, c.raw);
19140
19837
  return status === 204 ? c.noContent() : c.json({}, status);
@@ -19157,8 +19854,8 @@ var oauthRouter = defineRouter4({
19157
19854
  // src/server/routes/admin/index.ts
19158
19855
  init_esm();
19159
19856
  import { ForbiddenError as ForbiddenError7 } from "@spfn/core/errors";
19160
- import { route as route9 } from "@spfn/core/route";
19161
- var listRoles = route9.get("/_auth/admin/roles").input({
19857
+ import { route as route11 } from "@spfn/core/route";
19858
+ var listRoles = route11.get("/_auth/admin/roles").input({
19162
19859
  query: Type.Object({
19163
19860
  includeInactive: Type.Optional(Type.Boolean({
19164
19861
  description: "Include inactive roles (default: false)"
@@ -19169,7 +19866,7 @@ var listRoles = route9.get("/_auth/admin/roles").input({
19169
19866
  const roles2 = await getAllRoles(query.includeInactive ?? false);
19170
19867
  return { roles: roles2 };
19171
19868
  });
19172
- var createAdminRole = route9.post("/_auth/admin/roles").input({
19869
+ var createAdminRole = route11.post("/_auth/admin/roles").input({
19173
19870
  body: Type.Object({
19174
19871
  name: Type.String({ description: "Unique role name (slug)" }),
19175
19872
  displayName: Type.String({ description: "Human-readable role name" }),
@@ -19191,7 +19888,7 @@ var createAdminRole = route9.post("/_auth/admin/roles").input({
19191
19888
  });
19192
19889
  return { role };
19193
19890
  });
19194
- var updateAdminRole = route9.patch("/_auth/admin/roles/:id").input({
19891
+ var updateAdminRole = route11.patch("/_auth/admin/roles/:id").input({
19195
19892
  params: Type.Object({
19196
19893
  id: Type.Number({ description: "Role ID" })
19197
19894
  }),
@@ -19206,7 +19903,7 @@ var updateAdminRole = route9.patch("/_auth/admin/roles/:id").input({
19206
19903
  const role = await updateRole(params.id, body);
19207
19904
  return { role };
19208
19905
  });
19209
- var deleteAdminRole = route9.delete("/_auth/admin/roles/:id").input({
19906
+ var deleteAdminRole = route11.delete("/_auth/admin/roles/:id").input({
19210
19907
  params: Type.Object({
19211
19908
  id: Type.Number({ description: "Role ID" })
19212
19909
  })
@@ -19215,7 +19912,7 @@ var deleteAdminRole = route9.delete("/_auth/admin/roles/:id").input({
19215
19912
  await deleteRole(params.id);
19216
19913
  return c.noContent();
19217
19914
  });
19218
- var updateUserRole = route9.patch("/_auth/admin/users/:userId/role").input({
19915
+ var updateUserRole = route11.patch("/_auth/admin/users/:userId/role").input({
19219
19916
  params: Type.Object({
19220
19917
  userId: Type.Number({ description: "User ID" })
19221
19918
  }),
@@ -19240,17 +19937,17 @@ var updateUserRole = route9.patch("/_auth/admin/users/:userId/role").input({
19240
19937
  // src/server/routes/deletion/index.ts
19241
19938
  init_esm();
19242
19939
  init_schema3();
19243
- import { Transactional as Transactional7 } from "@spfn/core/db";
19244
- import { rateLimitPolicy as rateLimitPolicy9 } from "@spfn/core/middleware";
19245
- import { defineRouter as defineRouter5, route as route10 } from "@spfn/core/route";
19246
- var requestAccountDeletion = route10.post("/_auth/deletion/request").input({
19940
+ import { Transactional as Transactional9 } from "@spfn/core/db";
19941
+ import { rateLimitPolicy as rateLimitPolicy11 } from "@spfn/core/middleware";
19942
+ import { defineRouter as defineRouter5, route as route12 } from "@spfn/core/route";
19943
+ var requestAccountDeletion = route12.post("/_auth/deletion/request").input({
19247
19944
  body: Type.Object({
19248
19945
  password: Type.Optional(Type.String({ minLength: 1, description: "Current password, if the account has one" })),
19249
19946
  verificationToken: Type.Optional(Type.String({ description: "Verification token (purpose: account_deletion), for passwordless/OAuth-only accounts" })),
19250
19947
  reason: Type.Optional(Type.String({ maxLength: 500, description: "Optional free-text reason" })),
19251
19948
  immediate: Type.Optional(Type.Boolean({ description: "Skip the grace period \u2014 requires deletion.allowSelfImmediate on the server" }))
19252
19949
  })
19253
- }).use([rateLimitPolicy9("auth-deletion-request", { limit: 5, windowMs: 6e4 }), Transactional7()]).handler(async (c) => {
19950
+ }).use([rateLimitPolicy11("auth-deletion-request", { limit: 5, windowMs: 6e4 }), Transactional9()]).handler(async (c) => {
19254
19951
  const { body } = await c.data();
19255
19952
  const { userId } = getAuth(c);
19256
19953
  const result = await requestAccountDeletionService(Number(userId), {
@@ -19264,7 +19961,7 @@ var requestAccountDeletion = route10.post("/_auth/deletion/request").input({
19264
19961
  purgeScheduledAt: result.purgeScheduledAt.toISOString()
19265
19962
  };
19266
19963
  });
19267
- var cancelAccountDeletion = route10.post("/_auth/deletion/cancel").input({
19964
+ var cancelAccountDeletion = route12.post("/_auth/deletion/cancel").input({
19268
19965
  body: Type.Object({
19269
19966
  email: Type.Optional(EmailSchema),
19270
19967
  phone: Type.Optional(PhoneSchema),
@@ -19275,7 +19972,7 @@ var cancelAccountDeletion = route10.post("/_auth/deletion/cancel").input({
19275
19972
  // email/phone + password|verificationToken
19276
19973
  description: "Email or phone must be provided with password or verificationToken"
19277
19974
  })
19278
- }).use([rateLimitPolicy9("auth-deletion-cancel", { limit: 10, windowMs: 6e4, by: byIpAndAccount({ ipLimit: 50 }) }), Transactional7()]).skip(["auth"]).handler(async (c) => {
19975
+ }).use([rateLimitPolicy11("auth-deletion-cancel", { limit: 10, windowMs: 6e4, by: byIpAndAccount({ ipLimit: 50 }) }), Transactional9()]).skip(["auth"]).handler(async (c) => {
19279
19976
  const { body } = await c.data();
19280
19977
  await cancelAccountDeletionService(body);
19281
19978
  return c.noContent();
@@ -19287,7 +19984,7 @@ var deletionRouter = defineRouter5({
19287
19984
 
19288
19985
  // src/server/routes/ops-tokens/index.ts
19289
19986
  init_esm();
19290
- import { route as route11 } from "@spfn/core/route";
19987
+ import { route as route13 } from "@spfn/core/route";
19291
19988
  import { BadRequestError as BadRequestError2, NotFoundError as NotFoundError5 } from "@spfn/core/errors";
19292
19989
  var MAX_EXPIRY_DAYS = 36500;
19293
19990
  function toSummary2(record) {
@@ -19301,7 +19998,7 @@ function toSummary2(record) {
19301
19998
  createdAt: record.createdAt ? record.createdAt.toISOString() : null
19302
19999
  };
19303
20000
  }
19304
- var issueOpsToken = route11.post("/_auth/ops-tokens").input({
20001
+ var issueOpsToken = route13.post("/_auth/ops-tokens").input({
19305
20002
  body: Type.Object({
19306
20003
  name: Type.String({ minLength: 1, description: "Operator-facing label" }),
19307
20004
  scopes: Type.Array(Type.String({ minLength: 1 }), {
@@ -19327,10 +20024,10 @@ var issueOpsToken = route11.post("/_auth/ops-tokens").input({
19327
20024
  const { token, record } = await issueOpsTokenService(body.name, body.scopes, expiresAt);
19328
20025
  return { token, opsToken: toSummary2(record) };
19329
20026
  });
19330
- var listOpsTokens = route11.get("/_auth/ops-tokens").use([authenticate, requireRole("admin", "superadmin")]).handler(async () => {
20027
+ var listOpsTokens = route13.get("/_auth/ops-tokens").use([authenticate, requireRole("admin", "superadmin")]).handler(async () => {
19331
20028
  return { opsTokens: (await listOpsTokensService()).map(toSummary2) };
19332
20029
  });
19333
- var revokeOpsToken = route11.delete("/_auth/ops-tokens/:id").input({
20030
+ var revokeOpsToken = route13.delete("/_auth/ops-tokens/:id").input({
19334
20031
  params: Type.Object({
19335
20032
  id: Type.Number({ description: "Ops token id" })
19336
20033
  })
@@ -19345,8 +20042,8 @@ var revokeOpsToken = route11.delete("/_auth/ops-tokens/:id").input({
19345
20042
 
19346
20043
  // src/server/routes/oauth2/index.ts
19347
20044
  init_esm();
19348
- import { route as route12 } from "@spfn/core/route";
19349
- import { getClientIp as getClientIp3, rateLimitPolicy as rateLimitPolicy10 } from "@spfn/core/middleware";
20045
+ import { route as route14 } from "@spfn/core/route";
20046
+ import { getClientIp as getClientIp3, rateLimitPolicy as rateLimitPolicy12 } from "@spfn/core/middleware";
19350
20047
 
19351
20048
  // src/server/routes/oauth2/http.ts
19352
20049
  import { NotFoundError as NotFoundError6 } from "@spfn/core/errors";
@@ -19405,7 +20102,7 @@ function flatten(parsed) {
19405
20102
  }
19406
20103
 
19407
20104
  // src/server/routes/oauth2/index.ts
19408
- var registerOAuth2Client = route12.post("/_auth/oauth2/register").use([rateLimitPolicy10("auth-oauth2-register", { limit: 10, windowMs: 6e4 })]).skip(["auth"]).handler(async (c) => {
20105
+ var registerOAuth2Client = route14.post("/_auth/oauth2/register").use([rateLimitPolicy12("auth-oauth2-register", { limit: 10, windowMs: 6e4 })]).skip(["auth"]).handler(async (c) => {
19409
20106
  requireAuthorizationServer();
19410
20107
  const body = await readRegistrationBody(c.raw);
19411
20108
  const result = await registerOAuth2ClientService(body, getClientIp3(c.raw) || null);
@@ -19422,17 +20119,17 @@ async function readRegistrationBody(c) {
19422
20119
  return {};
19423
20120
  }
19424
20121
  }
19425
- var listOAuth2Grants = route12.get("/_auth/oauth2/grants").use([authenticate]).handler(async (c) => {
20122
+ var listOAuth2Grants = route14.get("/_auth/oauth2/grants").use([authenticate]).handler(async (c) => {
19426
20123
  requireAuthorizationServer();
19427
20124
  return { grants: await listOAuth2GrantsService(Number(getAuth(c).userId)) };
19428
20125
  });
19429
- var revokeOAuth2Grant = route12.delete("/_auth/oauth2/grants/:id").input({ params: Type.Object({ id: Type.Number({ description: "Grant id from the list" }) }) }).use([authenticate]).handler(async (c) => {
20126
+ var revokeOAuth2Grant = route14.delete("/_auth/oauth2/grants/:id").input({ params: Type.Object({ id: Type.Number({ description: "Grant id from the list" }) }) }).use([authenticate]).handler(async (c) => {
19430
20127
  requireAuthorizationServer();
19431
20128
  const { params } = await c.data();
19432
20129
  await revokeOAuth2GrantService(params.id, Number(getAuth(c).userId));
19433
20130
  return { revoked: true };
19434
20131
  });
19435
- var oauth2AuthorizationServerMetadata = route12.get("/.well-known/oauth-authorization-server").skip(["auth"]).handler(async (c) => {
20132
+ var oauth2AuthorizationServerMetadata = route14.get("/.well-known/oauth-authorization-server").skip(["auth"]).handler(async (c) => {
19436
20133
  const config4 = requireAuthorizationServer();
19437
20134
  return c.json({
19438
20135
  issuer: config4.issuer,
@@ -19450,8 +20147,8 @@ var oauth2AuthorizationServerMetadata = route12.get("/.well-known/oauth-authoriz
19450
20147
 
19451
20148
  // src/server/routes/oauth2/authorize.ts
19452
20149
  init_esm();
19453
- import { route as route13 } from "@spfn/core/route";
19454
- import { rateLimitPolicy as rateLimitPolicy11 } from "@spfn/core/middleware";
20150
+ import { route as route15 } from "@spfn/core/route";
20151
+ import { rateLimitPolicy as rateLimitPolicy13 } from "@spfn/core/middleware";
19455
20152
  var AUTHORIZE_FIELDS = {
19456
20153
  client_id: Type.String({ minLength: 1, description: "client_id from dynamic registration" }),
19457
20154
  redirect_uri: Type.String({ minLength: 1, description: "Where the code is sent; must be registered" }),
@@ -19472,17 +20169,17 @@ function toParams(input) {
19472
20169
  state: input.state
19473
20170
  };
19474
20171
  }
19475
- var authorizeRateLimit = rateLimitPolicy11("auth-oauth2-authorize", {
20172
+ var authorizeRateLimit = rateLimitPolicy13("auth-oauth2-authorize", {
19476
20173
  limit: 30,
19477
20174
  windowMs: 6e4,
19478
20175
  by: byIpAndCaller({ ipLimit: 120 })
19479
20176
  });
19480
- var getOAuth2Authorize = route13.get("/_auth/oauth2/authorize").input({ query: Type.Object(AUTHORIZE_FIELDS) }).use([authenticate, authorizeRateLimit]).handler(async (c) => {
20177
+ var getOAuth2Authorize = route15.get("/_auth/oauth2/authorize").input({ query: Type.Object(AUTHORIZE_FIELDS) }).use([authenticate, authorizeRateLimit]).handler(async (c) => {
19481
20178
  requireAuthorizationServer();
19482
20179
  const { query } = await c.data();
19483
20180
  return await describeOAuth2AuthorizeRequestService(toParams(query));
19484
20181
  });
19485
- var createOAuth2AuthorizationCode = route13.post("/_auth/oauth2/authorize").input({
20182
+ var createOAuth2AuthorizationCode = route15.post("/_auth/oauth2/authorize").input({
19486
20183
  body: Type.Object({
19487
20184
  ...AUTHORIZE_FIELDS,
19488
20185
  approve: Type.Boolean({ description: "What the account owner decided" })
@@ -19498,9 +20195,9 @@ var createOAuth2AuthorizationCode = route13.post("/_auth/oauth2/authorize").inpu
19498
20195
  });
19499
20196
 
19500
20197
  // src/server/routes/oauth2/token.ts
19501
- import { route as route14 } from "@spfn/core/route";
19502
- import { rateLimitPolicy as rateLimitPolicy12 } from "@spfn/core/middleware";
19503
- var oauth2Token = route14.post("/_auth/oauth2/token").use([rateLimitPolicy12("auth-oauth2-token", { limit: 60, windowMs: 6e4 })]).skip(["auth"]).handler(async (c) => {
20198
+ import { route as route16 } from "@spfn/core/route";
20199
+ import { rateLimitPolicy as rateLimitPolicy14 } from "@spfn/core/middleware";
20200
+ var oauth2Token = route16.post("/_auth/oauth2/token").use([rateLimitPolicy14("auth-oauth2-token", { limit: 60, windowMs: 6e4 })]).skip(["auth"]).handler(async (c) => {
19504
20201
  requireAuthorizationServer();
19505
20202
  const result = await oauth2TokenService(await readOAuth2Body(c.raw));
19506
20203
  if (!result.ok) {
@@ -19508,7 +20205,7 @@ var oauth2Token = route14.post("/_auth/oauth2/token").use([rateLimitPolicy12("au
19508
20205
  }
19509
20206
  return oauth2JsonResponse(c.raw, 200, result.tokens);
19510
20207
  });
19511
- var oauth2Revoke = route14.post("/_auth/oauth2/revoke").use([rateLimitPolicy12("auth-oauth2-revoke", { limit: 60, windowMs: 6e4 })]).skip(["auth"]).handler(async (c) => {
20208
+ var oauth2Revoke = route16.post("/_auth/oauth2/revoke").use([rateLimitPolicy14("auth-oauth2-revoke", { limit: 60, windowMs: 6e4 })]).skip(["auth"]).handler(async (c) => {
19512
20209
  requireAuthorizationServer();
19513
20210
  const body = await readOAuth2Body(c.raw);
19514
20211
  if (!body.client_id) {
@@ -19571,6 +20268,13 @@ var mainAuthRouter = defineRouter6({
19571
20268
  consumeRevokeAllLink: consumeRevokeAllLink2,
19572
20269
  changePassword,
19573
20270
  getAuthSession,
20271
+ // Session binding routes (#97)
20272
+ setSessionBinding,
20273
+ getSessionBinding,
20274
+ sessionBindingDisableOptions,
20275
+ // Session renewal routes (#97) — public, like the sign-in paths
20276
+ sessionRenewOptions,
20277
+ sessionRenewVerify,
19574
20278
  // One-Time Token routes
19575
20279
  issueOneTimeToken,
19576
20280
  // Account deletion routes
@@ -19732,6 +20436,7 @@ function shouldRotateKey(createdAt, rotationDays = 90) {
19732
20436
  }
19733
20437
 
19734
20438
  // src/server/lib/session.ts
20439
+ init_logger();
19735
20440
  import * as jose2 from "jose";
19736
20441
  import { env as env19 } from "@spfn/auth/config";
19737
20442
  import { env as coreEnv } from "@spfn/core/config";
@@ -19817,9 +20522,16 @@ async function shouldRefreshSession(jwt4, thresholdHours = 24) {
19817
20522
  return hoursRemaining < thresholdHours;
19818
20523
  }
19819
20524
 
20525
+ // src/server/lib/index.ts
20526
+ init_config();
20527
+
20528
+ // src/server.ts
20529
+ init_logger();
20530
+
19820
20531
  // src/server/setup.ts
19821
20532
  import { env as env20 } from "@spfn/auth/config";
19822
20533
  import { getRoleByName as getRoleByName2 } from "@spfn/auth/server";
20534
+ init_logger();
19823
20535
  init_repositories();
19824
20536
  function parseAdminAccounts() {
19825
20537
  const accounts = [];
@@ -19940,7 +20652,11 @@ async function ensureAdminExists() {
19940
20652
  }
19941
20653
  }
19942
20654
 
20655
+ // src/server/lifecycle.ts
20656
+ init_logger();
20657
+
19943
20658
  // src/server/lib/oauth/redirect-uri-check.ts
20659
+ init_logger();
19944
20660
  var PROVIDERS = ["google", "kakao", "naver", "github"];
19945
20661
  var OPT_OUT_VAR = "SPFN_AUTH_OAUTH_CALLBACK_ORIGIN_CHECK";
19946
20662
  function redirectUriVar(provider) {
@@ -20045,6 +20761,7 @@ import { defineJobRouter } from "@spfn/core/job";
20045
20761
 
20046
20762
  // src/server/jobs/deletion-purge.ts
20047
20763
  import { job as job2 } from "@spfn/core/job";
20764
+ init_logger();
20048
20765
  function createAuthDeletionPurgeJob(cronExpression = DEFAULT_DELETION_PURGE_CRON) {
20049
20766
  return job2("auth.deletion.purge").cron(cronExpression).options({ retryLimit: 1 }).handler(async () => {
20050
20767
  const result = await sweepDuePurges();
@@ -20056,6 +20773,7 @@ function createAuthDeletionPurgeJob(cronExpression = DEFAULT_DELETION_PURGE_CRON
20056
20773
 
20057
20774
  // src/server/jobs/oauth2-client-purge.ts
20058
20775
  import { job as job3 } from "@spfn/core/job";
20776
+ init_logger();
20059
20777
  var DEFAULT_OAUTH2_CLIENT_PURGE_CRON = "0 5 * * *";
20060
20778
  function createOAuth2ClientPurgeJob(cronExpression = DEFAULT_OAUTH2_CLIENT_PURGE_CRON) {
20061
20779
  return job3("auth.oauth2.client-purge").cron(cronExpression).options({ retryLimit: 1 }).handler(async () => {
@@ -20068,6 +20786,7 @@ function createOAuth2ClientPurgeJob(cronExpression = DEFAULT_OAUTH2_CLIENT_PURGE
20068
20786
 
20069
20787
  // src/server/jobs/revoke-all-token-purge.ts
20070
20788
  import { job as job4 } from "@spfn/core/job";
20789
+ init_logger();
20071
20790
  var DEFAULT_REVOKE_ALL_TOKEN_PURGE_CRON = "0 6 * * *";
20072
20791
  function createRevokeAllTokenPurgeJob(cronExpression = DEFAULT_REVOKE_ALL_TOKEN_PURGE_CRON) {
20073
20792
  return job4("auth.revoke-all-token-purge").cron(cronExpression).options({ retryLimit: 1 }).handler(async () => {
@@ -20080,6 +20799,7 @@ function createRevokeAllTokenPurgeJob(cronExpression = DEFAULT_REVOKE_ALL_TOKEN_
20080
20799
 
20081
20800
  // src/server/jobs/mfa-sweep.ts
20082
20801
  import { job as job5 } from "@spfn/core/job";
20802
+ init_logger();
20083
20803
  var DEFAULT_MFA_SWEEP_CRON = "0 7 * * *";
20084
20804
  function createMfaSweepJob(cronExpression = DEFAULT_MFA_SWEEP_CRON) {
20085
20805
  return job5("auth.mfa.sweep").cron(cronExpression).options({ retryLimit: 1 }).handler(async () => {
@@ -20166,6 +20886,7 @@ export {
20166
20886
  PublicKeySchema,
20167
20887
  RolePermissionsRepository,
20168
20888
  RolesRepository,
20889
+ SESSION_BINDINGS,
20169
20890
  SOCIAL_PROVIDERS,
20170
20891
  STALE_CLIENT_MAX_AGE_MS,
20171
20892
  SUPPORTED_GRANT_TYPES,
@@ -20192,6 +20913,8 @@ export {
20192
20913
  accountDeletionRequests,
20193
20914
  accountDeletionRequestsRepository,
20194
20915
  addPermissionToRole,
20916
+ admitBearerKey,
20917
+ admitsForRenewal,
20195
20918
  appleProvider,
20196
20919
  approveDeviceAuthService,
20197
20920
  approveOAuth2AuthorizeService,
@@ -20216,6 +20939,8 @@ export {
20216
20939
  mainAuthRouter as authRouter,
20217
20940
  authSchema,
20218
20941
  authenticate,
20942
+ authenticateForRenewal,
20943
+ bearerAuthContext,
20219
20944
  buildOAuthErrorUrl,
20220
20945
  cancelAccountDeletionService,
20221
20946
  cancelInvitation,
@@ -20254,11 +20979,14 @@ export {
20254
20979
  deviceAuthorizations,
20255
20980
  deviceAuthorizationsRepository,
20256
20981
  disableMfaService,
20982
+ disableSessionBindingService,
20983
+ enableSessionBindingService,
20257
20984
  encryptToken,
20258
20985
  exchangeCodeForTokens,
20259
20986
  expireOldInvitations,
20260
20987
  finishPasskeyEnrollmentService,
20261
20988
  finishPasskeyLoginService,
20989
+ finishSessionRenewService,
20262
20990
  formatUserCode,
20263
20991
  generateAccessToken,
20264
20992
  generateAuthorizationCode,
@@ -20276,6 +21004,9 @@ export {
20276
21004
  getAuthConfig,
20277
21005
  getAuthSessionService,
20278
21006
  getAuthorizationServerConfig,
21007
+ getBoundKeyRenewGraceMs,
21008
+ getBoundKeyTtlMs,
21009
+ getConcurrentUseWindowMs,
20279
21010
  getCsrfExemptPaths,
20280
21011
  getCsrfMode,
20281
21012
  getDeletionConfig,
@@ -20305,7 +21036,9 @@ export {
20305
21036
  getRole,
20306
21037
  getRoleByName,
20307
21038
  getRolePermissions,
21039
+ getSessionBindingService,
20308
21040
  getSessionInfo,
21041
+ getSessionRenewPath,
20309
21042
  getSessionTtl,
20310
21043
  getUser,
20311
21044
  getUserByEmailService,
@@ -20344,6 +21077,7 @@ export {
20344
21077
  kakaoProvider,
20345
21078
  keyRevokeAllTokens,
20346
21079
  keyRevokeAllTokensRepository,
21080
+ keySessionBindingService,
20347
21081
  keysRepository,
20348
21082
  linkMailJob,
20349
21083
  listInvitations,
@@ -20459,6 +21193,8 @@ export {
20459
21193
  startDeviceAuthService,
20460
21194
  startPasskeyEnrollmentService,
20461
21195
  startPasskeyLoginService,
21196
+ startSessionBindingDisableService,
21197
+ startSessionRenewService,
20462
21198
  startStepUpService,
20463
21199
  startTotpEnrolmentService,
20464
21200
  stepUpService,