@feelflow/ffid-sdk 10.0.0 → 11.0.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,20 @@ interface FFIDProvisionOrganizationRequest {
124
124
  slug?: string;
125
125
  /** 請求先メールアドレス */
126
126
  billingEmail?: string;
127
+ /**
128
+ * 冪等キー(1〜255 文字の表示可能な ASCII。空白不可)。
129
+ *
130
+ * 指定すると、同じ代理店・同じキーの再送(24 時間以内)は**組織を作らずに**
131
+ * 初回の結果を返す。タイムアウトや `PARSE_ERROR` で応答を受け取れなかったときは、
132
+ * **同じキーのまま**再送すれば二重作成にならない。申込番号など、作りたい組織 1 つに
133
+ * 1 つ対応する値を使う。
134
+ *
135
+ * - 同じキーで内容(`name` / `ownerEmail` / `slug` / `billingEmail`)が違うと 422
136
+ * `IDEMPOTENCY_KEY_REUSED`(別の組織を作るなら別のキーを使う)
137
+ * - 省略すると従来どおり毎回作成する
138
+ * - HTTP では `Idempotency-Key` ヘッダーでも渡せる(本文と両方に入れる場合は同じ値)
139
+ */
140
+ idempotencyKey?: string;
127
141
  }
128
142
  /** 案内メールの送信結果。組織作成の成否とは独立している。 */
129
143
  type FFIDWelcomeEmailOutcome = 'sent' | 'failed';
@@ -453,9 +467,17 @@ interface FFIDAgencyServerClient {
453
467
  * そのほか:
454
468
  * - スコープ不足 / 代理店が停止中 → `FORBIDDEN`(403)
455
469
  * - slug が既存組織と衝突 → `ORGANIZATION_SLUG_CONFLICT`(409、組織は作成されない)
470
+ * - slug が予約語(`admin` / `api` / `login` など)→ `ORGANIZATION_SLUG_RESERVED`
471
+ * (400、組織は作成されない。別の値を指定する)
472
+ * - `ownerEmail` の既存アカウントが停止・削除済み → `OWNER_ACCOUNT_UNAVAILABLE`
473
+ * (422、**組織は作成されない**。別のメールアドレスを指定する)
456
474
  * - 認証段階の一時障害 → `SERVICE_UNAVAILABLE`(503、**安全に再試行できる**)
457
475
  * - `PARSE_ERROR` / `NETWORK_ERROR` — **組織が作成された可能性がある**。
458
- * 再試行の前に組織一覧を確認すること(#5165 で冪等キーを導入予定)
476
+ * `idempotencyKey` を付けていれば**同じキーで**再送してよい(作成済みなら
477
+ * 初回の結果が返り、組織は増えない。#5165)。付けていない場合は再試行の前に
478
+ * 組織一覧を確認すること
479
+ * - 同じ `idempotencyKey` で内容が違う → `IDEMPOTENCY_KEY_REUSED`(422)
480
+ * - `idempotencyKey` の形式不正 → `INVALID_IDEMPOTENCY_KEY`(400)
459
481
  */
460
482
  provisionCustomerOrganization(data: FFIDProvisionOrganizationRequest): Promise<FFIDAgencyServerClientResponse<FFIDProvisionedOrganization>>;
461
483
  }
@@ -124,6 +124,20 @@ interface FFIDProvisionOrganizationRequest {
124
124
  slug?: string;
125
125
  /** 請求先メールアドレス */
126
126
  billingEmail?: string;
127
+ /**
128
+ * 冪等キー(1〜255 文字の表示可能な ASCII。空白不可)。
129
+ *
130
+ * 指定すると、同じ代理店・同じキーの再送(24 時間以内)は**組織を作らずに**
131
+ * 初回の結果を返す。タイムアウトや `PARSE_ERROR` で応答を受け取れなかったときは、
132
+ * **同じキーのまま**再送すれば二重作成にならない。申込番号など、作りたい組織 1 つに
133
+ * 1 つ対応する値を使う。
134
+ *
135
+ * - 同じキーで内容(`name` / `ownerEmail` / `slug` / `billingEmail`)が違うと 422
136
+ * `IDEMPOTENCY_KEY_REUSED`(別の組織を作るなら別のキーを使う)
137
+ * - 省略すると従来どおり毎回作成する
138
+ * - HTTP では `Idempotency-Key` ヘッダーでも渡せる(本文と両方に入れる場合は同じ値)
139
+ */
140
+ idempotencyKey?: string;
127
141
  }
128
142
  /** 案内メールの送信結果。組織作成の成否とは独立している。 */
129
143
  type FFIDWelcomeEmailOutcome = 'sent' | 'failed';
@@ -453,9 +467,17 @@ interface FFIDAgencyServerClient {
453
467
  * そのほか:
454
468
  * - スコープ不足 / 代理店が停止中 → `FORBIDDEN`(403)
455
469
  * - slug が既存組織と衝突 → `ORGANIZATION_SLUG_CONFLICT`(409、組織は作成されない)
470
+ * - slug が予約語(`admin` / `api` / `login` など)→ `ORGANIZATION_SLUG_RESERVED`
471
+ * (400、組織は作成されない。別の値を指定する)
472
+ * - `ownerEmail` の既存アカウントが停止・削除済み → `OWNER_ACCOUNT_UNAVAILABLE`
473
+ * (422、**組織は作成されない**。別のメールアドレスを指定する)
456
474
  * - 認証段階の一時障害 → `SERVICE_UNAVAILABLE`(503、**安全に再試行できる**)
457
475
  * - `PARSE_ERROR` / `NETWORK_ERROR` — **組織が作成された可能性がある**。
458
- * 再試行の前に組織一覧を確認すること(#5165 で冪等キーを導入予定)
476
+ * `idempotencyKey` を付けていれば**同じキーで**再送してよい(作成済みなら
477
+ * 初回の結果が返り、組織は増えない。#5165)。付けていない場合は再試行の前に
478
+ * 組織一覧を確認すること
479
+ * - 同じ `idempotencyKey` で内容が違う → `IDEMPOTENCY_KEY_REUSED`(422)
480
+ * - `idempotencyKey` の形式不正 → `INVALID_IDEMPOTENCY_KEY`(400)
459
481
  */
460
482
  provisionCustomerOrganization(data: FFIDProvisionOrganizationRequest): Promise<FFIDAgencyServerClientResponse<FFIDProvisionedOrganization>>;
461
483
  }
@@ -308,6 +308,9 @@ var OAUTH_INTROSPECT_ENDPOINT = "/api/v1/oauth/introspect";
308
308
  var CACHE_KEY_PREFIX = "ffid:introspect:";
309
309
  var HEX_BASE = 16;
310
310
  var HEX_BYTE_WIDTH = 2;
311
+ var HTTP_TOO_MANY_REQUESTS = 429;
312
+ var HTTP_SERVER_ERROR_MIN = 500;
313
+ var HTTP_SERVER_ERROR_LIMIT = 600;
311
314
  function createVerifyAccessToken(deps) {
312
315
  const { authMode, baseUrl, serviceCode, serviceApiKey, verifyStrategy, logger, createError, errorCodes, cache, timeout } = deps;
313
316
  let jwtVerify2 = null;
@@ -362,6 +365,16 @@ function createVerifyAccessToken(deps) {
362
365
  }
363
366
  return verifyViaIntrospect(accessToken);
364
367
  }
368
+ function introspectionUnavailable(status) {
369
+ logger.error("Token introspection unavailable", { status });
370
+ return {
371
+ error: {
372
+ code: errorCodes.NETWORK_ERROR,
373
+ message: `\u30C8\u30FC\u30AF\u30F3\u691C\u8A3C\u30B5\u30FC\u30D3\u30B9\u304C\u4E00\u6642\u7684\u306B\u5229\u7528\u3067\u304D\u307E\u305B\u3093 (status: ${status})`,
374
+ details: { status }
375
+ }
376
+ };
377
+ }
365
378
  async function verifyViaIntrospect(accessToken, cacheKeySuffix) {
366
379
  if (!serviceApiKey) {
367
380
  return {
@@ -419,10 +432,12 @@ function createVerifyAccessToken(deps) {
419
432
  }
420
433
  };
421
434
  }
435
+ const upstreamUnavailable = response.status === HTTP_TOO_MANY_REQUESTS || response.status >= HTTP_SERVER_ERROR_MIN && response.status < HTTP_SERVER_ERROR_LIMIT;
422
436
  let introspectResponse;
423
437
  try {
424
438
  introspectResponse = await response.json();
425
439
  } catch (parseError) {
440
+ if (upstreamUnavailable) return introspectionUnavailable(response.status);
426
441
  logger.error("Parse error during token verification:", parseError);
427
442
  return {
428
443
  error: {
@@ -431,6 +446,7 @@ function createVerifyAccessToken(deps) {
431
446
  }
432
447
  };
433
448
  }
449
+ if (upstreamUnavailable) return introspectionUnavailable(response.status);
434
450
  if (!response.ok) {
435
451
  const errorBody = introspectResponse;
436
452
  return {
@@ -1374,7 +1390,7 @@ var FFID_ORGANIZATION_PROFILE_ERROR_CODES = {
1374
1390
  };
1375
1391
 
1376
1392
  // src/client/version-check.ts
1377
- var SDK_VERSION = "10.0.0";
1393
+ var SDK_VERSION = "11.0.0";
1378
1394
  var SDK_USER_AGENT = `FFID-SDK/${SDK_VERSION} (TypeScript)`;
1379
1395
  var SDK_VERSION_HEADER = "X-FFID-SDK-Version";
1380
1396
  function sdkHeaders() {
@@ -1942,7 +1958,7 @@ function createInviteMethods(deps) {
1942
1958
  }).toString();
1943
1959
  return fetchWithAuth(`${EXT_INVITE_ENDPOINT}?${query}`, {
1944
1960
  method: "POST",
1945
- body: JSON.stringify({ email: params.email, role: params.role })
1961
+ body: JSON.stringify({ email: params.email, role: params.role, inviterUserId: params.inviterUserId })
1946
1962
  });
1947
1963
  }
1948
1964
  return { inviteMember };
@@ -2245,6 +2261,25 @@ function createOAuthTokenMethods(deps) {
2245
2261
  return { exchangeCodeForTokens, refreshAccessToken, signOutToken };
2246
2262
  }
2247
2263
 
2264
+ // src/client/request-timeout.ts
2265
+ function withRequestTimeout(options, timeout) {
2266
+ if (timeout === void 0 || options.signal?.aborted) return options;
2267
+ const deadline = AbortSignal.timeout(timeout);
2268
+ if (!options.signal) return { ...options, signal: deadline };
2269
+ const callerSignal = options.signal;
2270
+ const controller = new AbortController();
2271
+ const abortFromCaller = () => abort(callerSignal.reason);
2272
+ const abortFromDeadline = () => abort(deadline.reason);
2273
+ function abort(reason) {
2274
+ callerSignal.removeEventListener("abort", abortFromCaller);
2275
+ deadline.removeEventListener("abort", abortFromDeadline);
2276
+ controller.abort(reason);
2277
+ }
2278
+ callerSignal.addEventListener("abort", abortFromCaller, { once: true });
2279
+ deadline.addEventListener("abort", abortFromDeadline, { once: true });
2280
+ return { ...options, signal: controller.signal };
2281
+ }
2282
+
2248
2283
  // src/client/session.ts
2249
2284
  var SESSION_ENDPOINT = "/api/v1/auth/session";
2250
2285
  var LOGOUT_ENDPOINT = "/api/v1/auth/signout";
@@ -3979,6 +4014,7 @@ function toServiceAccessDecision(response, allowGrace) {
3979
4014
  const isBlocked = response.isBlocked ?? (effectiveStatus === null || effectiveStatus !== null && BLOCKING_EFFECTIVE_STATUSES.includes(effectiveStatus));
3980
4015
  return {
3981
4016
  hasAccess,
4017
+ ...response.seatAssigned !== void 0 ? { seatAssigned: response.seatAssigned } : {},
3982
4018
  effectiveStatus,
3983
4019
  isGrace,
3984
4020
  isBlocked: isBlocked || !hasAccess,
@@ -4046,6 +4082,7 @@ var FFID_SUBSCRIPTION_ERROR_CODES = {
4046
4082
  var FFID_CREDITS_ERROR_CODES = {
4047
4083
  CREDITS_INSUFFICIENT: "CREDITS_INSUFFICIENT",
4048
4084
  CREDITS_IDEMPOTENCY_CONFLICT: "CREDITS_IDEMPOTENCY_CONFLICT",
4085
+ CREDITS_TURN_CANCELLED: "CREDITS_TURN_CANCELLED",
4049
4086
  CREDITS_NOT_APPLICABLE: "CREDITS_NOT_APPLICABLE",
4050
4087
  CREDITS_BALANCE_CAP_EXCEEDED: "CREDITS_BALANCE_CAP_EXCEEDED",
4051
4088
  SEAT_BILLING_NOT_APPLICABLE: "SEAT_BILLING_NOT_APPLICABLE",
@@ -4210,9 +4247,10 @@ function createFFIDClient(config) {
4210
4247
  async function fetchWithAuth(endpoint, options = {}, authOverride) {
4211
4248
  const url = `${baseUrl}${endpoint}`;
4212
4249
  logger.debug("Fetching:", url);
4213
- const fetchOptions = buildFetchOptions(options, authOverride);
4250
+ let fetchOptions = buildFetchOptions(options, authOverride);
4214
4251
  let response;
4215
4252
  try {
4253
+ fetchOptions = withRequestTimeout(fetchOptions, timeout);
4216
4254
  response = await fetch(url, fetchOptions);
4217
4255
  } catch (error) {
4218
4256
  logger.error("Network error:", error);
@@ -4227,9 +4265,9 @@ function createFFIDClient(config) {
4227
4265
  const refreshResult = await refreshAccessToken();
4228
4266
  if (!refreshResult.error) {
4229
4267
  logger.debug("Token refreshed, retrying request");
4230
- const retryOptions = buildFetchOptions(options);
4231
4268
  try {
4232
- response = await fetch(url, retryOptions);
4269
+ fetchOptions = withRequestTimeout(buildFetchOptions(options), timeout);
4270
+ response = await fetch(url, fetchOptions);
4233
4271
  } catch (retryError) {
4234
4272
  logger.error("Network error on retry:", retryError);
4235
4273
  return {
@@ -4248,6 +4286,15 @@ function createFFIDClient(config) {
4248
4286
  try {
4249
4287
  raw = await response.json();
4250
4288
  } catch (parseError) {
4289
+ if (fetchOptions.signal?.aborted) {
4290
+ logger.error("Response body aborted:", parseError);
4291
+ return {
4292
+ error: {
4293
+ code: FFID_ERROR_CODES.NETWORK_ERROR,
4294
+ message: "\u30EC\u30B9\u30DD\u30F3\u30B9\u306E\u53D7\u4FE1\u304C\u4E2D\u65AD\u3055\u308C\u307E\u3057\u305F"
4295
+ }
4296
+ };
4297
+ }
4251
4298
  logger.error("Parse error:", parseError, "Status:", response.status);
4252
4299
  return {
4253
4300
  error: {
@@ -4609,6 +4656,50 @@ function createFFIDClient(config) {
4609
4656
  };
4610
4657
  }
4611
4658
 
4659
+ // src/client/authorization-error.ts
4660
+ var FFID_OAUTH_DENIAL_REASONS = [
4661
+ /** The user is not a member of the organization */
4662
+ "no_membership",
4663
+ /** The organization has no usable subscription for the service */
4664
+ "no_active_subscription",
4665
+ /** The organization has a subscription, but the user has no seat in it */
4666
+ "no_seat_assignment",
4667
+ /** The subscription has expired (canceled / past due beyond grace) */
4668
+ "subscription_expired",
4669
+ /** The flat plan's member limit is full, so no seat could be given */
4670
+ "member_limit_reached"
4671
+ ];
4672
+ function isFFIDOAuthDenialReason(value) {
4673
+ return typeof value === "string" && FFID_OAUTH_DENIAL_REASONS.includes(value);
4674
+ }
4675
+ var DEFAULT_AUTHORIZATION_ERROR_MESSAGE = "\u8A8D\u53EF\u30B5\u30FC\u30D0\u30FC\u304C\u30A8\u30E9\u30FC\u3092\u8FD4\u3057\u307E\u3057\u305F";
4676
+ function buildAuthorizationError(params) {
4677
+ const oauthError = params.get("error");
4678
+ if (!oauthError) return null;
4679
+ const rawReason = params.get("denial_reason");
4680
+ const errorDescription = params.get("error_description");
4681
+ const details = {
4682
+ source: "authorization",
4683
+ denialReason: isFFIDOAuthDenialReason(rawReason) ? rawReason : null,
4684
+ errorDescription
4685
+ };
4686
+ return {
4687
+ code: oauthError,
4688
+ message: errorDescription ?? DEFAULT_AUTHORIZATION_ERROR_MESSAGE,
4689
+ details: { ...details }
4690
+ };
4691
+ }
4692
+ function getFFIDAuthorizationError(error) {
4693
+ const details = error?.details;
4694
+ if (!error || !details || details.source !== "authorization") return null;
4695
+ const description = details.errorDescription;
4696
+ return {
4697
+ oauthError: error.code,
4698
+ denialReason: isFFIDOAuthDenialReason(details.denialReason) ? details.denialReason : null,
4699
+ errorDescription: typeof description === "string" ? description : null
4700
+ };
4701
+ }
4702
+
4612
4703
  // src/client/callback.ts
4613
4704
  var CALLBACK_ERROR_CODES = {
4614
4705
  /** Stored state missing or did not match the callback `state` (CSRF guard) */
@@ -4633,16 +4724,43 @@ function extractSearchParams(source) {
4633
4724
  return new URLSearchParams(query);
4634
4725
  }
4635
4726
  }
4636
- function cleanCallbackUrlParams(logger) {
4727
+ var CODE_CALLBACK_PARAMS = ["code", "state"];
4728
+ var ERROR_CALLBACK_CONSUMED_PARAMS = ["state"];
4729
+ function cleanCallbackUrlParams(logger, names) {
4637
4730
  try {
4638
4731
  const cleanUrl = new URL(window.location.href);
4639
- cleanUrl.searchParams.delete("code");
4640
- cleanUrl.searchParams.delete("state");
4732
+ for (const name of names) cleanUrl.searchParams.delete(name);
4641
4733
  window.history.replaceState({}, "", cleanUrl.toString());
4642
4734
  } catch (error) {
4643
4735
  logger.warn("handleRedirectCallback: URL \u306E\u30AF\u30EA\u30FC\u30F3\u30A2\u30C3\u30D7\u306B\u5931\u6557\u3057\u307E\u3057\u305F:", error);
4644
4736
  }
4645
4737
  }
4738
+ function validateCallbackState(client, urlState) {
4739
+ const { logger } = client;
4740
+ const storedState = retrieveState(logger);
4741
+ if (storedState === null && urlState === null) return null;
4742
+ if (storedState !== null && storedState === urlState) return null;
4743
+ if (storedState === null && !isStateStorageAvailable()) {
4744
+ logger.error(
4745
+ "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"
4746
+ );
4747
+ return {
4748
+ error: client.createError(
4749
+ CALLBACK_ERROR_CODES.STATE_UNAVAILABLE,
4750
+ "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"
4751
+ )
4752
+ };
4753
+ }
4754
+ logger.error(
4755
+ "handleRedirectCallback: OAuth state mismatch or omitted (possible CSRF) \u2014 \u8A8D\u8A3C\u3092\u4E2D\u6B62\u3057\u307E\u3057\u305F\u3002\u6D88\u8CBB\u6E08\u307F callback \u30D1\u30E9\u30E1\u30FC\u30BF\u306E\u518D\u51E6\u7406\uFF08`cleanUrl: false` \u5229\u7528\u6642\u306E cleanup \u6F0F\u308C\u3084 reload \u7B49\uFF09\u3067\u3082\u767A\u751F\u3057\u307E\u3059"
4756
+ );
4757
+ return {
4758
+ error: client.createError(
4759
+ CALLBACK_ERROR_CODES.STATE_MISMATCH,
4760
+ "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"
4761
+ )
4762
+ };
4763
+ }
4646
4764
  async function handleRedirectCallback(client, options) {
4647
4765
  const { logger } = client;
4648
4766
  const hasWindow = typeof window !== "undefined";
@@ -4659,6 +4777,24 @@ async function handleRedirectCallback(client, options) {
4659
4777
  const params = extractSearchParams(source);
4660
4778
  const code = params.get("code");
4661
4779
  const urlState = params.get("state");
4780
+ const authorizationError = buildAuthorizationError(params);
4781
+ if (authorizationError && urlState !== null) {
4782
+ const stateError2 = validateCallbackState(client, urlState);
4783
+ if (stateError2) return stateError2;
4784
+ retrieveCodeVerifier(logger);
4785
+ logger.warn("handleRedirectCallback: \u8A8D\u53EF\u30B5\u30FC\u30D0\u30FC\u304C\u30A8\u30E9\u30FC\u3092\u8FD4\u3057\u307E\u3057\u305F:", {
4786
+ error: authorizationError.code,
4787
+ denialReason: params.get("denial_reason")
4788
+ });
4789
+ if (hasWindow) {
4790
+ if (options?.cleanUrl !== false) {
4791
+ cleanCallbackUrlParams(logger, ERROR_CALLBACK_CONSUMED_PARAMS);
4792
+ } else {
4793
+ logger.debug("handleRedirectCallback: cleanUrl=false \u306E\u305F\u3081 URL cleanup \u3092 consumer \u306B\u59D4\u8B72\u3057\u307E\u3057\u305F");
4794
+ }
4795
+ }
4796
+ return { error: authorizationError };
4797
+ }
4662
4798
  if (!code) {
4663
4799
  logger.warn("handleRedirectCallback: \u8A8D\u53EF\u30B3\u30FC\u30C9\u304C\u898B\u3064\u304B\u308A\u307E\u305B\u3093");
4664
4800
  return {
@@ -4668,37 +4804,13 @@ async function handleRedirectCallback(client, options) {
4668
4804
  )
4669
4805
  };
4670
4806
  }
4671
- const storedState = retrieveState(logger);
4672
- if (storedState !== null || urlState !== null) {
4673
- const stateMatches = storedState !== null && storedState === urlState;
4674
- if (!stateMatches) {
4675
- if (storedState === null && !isStateStorageAvailable()) {
4676
- logger.error(
4677
- "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"
4678
- );
4679
- return {
4680
- error: client.createError(
4681
- CALLBACK_ERROR_CODES.STATE_UNAVAILABLE,
4682
- "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"
4683
- )
4684
- };
4685
- }
4686
- logger.error(
4687
- "handleRedirectCallback: OAuth state mismatch or omitted (possible CSRF) \u2014 \u8A8D\u8A3C\u3092\u4E2D\u6B62\u3057\u307E\u3057\u305F\u3002\u6D88\u8CBB\u6E08\u307F callback \u30D1\u30E9\u30E1\u30FC\u30BF\u306E\u518D\u51E6\u7406\uFF08`cleanUrl: false` \u5229\u7528\u6642\u306E cleanup \u6F0F\u308C\u3084 reload \u7B49\uFF09\u3067\u3082\u767A\u751F\u3057\u307E\u3059"
4688
- );
4689
- return {
4690
- error: client.createError(
4691
- CALLBACK_ERROR_CODES.STATE_MISMATCH,
4692
- "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"
4693
- )
4694
- };
4695
- }
4696
- }
4807
+ const stateError = validateCallbackState(client, urlState);
4808
+ if (stateError) return stateError;
4697
4809
  const verifier = retrieveCodeVerifier(logger);
4698
4810
  const result = await client.exchangeCodeForTokens(code, verifier ?? void 0);
4699
4811
  if (!result.error && hasWindow) {
4700
4812
  if (options?.cleanUrl !== false) {
4701
- cleanCallbackUrlParams(logger);
4813
+ cleanCallbackUrlParams(logger, CODE_CALLBACK_PARAMS);
4702
4814
  } else {
4703
4815
  logger.debug("handleRedirectCallback: cleanUrl=false \u306E\u305F\u3081 URL cleanup \u3092 consumer \u306B\u59D4\u8B72\u3057\u307E\u3057\u305F");
4704
4816
  }
@@ -4707,6 +4819,13 @@ async function handleRedirectCallback(client, options) {
4707
4819
  }
4708
4820
  var DEFAULT_REFRESH_INTERVAL_MS = 5 * 60 * 1e3;
4709
4821
  var TOKEN_REFRESH_RATIO = 0.8;
4822
+ function resolveCallbackKey(params) {
4823
+ const code = params.get("code");
4824
+ if (code) return code;
4825
+ const error = params.get("error");
4826
+ const state = params.get("state");
4827
+ return error && state ? `error:${error}:${state}` : null;
4828
+ }
4710
4829
  var FFIDContext = createContext(null);
4711
4830
  var FFIDClientContext = createContext(null);
4712
4831
  function FFIDProvider({
@@ -4886,19 +5005,26 @@ function FFIDProvider({
4886
5005
  },
4887
5006
  [organizations, client]
4888
5007
  );
4889
- const handledCodeRef = useRef(null);
5008
+ const handledCallbackRef = useRef(null);
5009
+ const callbackPendingRef = useRef(false);
5010
+ const authorizationDeniedRef = useRef(false);
4890
5011
  useEffect(() => {
4891
5012
  if (client.authMode !== "token") return;
4892
5013
  if (typeof window === "undefined") return;
4893
- const urlParams = new URLSearchParams(window.location.search);
4894
- const code = urlParams.get("code");
4895
- if (!code) return;
4896
- if (handledCodeRef.current === code) return;
4897
- handledCodeRef.current = code;
4898
- client.logger.debug("Authorization code detected, exchanging for tokens");
5014
+ const callbackKey = resolveCallbackKey(new URLSearchParams(window.location.search));
5015
+ if (!callbackKey) return;
5016
+ if (handledCallbackRef.current === callbackKey) return;
5017
+ handledCallbackRef.current = callbackKey;
5018
+ callbackPendingRef.current = true;
5019
+ client.logger.debug("OAuth callback detected, handling it");
4899
5020
  handleRedirectCallback(client, { cleanUrl: cleanCallbackUrl }).then((result) => {
4900
5021
  if (result.error) {
4901
- client.logger.error("Token exchange failed:", result.error);
5022
+ if (getFFIDAuthorizationError(result.error)) {
5023
+ client.logger.warn("FFID \u304C\u30ED\u30B0\u30A4\u30F3\u3092\u62D2\u5426\u3057\u307E\u3057\u305F:", result.error);
5024
+ authorizationDeniedRef.current = true;
5025
+ } else {
5026
+ client.logger.error("Token exchange failed:", result.error);
5027
+ }
4902
5028
  if (result.error.code === FFID_ERROR_CODES.STATE_MISMATCH_ERROR && client.tokenStore.getTokens()) {
4903
5029
  client.logger.debug(
4904
5030
  "Stale callback rejected but stored tokens present, restoring session via refresh"
@@ -4916,13 +5042,15 @@ function FFIDProvider({
4916
5042
  }).catch((err) => {
4917
5043
  client.logger.error("Token exchange error:", err);
4918
5044
  setIsLoading(false);
5045
+ }).finally(() => {
5046
+ callbackPendingRef.current = false;
4919
5047
  });
4920
5048
  }, [client]);
4921
5049
  useEffect(() => {
4922
5050
  if (client.authMode === "token") {
5051
+ if (callbackPendingRef.current || authorizationDeniedRef.current) return;
4923
5052
  if (typeof window !== "undefined") {
4924
- const urlParams = new URLSearchParams(window.location.search);
4925
- if (urlParams.has("code")) {
5053
+ if (resolveCallbackKey(new URLSearchParams(window.location.search))) {
4926
5054
  return;
4927
5055
  }
4928
5056
  }
@@ -6737,4 +6865,4 @@ function FFIDInquiryForm({
6737
6865
  );
6738
6866
  }
6739
6867
 
6740
- export { ACCESS_GRANTING_EFFECTIVE_STATUSES, AGENCY_INVITE_COOKIE_MAX_AGE_SEC, AGENCY_INVITE_COOKIE_NAME, AGENCY_INVITE_TOKEN_PARAM, AGENCY_INVITE_TOKEN_REDACTION, BLOCKING_EFFECTIVE_STATUSES, CATALOG_HASH_PATTERN, DEFAULT_API_BASE_URL, DEFAULT_OAUTH_SCOPES, EFFECTIVE_SUBSCRIPTION_STATUSES, FFIDAnnouncementBadge, FFIDAnnouncementList, FFIDCatalogHashError, FFIDInquiryForm, FFIDLoginButton, FFIDOrganizationSwitcher, FFIDProvider, FFIDSDKError, FFIDSubscriptionBadge, FFIDUserMenu, FFID_ALLOWED_ORGANIZATION_LOGO_MIME_TYPES, FFID_ANNOUNCEMENTS_ERROR_CODES, FFID_CATALOG_ENVIRONMENTS, FFID_CATALOG_PUBLICATION_STATUSES, FFID_COMPANY_SIZE_CODES, FFID_CREDITS_CONSUME_MAX_AMOUNT, FFID_CREDITS_CONSUME_MIN_AMOUNT, FFID_CREDITS_ERROR_CODES, FFID_CREDITS_FEATURE_PATTERN, FFID_CREDITS_HISTORY_DEFAULT_LIMIT, FFID_CREDITS_HISTORY_MAX_LIMIT, FFID_CREDITS_HISTORY_MIN_LIMIT, FFID_CREDITS_IDEMPOTENCY_KEY_PATTERN, FFID_CREDITS_LOW_THRESHOLD_PERCENT, FFID_CREDITS_METADATA_MAX_BYTES, FFID_CREDIT_GRANT_KINDS, FFID_CREDIT_HISTORY_TYPES, FFID_CREDIT_PACK_MAX_QUANTITY, FFID_CREDIT_PACK_MIN_QUANTITY, FFID_ERROR_CODES, FFID_INDUSTRY_CODES, FFID_INQUIRY_ACQUISITION_SOURCES, FFID_INQUIRY_CATEGORIES, FFID_INQUIRY_CATEGORIES_SITE_2026, FFID_MAX_ORGANIZATION_LOGO_FILE_SIZE_BYTES, FFID_MAX_ORGANIZATION_LOGO_FILE_SIZE_MB, FFID_ORGANIZATION_PROFILE_ERROR_CODES, FFID_SUBSCRIPTION_ERROR_CODES, canonicalizeCatalogValue, captureAgencyInviteToken, cleanupStateStorage, clearAgencyInviteToken, computeEffectiveStatusFromSession, computeFFIDCheckoutCopyHash, computeLegalDisclosureHash, computePaymentConfigurationHash, computePraxisCopyHash, computeScopeHash, computeTaxConfigurationHash, createFFIDAnnouncementsClient, createFFIDClient, createTokenStore, generateCodeChallenge, generateCodeVerifier, handleRedirectCallback, hasAccessFromUserinfo, hashCanonicalJson, isBlockedFromUserinfo, isFFIDInquiryAcquisitionSource, isFFIDInquiryCategorySite2026, normalizeRedirectUri, readAgencyInviteCookie, readAgencyInviteToken, redactAgencyInviteToken, retrieveCodeVerifier, retrieveState, sha256Hex, storeCodeVerifier, storeState, useFFID, useFFIDAnnouncements, useFFIDContext, useSubscription, validatePublishedCatalog, withSubscription };
6868
+ export { ACCESS_GRANTING_EFFECTIVE_STATUSES, AGENCY_INVITE_COOKIE_MAX_AGE_SEC, AGENCY_INVITE_COOKIE_NAME, AGENCY_INVITE_TOKEN_PARAM, AGENCY_INVITE_TOKEN_REDACTION, BLOCKING_EFFECTIVE_STATUSES, CATALOG_HASH_PATTERN, DEFAULT_API_BASE_URL, DEFAULT_OAUTH_SCOPES, EFFECTIVE_SUBSCRIPTION_STATUSES, FFIDAnnouncementBadge, FFIDAnnouncementList, FFIDCatalogHashError, FFIDInquiryForm, FFIDLoginButton, FFIDOrganizationSwitcher, FFIDProvider, FFIDSDKError, FFIDSubscriptionBadge, FFIDUserMenu, FFID_ALLOWED_ORGANIZATION_LOGO_MIME_TYPES, FFID_ANNOUNCEMENTS_ERROR_CODES, FFID_CATALOG_ENVIRONMENTS, FFID_CATALOG_PUBLICATION_STATUSES, FFID_COMPANY_SIZE_CODES, FFID_CREDITS_CONSUME_MAX_AMOUNT, FFID_CREDITS_CONSUME_MIN_AMOUNT, FFID_CREDITS_ERROR_CODES, FFID_CREDITS_FEATURE_PATTERN, FFID_CREDITS_HISTORY_DEFAULT_LIMIT, FFID_CREDITS_HISTORY_MAX_LIMIT, FFID_CREDITS_HISTORY_MIN_LIMIT, FFID_CREDITS_IDEMPOTENCY_KEY_PATTERN, FFID_CREDITS_LOW_THRESHOLD_PERCENT, FFID_CREDITS_METADATA_MAX_BYTES, FFID_CREDIT_GRANT_KINDS, FFID_CREDIT_HISTORY_TYPES, FFID_CREDIT_PACK_MAX_QUANTITY, FFID_CREDIT_PACK_MIN_QUANTITY, FFID_ERROR_CODES, FFID_INDUSTRY_CODES, FFID_INQUIRY_ACQUISITION_SOURCES, FFID_INQUIRY_CATEGORIES, FFID_INQUIRY_CATEGORIES_SITE_2026, FFID_MAX_ORGANIZATION_LOGO_FILE_SIZE_BYTES, FFID_MAX_ORGANIZATION_LOGO_FILE_SIZE_MB, FFID_OAUTH_DENIAL_REASONS, FFID_ORGANIZATION_PROFILE_ERROR_CODES, FFID_SUBSCRIPTION_ERROR_CODES, canonicalizeCatalogValue, captureAgencyInviteToken, cleanupStateStorage, clearAgencyInviteToken, computeEffectiveStatusFromSession, computeFFIDCheckoutCopyHash, computeLegalDisclosureHash, computePaymentConfigurationHash, computePraxisCopyHash, computeScopeHash, computeTaxConfigurationHash, createFFIDAnnouncementsClient, createFFIDClient, createTokenStore, generateCodeChallenge, generateCodeVerifier, getFFIDAuthorizationError, handleRedirectCallback, hasAccessFromUserinfo, hashCanonicalJson, isBlockedFromUserinfo, isFFIDInquiryAcquisitionSource, isFFIDInquiryCategorySite2026, isFFIDOAuthDenialReason, normalizeRedirectUri, readAgencyInviteCookie, readAgencyInviteToken, redactAgencyInviteToken, retrieveCodeVerifier, retrieveState, sha256Hex, storeCodeVerifier, storeState, useFFID, useFFIDAnnouncements, useFFIDContext, useSubscription, validatePublishedCatalog, withSubscription };