@feelflow/ffid-sdk 5.12.0 → 5.13.0

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.
@@ -124,6 +124,9 @@ function normalizeUserinfo(raw) {
124
124
  return {
125
125
  sub: raw.sub,
126
126
  email: raw.email,
127
+ // #3791: undefined → undefined を保つ(false に丸めない)。古い FFID は wire に
128
+ // 含めないため、consumer は undefined を「未確認」ではなく「不明」として扱える。
129
+ emailVerified: raw.email_verified,
127
130
  name: raw.name,
128
131
  picture: raw.picture,
129
132
  companyName: raw.company_name ?? null,
@@ -379,6 +382,9 @@ function createVerifyAccessToken(deps) {
379
382
  const base = {
380
383
  sub: introspectResponse.sub,
381
384
  email: introspectResponse.email ?? null,
385
+ // #3791: preserve undefined (do NOT coerce to false) so introspect-strategy
386
+ // callers can distinguish "verified" / "not verified" / "older server".
387
+ email_verified: introspectResponse.email_verified,
382
388
  name: introspectResponse.name ?? null,
383
389
  picture: introspectResponse.picture ?? null,
384
390
  company_name: introspectResponse.company_name ?? null,
@@ -934,7 +940,7 @@ function createProfileMethods(deps) {
934
940
  }
935
941
 
936
942
  // src/client/version-check.ts
937
- var SDK_VERSION = "5.12.0";
943
+ var SDK_VERSION = "5.13.0";
938
944
  var SDK_USER_AGENT = `FFID-SDK/${SDK_VERSION} (TypeScript)`;
939
945
  var SDK_VERSION_HEADER = "X-FFID-SDK-Version";
940
946
  function sdkHeaders() {
@@ -1006,9 +1012,20 @@ function createOAuthTokenMethods(deps) {
1006
1012
  logger,
1007
1013
  errorCodes
1008
1014
  } = deps;
1009
- async function exchangeCodeForTokens(code, codeVerifier) {
1015
+ async function exchangeCodeForTokens(code, codeVerifier, opts) {
1010
1016
  const url = `${baseUrl}${OAUTH_TOKEN_ENDPOINT}`;
1011
1017
  logger.debug("Exchanging code for tokens:", url);
1018
+ if (opts?.expectedState !== void 0 || opts?.actualState !== void 0) {
1019
+ if (opts?.expectedState !== opts?.actualState) {
1020
+ logger.error("Token exchange aborted: OAuth state mismatch or omitted (possible CSRF)");
1021
+ return {
1022
+ error: {
1023
+ code: errorCodes.STATE_MISMATCH_ERROR,
1024
+ message: "OAuth state \u304C\u4E00\u81F4\u3057\u307E\u305B\u3093\u3002CSRF \u306E\u53EF\u80FD\u6027\u304C\u3042\u308B\u305F\u3081\u8A8D\u8A3C\u3092\u4E2D\u6B62\u3057\u307E\u3057\u305F"
1025
+ }
1026
+ };
1027
+ }
1028
+ }
1012
1029
  const effectiveRedirectUri = resolvedRedirectUri ?? (typeof window !== "undefined" ? window.location.origin + window.location.pathname : null);
1013
1030
  if (!effectiveRedirectUri) {
1014
1031
  logger.error("redirectUri is required for token exchange in SSR environments. Set config.redirectUri explicitly.");
@@ -1492,6 +1509,102 @@ function base64UrlEncode(buffer) {
1492
1509
  return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
1493
1510
  }
1494
1511
 
1512
+ // src/auth/state.ts
1513
+ var STATE_STORAGE_KEY = "ffid_oauth_state";
1514
+ var STATE_FALLBACK_STORAGE_KEY = "ffid_oauth_state_fb";
1515
+ var STATE_FALLBACK_TIMESTAMP_KEY = "ffid_oauth_state_fb_ts";
1516
+ var OAUTH_STATE_MAX_AGE_MS = 5 * 60 * 1e3;
1517
+ function storeState(state, logger) {
1518
+ if (typeof window === "undefined") {
1519
+ logger?.warn("storeState: storage is not available in SSR context");
1520
+ return false;
1521
+ }
1522
+ let sessionStored = false;
1523
+ try {
1524
+ window.sessionStorage.setItem(STATE_STORAGE_KEY, state);
1525
+ sessionStored = true;
1526
+ } catch (error) {
1527
+ logger?.warn("storeState: sessionStorage \u3078\u306E\u4FDD\u5B58\u306B\u5931\u6557\u3057\u307E\u3057\u305F:", error);
1528
+ }
1529
+ let localStored = false;
1530
+ try {
1531
+ window.localStorage.setItem(STATE_FALLBACK_STORAGE_KEY, state);
1532
+ window.localStorage.setItem(STATE_FALLBACK_TIMESTAMP_KEY, String(Date.now()));
1533
+ localStored = true;
1534
+ } catch (error) {
1535
+ logger?.warn("storeState: localStorage fallback \u3078\u306E\u4FDD\u5B58\u306B\u5931\u6557\u3057\u307E\u3057\u305F:", error);
1536
+ }
1537
+ return sessionStored || localStored;
1538
+ }
1539
+ function retrieveState(logger) {
1540
+ if (typeof window === "undefined") return null;
1541
+ let sessionState = null;
1542
+ try {
1543
+ sessionState = window.sessionStorage.getItem(STATE_STORAGE_KEY);
1544
+ } catch (error) {
1545
+ logger?.warn("retrieveState: sessionStorage \u304B\u3089\u306E\u53D6\u5F97\u306B\u5931\u6557\u3057\u307E\u3057\u305F:", error);
1546
+ }
1547
+ if (sessionState) {
1548
+ cleanupStateStorage(logger);
1549
+ return sessionState;
1550
+ }
1551
+ let fallbackState = null;
1552
+ let fallbackTimestamp = null;
1553
+ try {
1554
+ fallbackState = window.localStorage.getItem(STATE_FALLBACK_STORAGE_KEY);
1555
+ fallbackTimestamp = window.localStorage.getItem(STATE_FALLBACK_TIMESTAMP_KEY);
1556
+ } catch (error) {
1557
+ logger?.warn("retrieveState: localStorage fallback \u304B\u3089\u306E\u53D6\u5F97\u306B\u5931\u6557\u3057\u307E\u3057\u305F:", error);
1558
+ cleanupStateStorage(logger);
1559
+ return null;
1560
+ }
1561
+ if (!fallbackState || !fallbackTimestamp) {
1562
+ cleanupStateStorage(logger);
1563
+ return null;
1564
+ }
1565
+ const ts = Number(fallbackTimestamp);
1566
+ const age = Date.now() - ts;
1567
+ if (!Number.isFinite(ts) || age < 0 || age > OAUTH_STATE_MAX_AGE_MS) {
1568
+ cleanupStateStorage(logger);
1569
+ return null;
1570
+ }
1571
+ logger?.warn(
1572
+ "retrieveState: sessionStorage was empty, recovered OAuth state from localStorage fallback"
1573
+ );
1574
+ cleanupStateStorage(logger);
1575
+ return fallbackState;
1576
+ }
1577
+ function isStateStorageAvailable() {
1578
+ if (typeof window === "undefined") return false;
1579
+ const probeKey = "__ffid_oauth_state_probe__";
1580
+ try {
1581
+ window.sessionStorage.setItem(probeKey, "1");
1582
+ window.sessionStorage.removeItem(probeKey);
1583
+ return true;
1584
+ } catch {
1585
+ }
1586
+ try {
1587
+ window.localStorage.setItem(probeKey, "1");
1588
+ window.localStorage.removeItem(probeKey);
1589
+ return true;
1590
+ } catch {
1591
+ return false;
1592
+ }
1593
+ }
1594
+ function cleanupStateStorage(logger) {
1595
+ try {
1596
+ window.sessionStorage.removeItem(STATE_STORAGE_KEY);
1597
+ } catch (error) {
1598
+ logger?.warn("retrieveState: sessionStorage \u306E\u30AF\u30EA\u30FC\u30F3\u30A2\u30C3\u30D7\u306B\u5931\u6557\u3057\u307E\u3057\u305F:", error);
1599
+ }
1600
+ try {
1601
+ window.localStorage.removeItem(STATE_FALLBACK_STORAGE_KEY);
1602
+ window.localStorage.removeItem(STATE_FALLBACK_TIMESTAMP_KEY);
1603
+ } catch (error) {
1604
+ logger?.warn("retrieveState: localStorage \u306E\u30AF\u30EA\u30FC\u30F3\u30A2\u30C3\u30D7\u306B\u5931\u6557\u3057\u307E\u3057\u305F:", error);
1605
+ }
1606
+ }
1607
+
1495
1608
  // src/client/redirect.ts
1496
1609
  var OAUTH_AUTHORIZE_ENDPOINT = "/api/v1/oauth/authorize";
1497
1610
  var AUTH_LOGOUT_ENDPOINT = "/api/v1/auth/logout";
@@ -1623,6 +1736,7 @@ function createRedirectMethods(deps) {
1623
1736
  const recentCount = getRecentRedirectCount(authorizeKey, now, logger);
1624
1737
  if (recentCount >= REDIRECT_LOOP_THRESHOLD) {
1625
1738
  cleanupVerifierStorage(logger);
1739
+ cleanupStateStorage(logger);
1626
1740
  logger.warn("[FFID SDK] redirect loop detected \u2014 \u81EA\u52D5\u30EA\u30C0\u30A4\u30EC\u30AF\u30C8\u3092\u505C\u6B62\u3057\u307E\u3057\u305F", {
1627
1741
  authorizeKey,
1628
1742
  recentCount,
@@ -1646,6 +1760,11 @@ function createRedirectMethods(deps) {
1646
1760
  return { success: false, error: errorMessage };
1647
1761
  }
1648
1762
  const state = generateRandomState();
1763
+ if (!storeState(state, logger)) {
1764
+ logger.error(
1765
+ "redirectToAuthorize: OAuth state \u3092\u4FDD\u5B58\u3067\u304D\u307E\u305B\u3093\u3067\u3057\u305F\uFF08storage \u5229\u7528\u4E0D\u53EF\uFF09\u3002\u30B3\u30FC\u30EB\u30D0\u30C3\u30AF\u3067\u306E CSRF \u691C\u8A3C\u304C\u5931\u6557\u3057\u307E\u3059"
1766
+ );
1767
+ }
1649
1768
  const redirectUri = resolvedRedirectUri ?? window.location.origin + window.location.pathname;
1650
1769
  const params = new URLSearchParams({
1651
1770
  response_type: "code",
@@ -2435,7 +2554,8 @@ var FFID_ERROR_CODES = {
2435
2554
  TOKEN_EXCHANGE_ERROR: "TOKEN_EXCHANGE_ERROR",
2436
2555
  TOKEN_REFRESH_ERROR: "TOKEN_REFRESH_ERROR",
2437
2556
  NO_TOKENS: "NO_TOKENS",
2438
- TOKEN_VERIFICATION_ERROR: "TOKEN_VERIFICATION_ERROR"
2557
+ TOKEN_VERIFICATION_ERROR: "TOKEN_VERIFICATION_ERROR",
2558
+ STATE_MISMATCH_ERROR: "STATE_MISMATCH_ERROR"
2439
2559
  };
2440
2560
  var EXT_CHECK_ENDPOINT = "/api/v1/subscriptions/ext/check";
2441
2561
  var DEFAULT_ALLOW_GRACE = true;
@@ -2953,6 +3073,99 @@ function createFFIDClient(config) {
2953
3073
  redirectUri: resolvedRedirectUri
2954
3074
  };
2955
3075
  }
3076
+
3077
+ // src/client/callback.ts
3078
+ var CALLBACK_ERROR_CODES = {
3079
+ /** Stored state missing or did not match the callback `state` (CSRF guard) */
3080
+ STATE_MISMATCH: "STATE_MISMATCH_ERROR",
3081
+ /**
3082
+ * Web storage is unavailable, so `state` could never be persisted across the
3083
+ * OAuth bounce. An environment issue (e.g. iOS private mode), not an attack —
3084
+ * surfaced distinctly so callers don't mistake it for a CSRF mismatch.
3085
+ */
3086
+ STATE_UNAVAILABLE: "STATE_STORAGE_UNAVAILABLE",
3087
+ /** Callback URL had no `code` param */
3088
+ MISSING_CODE: "MISSING_AUTHORIZATION_CODE",
3089
+ /** SSR with no `options.url` — cannot resolve callback params */
3090
+ CALLBACK_URL_UNAVAILABLE: "CALLBACK_URL_UNAVAILABLE"
3091
+ };
3092
+ function extractSearchParams(source) {
3093
+ try {
3094
+ return new URL(source).searchParams;
3095
+ } catch {
3096
+ const queryStart = source.indexOf("?");
3097
+ const query = queryStart >= 0 ? source.slice(queryStart + 1) : source;
3098
+ return new URLSearchParams(query);
3099
+ }
3100
+ }
3101
+ function cleanCallbackUrlParams(logger) {
3102
+ try {
3103
+ const cleanUrl = new URL(window.location.href);
3104
+ cleanUrl.searchParams.delete("code");
3105
+ cleanUrl.searchParams.delete("state");
3106
+ window.history.replaceState({}, "", cleanUrl.toString());
3107
+ } catch (error) {
3108
+ logger.warn("handleRedirectCallback: URL \u306E\u30AF\u30EA\u30FC\u30F3\u30A2\u30C3\u30D7\u306B\u5931\u6557\u3057\u307E\u3057\u305F:", error);
3109
+ }
3110
+ }
3111
+ async function handleRedirectCallback(client, options) {
3112
+ const { logger } = client;
3113
+ const hasWindow = typeof window !== "undefined";
3114
+ const source = options?.url ?? (hasWindow ? window.location.search : null);
3115
+ if (source === null) {
3116
+ logger.warn("handleRedirectCallback: SSR \u74B0\u5883\u3067\u306F options.url \u304C\u5FC5\u8981\u3067\u3059");
3117
+ return {
3118
+ error: client.createError(
3119
+ CALLBACK_ERROR_CODES.CALLBACK_URL_UNAVAILABLE,
3120
+ "\u30B3\u30FC\u30EB\u30D0\u30C3\u30AF URL \u3092\u89E3\u6C7A\u3067\u304D\u307E\u305B\u3093\u3002SSR \u74B0\u5883\u3067\u306F options.url \u3092\u6307\u5B9A\u3057\u3066\u304F\u3060\u3055\u3044"
3121
+ )
3122
+ };
3123
+ }
3124
+ const params = extractSearchParams(source);
3125
+ const code = params.get("code");
3126
+ const urlState = params.get("state");
3127
+ if (!code) {
3128
+ logger.warn("handleRedirectCallback: \u8A8D\u53EF\u30B3\u30FC\u30C9\u304C\u898B\u3064\u304B\u308A\u307E\u305B\u3093");
3129
+ return {
3130
+ error: client.createError(
3131
+ CALLBACK_ERROR_CODES.MISSING_CODE,
3132
+ "\u30B3\u30FC\u30EB\u30D0\u30C3\u30AF URL \u306B\u8A8D\u53EF\u30B3\u30FC\u30C9\u304C\u542B\u307E\u308C\u3066\u3044\u307E\u305B\u3093"
3133
+ )
3134
+ };
3135
+ }
3136
+ const storedState = retrieveState(logger);
3137
+ if (storedState !== null || urlState !== null) {
3138
+ const stateMatches = storedState !== null && storedState === urlState;
3139
+ if (!stateMatches) {
3140
+ if (storedState === null && !isStateStorageAvailable()) {
3141
+ logger.error(
3142
+ "handleRedirectCallback: OAuth state \u3092\u4FDD\u5B58\u3067\u304D\u308B storage \u304C\u5229\u7528\u3067\u304D\u306A\u3044\u305F\u3081 CSRF \u691C\u8A3C\u304C\u3067\u304D\u307E\u305B\u3093\uFF08iOS private mode \u7B49\uFF09"
3143
+ );
3144
+ return {
3145
+ error: client.createError(
3146
+ CALLBACK_ERROR_CODES.STATE_UNAVAILABLE,
3147
+ "OAuth state \u3092\u4FDD\u5B58\u3067\u304D\u308B storage \u304C\u5229\u7528\u3067\u304D\u306A\u3044\u305F\u3081\u8A8D\u8A3C\u3092\u5B8C\u4E86\u3067\u304D\u307E\u305B\u3093\u3002\u30D6\u30E9\u30A6\u30B6\u306E\u30D7\u30E9\u30A4\u30D9\u30FC\u30C8\u30E2\u30FC\u30C9\u3084\u30B9\u30C8\u30EC\u30FC\u30B8\u8A2D\u5B9A\u3092\u3054\u78BA\u8A8D\u304F\u3060\u3055\u3044"
3148
+ )
3149
+ };
3150
+ }
3151
+ logger.error(
3152
+ "handleRedirectCallback: OAuth state mismatch or omitted (possible CSRF) \u2014 \u8A8D\u8A3C\u3092\u4E2D\u6B62\u3057\u307E\u3057\u305F"
3153
+ );
3154
+ return {
3155
+ error: client.createError(
3156
+ CALLBACK_ERROR_CODES.STATE_MISMATCH,
3157
+ "OAuth state \u304C\u4E00\u81F4\u3057\u307E\u305B\u3093\u3002CSRF \u306E\u53EF\u80FD\u6027\u304C\u3042\u308B\u305F\u3081\u8A8D\u8A3C\u3092\u4E2D\u6B62\u3057\u307E\u3057\u305F"
3158
+ )
3159
+ };
3160
+ }
3161
+ }
3162
+ const verifier = retrieveCodeVerifier(logger);
3163
+ const result = await client.exchangeCodeForTokens(code, verifier ?? void 0);
3164
+ if (!result.error && hasWindow) {
3165
+ cleanCallbackUrlParams(logger);
3166
+ }
3167
+ return result;
3168
+ }
2956
3169
  var DEFAULT_REFRESH_INTERVAL_MS = 5 * 60 * 1e3;
2957
3170
  var TOKEN_REFRESH_RATIO = 0.8;
2958
3171
  var FFIDContext = react.createContext(null);
@@ -3127,8 +3340,7 @@ function FFIDProvider({
3127
3340
  const code = urlParams.get("code");
3128
3341
  if (!code) return;
3129
3342
  client.logger.debug("Authorization code detected, exchanging for tokens");
3130
- const codeVerifier = retrieveCodeVerifier(client.logger);
3131
- client.exchangeCodeForTokens(code, codeVerifier ?? void 0).then((result) => {
3343
+ handleRedirectCallback(client).then((result) => {
3132
3344
  if (result.error) {
3133
3345
  client.logger.error("Token exchange failed:", result.error);
3134
3346
  setError(result.error);
@@ -3136,10 +3348,6 @@ function FFIDProvider({
3136
3348
  setIsLoading(false);
3137
3349
  return;
3138
3350
  }
3139
- const cleanUrl = new URL(window.location.href);
3140
- cleanUrl.searchParams.delete("code");
3141
- cleanUrl.searchParams.delete("state");
3142
- window.history.replaceState({}, "", cleanUrl.toString());
3143
3351
  client.logger.debug("Token exchange successful, refreshing session");
3144
3352
  return refresh();
3145
3353
  }).catch((err) => {
@@ -4957,18 +5165,22 @@ exports.FFIDUserMenu = FFIDUserMenu;
4957
5165
  exports.FFID_ANNOUNCEMENTS_ERROR_CODES = FFID_ANNOUNCEMENTS_ERROR_CODES;
4958
5166
  exports.FFID_INQUIRY_CATEGORIES = FFID_INQUIRY_CATEGORIES;
4959
5167
  exports.FFID_INQUIRY_CATEGORIES_SITE_2026 = FFID_INQUIRY_CATEGORIES_SITE_2026;
5168
+ exports.cleanupStateStorage = cleanupStateStorage;
4960
5169
  exports.computeEffectiveStatusFromSession = computeEffectiveStatusFromSession;
4961
5170
  exports.createFFIDAnnouncementsClient = createFFIDAnnouncementsClient;
4962
5171
  exports.createFFIDClient = createFFIDClient;
4963
5172
  exports.createTokenStore = createTokenStore;
4964
5173
  exports.generateCodeChallenge = generateCodeChallenge;
4965
5174
  exports.generateCodeVerifier = generateCodeVerifier;
5175
+ exports.handleRedirectCallback = handleRedirectCallback;
4966
5176
  exports.hasAccessFromUserinfo = hasAccessFromUserinfo;
4967
5177
  exports.isBlockedFromUserinfo = isBlockedFromUserinfo;
4968
5178
  exports.isFFIDInquiryCategorySite2026 = isFFIDInquiryCategorySite2026;
4969
5179
  exports.normalizeRedirectUri = normalizeRedirectUri;
4970
5180
  exports.retrieveCodeVerifier = retrieveCodeVerifier;
5181
+ exports.retrieveState = retrieveState;
4971
5182
  exports.storeCodeVerifier = storeCodeVerifier;
5183
+ exports.storeState = storeState;
4972
5184
  exports.useFFID = useFFID;
4973
5185
  exports.useFFIDAnnouncements = useFFIDAnnouncements;
4974
5186
  exports.useFFIDContext = useFFIDContext;
@@ -122,6 +122,9 @@ function normalizeUserinfo(raw) {
122
122
  return {
123
123
  sub: raw.sub,
124
124
  email: raw.email,
125
+ // #3791: undefined → undefined を保つ(false に丸めない)。古い FFID は wire に
126
+ // 含めないため、consumer は undefined を「未確認」ではなく「不明」として扱える。
127
+ emailVerified: raw.email_verified,
125
128
  name: raw.name,
126
129
  picture: raw.picture,
127
130
  companyName: raw.company_name ?? null,
@@ -377,6 +380,9 @@ function createVerifyAccessToken(deps) {
377
380
  const base = {
378
381
  sub: introspectResponse.sub,
379
382
  email: introspectResponse.email ?? null,
383
+ // #3791: preserve undefined (do NOT coerce to false) so introspect-strategy
384
+ // callers can distinguish "verified" / "not verified" / "older server".
385
+ email_verified: introspectResponse.email_verified,
380
386
  name: introspectResponse.name ?? null,
381
387
  picture: introspectResponse.picture ?? null,
382
388
  company_name: introspectResponse.company_name ?? null,
@@ -932,7 +938,7 @@ function createProfileMethods(deps) {
932
938
  }
933
939
 
934
940
  // src/client/version-check.ts
935
- var SDK_VERSION = "5.12.0";
941
+ var SDK_VERSION = "5.13.0";
936
942
  var SDK_USER_AGENT = `FFID-SDK/${SDK_VERSION} (TypeScript)`;
937
943
  var SDK_VERSION_HEADER = "X-FFID-SDK-Version";
938
944
  function sdkHeaders() {
@@ -1004,9 +1010,20 @@ function createOAuthTokenMethods(deps) {
1004
1010
  logger,
1005
1011
  errorCodes
1006
1012
  } = deps;
1007
- async function exchangeCodeForTokens(code, codeVerifier) {
1013
+ async function exchangeCodeForTokens(code, codeVerifier, opts) {
1008
1014
  const url = `${baseUrl}${OAUTH_TOKEN_ENDPOINT}`;
1009
1015
  logger.debug("Exchanging code for tokens:", url);
1016
+ if (opts?.expectedState !== void 0 || opts?.actualState !== void 0) {
1017
+ if (opts?.expectedState !== opts?.actualState) {
1018
+ logger.error("Token exchange aborted: OAuth state mismatch or omitted (possible CSRF)");
1019
+ return {
1020
+ error: {
1021
+ code: errorCodes.STATE_MISMATCH_ERROR,
1022
+ message: "OAuth state \u304C\u4E00\u81F4\u3057\u307E\u305B\u3093\u3002CSRF \u306E\u53EF\u80FD\u6027\u304C\u3042\u308B\u305F\u3081\u8A8D\u8A3C\u3092\u4E2D\u6B62\u3057\u307E\u3057\u305F"
1023
+ }
1024
+ };
1025
+ }
1026
+ }
1010
1027
  const effectiveRedirectUri = resolvedRedirectUri ?? (typeof window !== "undefined" ? window.location.origin + window.location.pathname : null);
1011
1028
  if (!effectiveRedirectUri) {
1012
1029
  logger.error("redirectUri is required for token exchange in SSR environments. Set config.redirectUri explicitly.");
@@ -1490,6 +1507,102 @@ function base64UrlEncode(buffer) {
1490
1507
  return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
1491
1508
  }
1492
1509
 
1510
+ // src/auth/state.ts
1511
+ var STATE_STORAGE_KEY = "ffid_oauth_state";
1512
+ var STATE_FALLBACK_STORAGE_KEY = "ffid_oauth_state_fb";
1513
+ var STATE_FALLBACK_TIMESTAMP_KEY = "ffid_oauth_state_fb_ts";
1514
+ var OAUTH_STATE_MAX_AGE_MS = 5 * 60 * 1e3;
1515
+ function storeState(state, logger) {
1516
+ if (typeof window === "undefined") {
1517
+ logger?.warn("storeState: storage is not available in SSR context");
1518
+ return false;
1519
+ }
1520
+ let sessionStored = false;
1521
+ try {
1522
+ window.sessionStorage.setItem(STATE_STORAGE_KEY, state);
1523
+ sessionStored = true;
1524
+ } catch (error) {
1525
+ logger?.warn("storeState: sessionStorage \u3078\u306E\u4FDD\u5B58\u306B\u5931\u6557\u3057\u307E\u3057\u305F:", error);
1526
+ }
1527
+ let localStored = false;
1528
+ try {
1529
+ window.localStorage.setItem(STATE_FALLBACK_STORAGE_KEY, state);
1530
+ window.localStorage.setItem(STATE_FALLBACK_TIMESTAMP_KEY, String(Date.now()));
1531
+ localStored = true;
1532
+ } catch (error) {
1533
+ logger?.warn("storeState: localStorage fallback \u3078\u306E\u4FDD\u5B58\u306B\u5931\u6557\u3057\u307E\u3057\u305F:", error);
1534
+ }
1535
+ return sessionStored || localStored;
1536
+ }
1537
+ function retrieveState(logger) {
1538
+ if (typeof window === "undefined") return null;
1539
+ let sessionState = null;
1540
+ try {
1541
+ sessionState = window.sessionStorage.getItem(STATE_STORAGE_KEY);
1542
+ } catch (error) {
1543
+ logger?.warn("retrieveState: sessionStorage \u304B\u3089\u306E\u53D6\u5F97\u306B\u5931\u6557\u3057\u307E\u3057\u305F:", error);
1544
+ }
1545
+ if (sessionState) {
1546
+ cleanupStateStorage(logger);
1547
+ return sessionState;
1548
+ }
1549
+ let fallbackState = null;
1550
+ let fallbackTimestamp = null;
1551
+ try {
1552
+ fallbackState = window.localStorage.getItem(STATE_FALLBACK_STORAGE_KEY);
1553
+ fallbackTimestamp = window.localStorage.getItem(STATE_FALLBACK_TIMESTAMP_KEY);
1554
+ } catch (error) {
1555
+ logger?.warn("retrieveState: localStorage fallback \u304B\u3089\u306E\u53D6\u5F97\u306B\u5931\u6557\u3057\u307E\u3057\u305F:", error);
1556
+ cleanupStateStorage(logger);
1557
+ return null;
1558
+ }
1559
+ if (!fallbackState || !fallbackTimestamp) {
1560
+ cleanupStateStorage(logger);
1561
+ return null;
1562
+ }
1563
+ const ts = Number(fallbackTimestamp);
1564
+ const age = Date.now() - ts;
1565
+ if (!Number.isFinite(ts) || age < 0 || age > OAUTH_STATE_MAX_AGE_MS) {
1566
+ cleanupStateStorage(logger);
1567
+ return null;
1568
+ }
1569
+ logger?.warn(
1570
+ "retrieveState: sessionStorage was empty, recovered OAuth state from localStorage fallback"
1571
+ );
1572
+ cleanupStateStorage(logger);
1573
+ return fallbackState;
1574
+ }
1575
+ function isStateStorageAvailable() {
1576
+ if (typeof window === "undefined") return false;
1577
+ const probeKey = "__ffid_oauth_state_probe__";
1578
+ try {
1579
+ window.sessionStorage.setItem(probeKey, "1");
1580
+ window.sessionStorage.removeItem(probeKey);
1581
+ return true;
1582
+ } catch {
1583
+ }
1584
+ try {
1585
+ window.localStorage.setItem(probeKey, "1");
1586
+ window.localStorage.removeItem(probeKey);
1587
+ return true;
1588
+ } catch {
1589
+ return false;
1590
+ }
1591
+ }
1592
+ function cleanupStateStorage(logger) {
1593
+ try {
1594
+ window.sessionStorage.removeItem(STATE_STORAGE_KEY);
1595
+ } catch (error) {
1596
+ logger?.warn("retrieveState: sessionStorage \u306E\u30AF\u30EA\u30FC\u30F3\u30A2\u30C3\u30D7\u306B\u5931\u6557\u3057\u307E\u3057\u305F:", error);
1597
+ }
1598
+ try {
1599
+ window.localStorage.removeItem(STATE_FALLBACK_STORAGE_KEY);
1600
+ window.localStorage.removeItem(STATE_FALLBACK_TIMESTAMP_KEY);
1601
+ } catch (error) {
1602
+ logger?.warn("retrieveState: localStorage \u306E\u30AF\u30EA\u30FC\u30F3\u30A2\u30C3\u30D7\u306B\u5931\u6557\u3057\u307E\u3057\u305F:", error);
1603
+ }
1604
+ }
1605
+
1493
1606
  // src/client/redirect.ts
1494
1607
  var OAUTH_AUTHORIZE_ENDPOINT = "/api/v1/oauth/authorize";
1495
1608
  var AUTH_LOGOUT_ENDPOINT = "/api/v1/auth/logout";
@@ -1621,6 +1734,7 @@ function createRedirectMethods(deps) {
1621
1734
  const recentCount = getRecentRedirectCount(authorizeKey, now, logger);
1622
1735
  if (recentCount >= REDIRECT_LOOP_THRESHOLD) {
1623
1736
  cleanupVerifierStorage(logger);
1737
+ cleanupStateStorage(logger);
1624
1738
  logger.warn("[FFID SDK] redirect loop detected \u2014 \u81EA\u52D5\u30EA\u30C0\u30A4\u30EC\u30AF\u30C8\u3092\u505C\u6B62\u3057\u307E\u3057\u305F", {
1625
1739
  authorizeKey,
1626
1740
  recentCount,
@@ -1644,6 +1758,11 @@ function createRedirectMethods(deps) {
1644
1758
  return { success: false, error: errorMessage };
1645
1759
  }
1646
1760
  const state = generateRandomState();
1761
+ if (!storeState(state, logger)) {
1762
+ logger.error(
1763
+ "redirectToAuthorize: OAuth state \u3092\u4FDD\u5B58\u3067\u304D\u307E\u305B\u3093\u3067\u3057\u305F\uFF08storage \u5229\u7528\u4E0D\u53EF\uFF09\u3002\u30B3\u30FC\u30EB\u30D0\u30C3\u30AF\u3067\u306E CSRF \u691C\u8A3C\u304C\u5931\u6557\u3057\u307E\u3059"
1764
+ );
1765
+ }
1647
1766
  const redirectUri = resolvedRedirectUri ?? window.location.origin + window.location.pathname;
1648
1767
  const params = new URLSearchParams({
1649
1768
  response_type: "code",
@@ -2433,7 +2552,8 @@ var FFID_ERROR_CODES = {
2433
2552
  TOKEN_EXCHANGE_ERROR: "TOKEN_EXCHANGE_ERROR",
2434
2553
  TOKEN_REFRESH_ERROR: "TOKEN_REFRESH_ERROR",
2435
2554
  NO_TOKENS: "NO_TOKENS",
2436
- TOKEN_VERIFICATION_ERROR: "TOKEN_VERIFICATION_ERROR"
2555
+ TOKEN_VERIFICATION_ERROR: "TOKEN_VERIFICATION_ERROR",
2556
+ STATE_MISMATCH_ERROR: "STATE_MISMATCH_ERROR"
2437
2557
  };
2438
2558
  var EXT_CHECK_ENDPOINT = "/api/v1/subscriptions/ext/check";
2439
2559
  var DEFAULT_ALLOW_GRACE = true;
@@ -2951,6 +3071,99 @@ function createFFIDClient(config) {
2951
3071
  redirectUri: resolvedRedirectUri
2952
3072
  };
2953
3073
  }
3074
+
3075
+ // src/client/callback.ts
3076
+ var CALLBACK_ERROR_CODES = {
3077
+ /** Stored state missing or did not match the callback `state` (CSRF guard) */
3078
+ STATE_MISMATCH: "STATE_MISMATCH_ERROR",
3079
+ /**
3080
+ * Web storage is unavailable, so `state` could never be persisted across the
3081
+ * OAuth bounce. An environment issue (e.g. iOS private mode), not an attack —
3082
+ * surfaced distinctly so callers don't mistake it for a CSRF mismatch.
3083
+ */
3084
+ STATE_UNAVAILABLE: "STATE_STORAGE_UNAVAILABLE",
3085
+ /** Callback URL had no `code` param */
3086
+ MISSING_CODE: "MISSING_AUTHORIZATION_CODE",
3087
+ /** SSR with no `options.url` — cannot resolve callback params */
3088
+ CALLBACK_URL_UNAVAILABLE: "CALLBACK_URL_UNAVAILABLE"
3089
+ };
3090
+ function extractSearchParams(source) {
3091
+ try {
3092
+ return new URL(source).searchParams;
3093
+ } catch {
3094
+ const queryStart = source.indexOf("?");
3095
+ const query = queryStart >= 0 ? source.slice(queryStart + 1) : source;
3096
+ return new URLSearchParams(query);
3097
+ }
3098
+ }
3099
+ function cleanCallbackUrlParams(logger) {
3100
+ try {
3101
+ const cleanUrl = new URL(window.location.href);
3102
+ cleanUrl.searchParams.delete("code");
3103
+ cleanUrl.searchParams.delete("state");
3104
+ window.history.replaceState({}, "", cleanUrl.toString());
3105
+ } catch (error) {
3106
+ logger.warn("handleRedirectCallback: URL \u306E\u30AF\u30EA\u30FC\u30F3\u30A2\u30C3\u30D7\u306B\u5931\u6557\u3057\u307E\u3057\u305F:", error);
3107
+ }
3108
+ }
3109
+ async function handleRedirectCallback(client, options) {
3110
+ const { logger } = client;
3111
+ const hasWindow = typeof window !== "undefined";
3112
+ const source = options?.url ?? (hasWindow ? window.location.search : null);
3113
+ if (source === null) {
3114
+ logger.warn("handleRedirectCallback: SSR \u74B0\u5883\u3067\u306F options.url \u304C\u5FC5\u8981\u3067\u3059");
3115
+ return {
3116
+ error: client.createError(
3117
+ CALLBACK_ERROR_CODES.CALLBACK_URL_UNAVAILABLE,
3118
+ "\u30B3\u30FC\u30EB\u30D0\u30C3\u30AF URL \u3092\u89E3\u6C7A\u3067\u304D\u307E\u305B\u3093\u3002SSR \u74B0\u5883\u3067\u306F options.url \u3092\u6307\u5B9A\u3057\u3066\u304F\u3060\u3055\u3044"
3119
+ )
3120
+ };
3121
+ }
3122
+ const params = extractSearchParams(source);
3123
+ const code = params.get("code");
3124
+ const urlState = params.get("state");
3125
+ if (!code) {
3126
+ logger.warn("handleRedirectCallback: \u8A8D\u53EF\u30B3\u30FC\u30C9\u304C\u898B\u3064\u304B\u308A\u307E\u305B\u3093");
3127
+ return {
3128
+ error: client.createError(
3129
+ CALLBACK_ERROR_CODES.MISSING_CODE,
3130
+ "\u30B3\u30FC\u30EB\u30D0\u30C3\u30AF URL \u306B\u8A8D\u53EF\u30B3\u30FC\u30C9\u304C\u542B\u307E\u308C\u3066\u3044\u307E\u305B\u3093"
3131
+ )
3132
+ };
3133
+ }
3134
+ const storedState = retrieveState(logger);
3135
+ if (storedState !== null || urlState !== null) {
3136
+ const stateMatches = storedState !== null && storedState === urlState;
3137
+ if (!stateMatches) {
3138
+ if (storedState === null && !isStateStorageAvailable()) {
3139
+ logger.error(
3140
+ "handleRedirectCallback: OAuth state \u3092\u4FDD\u5B58\u3067\u304D\u308B storage \u304C\u5229\u7528\u3067\u304D\u306A\u3044\u305F\u3081 CSRF \u691C\u8A3C\u304C\u3067\u304D\u307E\u305B\u3093\uFF08iOS private mode \u7B49\uFF09"
3141
+ );
3142
+ return {
3143
+ error: client.createError(
3144
+ CALLBACK_ERROR_CODES.STATE_UNAVAILABLE,
3145
+ "OAuth state \u3092\u4FDD\u5B58\u3067\u304D\u308B storage \u304C\u5229\u7528\u3067\u304D\u306A\u3044\u305F\u3081\u8A8D\u8A3C\u3092\u5B8C\u4E86\u3067\u304D\u307E\u305B\u3093\u3002\u30D6\u30E9\u30A6\u30B6\u306E\u30D7\u30E9\u30A4\u30D9\u30FC\u30C8\u30E2\u30FC\u30C9\u3084\u30B9\u30C8\u30EC\u30FC\u30B8\u8A2D\u5B9A\u3092\u3054\u78BA\u8A8D\u304F\u3060\u3055\u3044"
3146
+ )
3147
+ };
3148
+ }
3149
+ logger.error(
3150
+ "handleRedirectCallback: OAuth state mismatch or omitted (possible CSRF) \u2014 \u8A8D\u8A3C\u3092\u4E2D\u6B62\u3057\u307E\u3057\u305F"
3151
+ );
3152
+ return {
3153
+ error: client.createError(
3154
+ CALLBACK_ERROR_CODES.STATE_MISMATCH,
3155
+ "OAuth state \u304C\u4E00\u81F4\u3057\u307E\u305B\u3093\u3002CSRF \u306E\u53EF\u80FD\u6027\u304C\u3042\u308B\u305F\u3081\u8A8D\u8A3C\u3092\u4E2D\u6B62\u3057\u307E\u3057\u305F"
3156
+ )
3157
+ };
3158
+ }
3159
+ }
3160
+ const verifier = retrieveCodeVerifier(logger);
3161
+ const result = await client.exchangeCodeForTokens(code, verifier ?? void 0);
3162
+ if (!result.error && hasWindow) {
3163
+ cleanCallbackUrlParams(logger);
3164
+ }
3165
+ return result;
3166
+ }
2954
3167
  var DEFAULT_REFRESH_INTERVAL_MS = 5 * 60 * 1e3;
2955
3168
  var TOKEN_REFRESH_RATIO = 0.8;
2956
3169
  var FFIDContext = createContext(null);
@@ -3125,8 +3338,7 @@ function FFIDProvider({
3125
3338
  const code = urlParams.get("code");
3126
3339
  if (!code) return;
3127
3340
  client.logger.debug("Authorization code detected, exchanging for tokens");
3128
- const codeVerifier = retrieveCodeVerifier(client.logger);
3129
- client.exchangeCodeForTokens(code, codeVerifier ?? void 0).then((result) => {
3341
+ handleRedirectCallback(client).then((result) => {
3130
3342
  if (result.error) {
3131
3343
  client.logger.error("Token exchange failed:", result.error);
3132
3344
  setError(result.error);
@@ -3134,10 +3346,6 @@ function FFIDProvider({
3134
3346
  setIsLoading(false);
3135
3347
  return;
3136
3348
  }
3137
- const cleanUrl = new URL(window.location.href);
3138
- cleanUrl.searchParams.delete("code");
3139
- cleanUrl.searchParams.delete("state");
3140
- window.history.replaceState({}, "", cleanUrl.toString());
3141
3349
  client.logger.debug("Token exchange successful, refreshing session");
3142
3350
  return refresh();
3143
3351
  }).catch((err) => {
@@ -4938,4 +5146,4 @@ function FFIDInquiryForm({
4938
5146
  );
4939
5147
  }
4940
5148
 
4941
- export { ACCESS_GRANTING_EFFECTIVE_STATUSES, BLOCKING_EFFECTIVE_STATUSES, DEFAULT_API_BASE_URL, DEFAULT_OAUTH_SCOPES, EFFECTIVE_SUBSCRIPTION_STATUSES, FFIDAnnouncementBadge, FFIDAnnouncementList, FFIDInquiryForm, FFIDLoginButton, FFIDOrganizationSwitcher, FFIDProvider, FFIDSDKError, FFIDSubscriptionBadge, FFIDUserMenu, FFID_ANNOUNCEMENTS_ERROR_CODES, FFID_INQUIRY_CATEGORIES, FFID_INQUIRY_CATEGORIES_SITE_2026, computeEffectiveStatusFromSession, createFFIDAnnouncementsClient, createFFIDClient, createTokenStore, generateCodeChallenge, generateCodeVerifier, hasAccessFromUserinfo, isBlockedFromUserinfo, isFFIDInquiryCategorySite2026, normalizeRedirectUri, retrieveCodeVerifier, storeCodeVerifier, useFFID, useFFIDAnnouncements, useFFIDContext, useSubscription, withSubscription };
5149
+ export { ACCESS_GRANTING_EFFECTIVE_STATUSES, BLOCKING_EFFECTIVE_STATUSES, DEFAULT_API_BASE_URL, DEFAULT_OAUTH_SCOPES, EFFECTIVE_SUBSCRIPTION_STATUSES, FFIDAnnouncementBadge, FFIDAnnouncementList, FFIDInquiryForm, FFIDLoginButton, FFIDOrganizationSwitcher, FFIDProvider, FFIDSDKError, FFIDSubscriptionBadge, FFIDUserMenu, FFID_ANNOUNCEMENTS_ERROR_CODES, FFID_INQUIRY_CATEGORIES, FFID_INQUIRY_CATEGORIES_SITE_2026, cleanupStateStorage, computeEffectiveStatusFromSession, createFFIDAnnouncementsClient, createFFIDClient, createTokenStore, generateCodeChallenge, generateCodeVerifier, handleRedirectCallback, hasAccessFromUserinfo, isBlockedFromUserinfo, isFFIDInquiryCategorySite2026, normalizeRedirectUri, retrieveCodeVerifier, retrieveState, storeCodeVerifier, storeState, useFFID, useFFIDAnnouncements, useFFIDContext, useSubscription, withSubscription };
@@ -1,34 +1,34 @@
1
1
  'use strict';
2
2
 
3
- var chunkAOJDV3UO_cjs = require('../chunk-AOJDV3UO.cjs');
3
+ var chunkJFFRO2Q5_cjs = require('../chunk-JFFRO2Q5.cjs');
4
4
 
5
5
 
6
6
 
7
7
  Object.defineProperty(exports, "FFIDAnnouncementBadge", {
8
8
  enumerable: true,
9
- get: function () { return chunkAOJDV3UO_cjs.FFIDAnnouncementBadge; }
9
+ get: function () { return chunkJFFRO2Q5_cjs.FFIDAnnouncementBadge; }
10
10
  });
11
11
  Object.defineProperty(exports, "FFIDAnnouncementList", {
12
12
  enumerable: true,
13
- get: function () { return chunkAOJDV3UO_cjs.FFIDAnnouncementList; }
13
+ get: function () { return chunkJFFRO2Q5_cjs.FFIDAnnouncementList; }
14
14
  });
15
15
  Object.defineProperty(exports, "FFIDInquiryForm", {
16
16
  enumerable: true,
17
- get: function () { return chunkAOJDV3UO_cjs.FFIDInquiryForm; }
17
+ get: function () { return chunkJFFRO2Q5_cjs.FFIDInquiryForm; }
18
18
  });
19
19
  Object.defineProperty(exports, "FFIDLoginButton", {
20
20
  enumerable: true,
21
- get: function () { return chunkAOJDV3UO_cjs.FFIDLoginButton; }
21
+ get: function () { return chunkJFFRO2Q5_cjs.FFIDLoginButton; }
22
22
  });
23
23
  Object.defineProperty(exports, "FFIDOrganizationSwitcher", {
24
24
  enumerable: true,
25
- get: function () { return chunkAOJDV3UO_cjs.FFIDOrganizationSwitcher; }
25
+ get: function () { return chunkJFFRO2Q5_cjs.FFIDOrganizationSwitcher; }
26
26
  });
27
27
  Object.defineProperty(exports, "FFIDSubscriptionBadge", {
28
28
  enumerable: true,
29
- get: function () { return chunkAOJDV3UO_cjs.FFIDSubscriptionBadge; }
29
+ get: function () { return chunkJFFRO2Q5_cjs.FFIDSubscriptionBadge; }
30
30
  });
31
31
  Object.defineProperty(exports, "FFIDUserMenu", {
32
32
  enumerable: true,
33
- get: function () { return chunkAOJDV3UO_cjs.FFIDUserMenu; }
33
+ get: function () { return chunkJFFRO2Q5_cjs.FFIDUserMenu; }
34
34
  });