auth.ts 5.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218
  1. function resolveAppOrigin(apiBaseUrl: string): string {
  2. return apiBaseUrl.replace(/\/api\/actions\/?$/, "");
  3. }
  4. export function splitSetCookieHeader(combined: string): string[] {
  5. const cookies: string[] = [];
  6. let start = 0;
  7. let inExpires = false;
  8. let inQuotes = false;
  9. let escapeNext = false;
  10. function isExpiresStart(index: number): boolean {
  11. if (combined.slice(index, index + 8).toLowerCase() !== "expires=") return false;
  12. if (index === 0) return true;
  13. const prev = combined[index - 1];
  14. return prev === ";" || prev === " " || prev === "\t";
  15. }
  16. for (let i = 0; i < combined.length; i++) {
  17. const ch = combined[i];
  18. if (inQuotes) {
  19. if (escapeNext) {
  20. escapeNext = false;
  21. continue;
  22. }
  23. if (ch === "\\") {
  24. escapeNext = true;
  25. continue;
  26. }
  27. if (ch === '"') {
  28. inQuotes = false;
  29. }
  30. continue;
  31. }
  32. if (ch === '"') {
  33. inQuotes = true;
  34. continue;
  35. }
  36. if (!inExpires && isExpiresStart(i)) {
  37. inExpires = true;
  38. i += 7;
  39. continue;
  40. }
  41. if (inExpires && ch === ";") {
  42. inExpires = false;
  43. continue;
  44. }
  45. if (ch !== ",") continue;
  46. const next = combined.slice(i + 1);
  47. const looksLikeCookieStart = /^\s*[^;\s]+=/.test(next);
  48. if (!looksLikeCookieStart) {
  49. continue;
  50. }
  51. const part = combined.slice(start, i).trim();
  52. if (part) {
  53. cookies.push(part);
  54. }
  55. start = i + 1;
  56. inExpires = false;
  57. }
  58. const last = combined.slice(start).trim();
  59. if (last) {
  60. cookies.push(last);
  61. }
  62. return cookies;
  63. }
  64. function getSetCookieHeaders(response: Response): string[] {
  65. const headersWithGetSetCookie = response.headers as unknown as {
  66. getSetCookie?: () => string[];
  67. };
  68. const headerList = headersWithGetSetCookie.getSetCookie?.();
  69. if (Array.isArray(headerList) && headerList.length > 0) {
  70. return headerList;
  71. }
  72. const combined = response.headers.get("set-cookie");
  73. if (!combined) return [];
  74. return splitSetCookieHeader(combined);
  75. }
  76. function extractCookieValue(setCookieHeader: string, cookieName: string): string | null {
  77. const trimmed = setCookieHeader.trim();
  78. if (!trimmed) return null;
  79. const segments = trimmed.split(";");
  80. for (const segment of segments) {
  81. const part = segment.trim();
  82. if (!part) continue;
  83. if (part.startsWith(`${cookieName}=`)) {
  84. return part.slice(cookieName.length + 1) || null;
  85. }
  86. }
  87. return null;
  88. }
  89. async function sleep(ms: number): Promise<void> {
  90. await new Promise((resolve) => setTimeout(resolve, ms));
  91. }
  92. function shouldRetryFetchError(error: unknown): boolean {
  93. if (!(error instanceof Error)) return false;
  94. const retryCodes = new Set([
  95. "ECONNREFUSED",
  96. "ECONNRESET",
  97. "ETIMEDOUT",
  98. "EAI_AGAIN",
  99. "UND_ERR_CONNECT_TIMEOUT",
  100. "UND_ERR_HEADERS_TIMEOUT",
  101. "UND_ERR_BODY_TIMEOUT",
  102. "UND_ERR_SOCKET",
  103. ]);
  104. const errorWithCause = error as { cause?: unknown; code?: unknown };
  105. const maybeCodes: string[] = [];
  106. if (typeof errorWithCause.code === "string") {
  107. maybeCodes.push(errorWithCause.code);
  108. }
  109. const cause = errorWithCause.cause;
  110. if (cause && typeof cause === "object") {
  111. const causeCode = (cause as { code?: unknown }).code;
  112. if (typeof causeCode === "string") {
  113. maybeCodes.push(causeCode);
  114. }
  115. }
  116. if (maybeCodes.length > 0) {
  117. return maybeCodes.some((code) => retryCodes.has(code));
  118. }
  119. const message = error.message.toLowerCase();
  120. return message.includes("fetch failed");
  121. }
  122. export async function loginAndGetAuthToken(apiBaseUrl: string, key: string): Promise<string> {
  123. const origin = resolveAppOrigin(apiBaseUrl);
  124. const url = `${origin}/api/auth/login`;
  125. const maxAttempts = 10;
  126. let lastError: unknown;
  127. for (let attempt = 1; attempt <= maxAttempts; attempt++) {
  128. let response: Response;
  129. try {
  130. response = await fetch(url, {
  131. method: "POST",
  132. headers: { "Content-Type": "application/json" },
  133. body: JSON.stringify({ key }),
  134. });
  135. } catch (error) {
  136. lastError = error;
  137. if (!shouldRetryFetchError(error)) {
  138. break;
  139. }
  140. if (attempt >= maxAttempts) {
  141. break;
  142. }
  143. await sleep(Math.min(1000, 100 * 2 ** (attempt - 1)));
  144. continue;
  145. }
  146. if (!response.ok) {
  147. const text = await response.text().catch(() => "");
  148. const errorCode = (() => {
  149. try {
  150. const parsed = JSON.parse(text) as { errorCode?: unknown };
  151. return typeof parsed?.errorCode === "string" ? parsed.errorCode : undefined;
  152. } catch {
  153. return undefined;
  154. }
  155. })();
  156. const error = new Error(`[e2e] login failed: ${response.status} ${text}`);
  157. const shouldRetry = response.status === 503 && errorCode === "SESSION_CREATE_FAILED";
  158. if (!shouldRetry) {
  159. throw error;
  160. }
  161. lastError = error;
  162. if (attempt >= maxAttempts) {
  163. break;
  164. }
  165. await sleep(Math.min(1000, 100 * 2 ** (attempt - 1)));
  166. continue;
  167. }
  168. const setCookieHeaders = getSetCookieHeaders(response);
  169. const authToken = setCookieHeaders
  170. .map((header) => extractCookieValue(header, "auth-token"))
  171. .find((value): value is string => Boolean(value));
  172. if (!authToken) {
  173. throw new Error("[e2e] login succeeded but auth-token cookie is missing");
  174. }
  175. return authToken;
  176. }
  177. throw lastError instanceof Error ? lastError : new Error("[e2e] login failed");
  178. }