@geekapps/auth-nextjs 0.22.0 → 0.24.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.
- package/README.md +250 -210
- package/dist/actions.js +1 -10
- package/dist/actions.js.map +1 -1
- package/dist/client.js.map +1 -1
- package/dist/connections.d.ts +101 -9
- package/dist/connections.js +313 -19
- package/dist/connections.js.map +1 -1
- package/dist/middleware.js +47 -26
- package/dist/middleware.js.map +1 -1
- package/dist/server.d.ts +20 -2
- package/dist/server.js +65 -18
- package/dist/server.js.map +1 -1
- package/dist/typegen.d.ts +42 -10
- package/dist/typegen.js +116 -15
- package/dist/typegen.js.map +1 -1
- package/package.json +73 -68
package/dist/middleware.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/middleware.ts","../src/server.ts"],"sourcesContent":["import { NextResponse, type NextRequest } from \"next/server\";\r\nimport { createRemoteJWKSet, jwtVerify } from \"jose\";\r\nimport { GEEKAPPS_AUTH_SERVER_URL } from \"./server\";\r\n\r\nexport type AuthMiddlewareConfig = {\r\n issuer?: string;\r\n basePath?: string;\r\n /**\r\n * @deprecated cookiePrefix is now auto-derived from GEEKAPPS_AUTH_CLIENT_ID.\r\n * This field is kept for backwards compatibility but is ignored.\r\n */\r\n cookiePrefix?: string;\r\n secureCookies?: boolean;\r\n /** Base URL of this app (e.g. \"https://meuapp.com\"). Overrides the GEEKAPPS_APP_URL env var. */\r\n appUrl?: string;\r\n /**\r\n * When true, unauthenticated requests are redirected to sign-in.\r\n * Defaults to false — the app controls its own auth-gating.\r\n */\r\n forceLoginOnUnauthenticated?: boolean;\r\n};\r\n\r\ntype TokenLikeResponse = {\r\n access_token?: string;\r\n accessToken?: string;\r\n refresh_token?: string;\r\n refreshToken?: string;\r\n id_token?: string;\r\n idToken?: string;\r\n};\r\n\r\nfunction env(name: string) {\r\n const value = process.env[name];\r\n return value && value.trim().length > 0 ? value : undefined;\r\n}\r\n\r\nfunction normalizeBaseUrl(url: string) {\r\n return url.replace(/\\/$/, \"\");\r\n}\r\n\r\nfunction normalizeBasePath(path = \"/api/auth\") {\r\n if (!path.startsWith(\"/\")) return `/${path}`;\r\n return path.replace(/\\/$/, \"\");\r\n}\r\n\r\nfunction resolveIssuer(input: AuthMiddlewareConfig) {\r\n return normalizeBaseUrl(\r\n input.issuer ??\r\n // Deprecated: kept for backwards compatibility during the auth-server rename.\r\n env(\"GEEKAPPS_AUTH_ISSUER\") ??\r\n env(\"GEEKAPPS_AUTH_API_URL\") ??\r\n GEEKAPPS_AUTH_SERVER_URL,\r\n );\r\n}\r\n\r\nfunction hashClientId(clientId: string): string {\r\n let h = 5381;\r\n for (let i = 0; i < clientId.length; i++) {\r\n h = ((h << 5) + h) ^ (clientId.charCodeAt(i) & 0xff);\r\n h = h >>> 0;\r\n }\r\n return h.toString(16).padStart(8, \"0\");\r\n}\r\n\r\nfunction deriveCookiePrefix(clientId: string | undefined): string {\r\n if (!clientId) return \"gk\";\r\n return `gk_${hashClientId(clientId)}`;\r\n}\r\n\r\n\r\nconst MAX_REFRESH_ATTEMPTS = 3;\r\n\r\nfunction decodeJwtExp(token: string): number | null {\r\n try {\r\n const parts = token.split(\".\");\r\n if (parts.length !== 3) return null;\r\n const payload = parts[1]!;\r\n const padded = payload + \"=\".repeat((4 - (payload.length % 4)) % 4);\r\n const decoded = JSON.parse(atob(padded)) as { exp?: number };\r\n return typeof decoded.exp === \"number\" ? decoded.exp : null;\r\n } catch {\r\n return null;\r\n }\r\n}\r\n\r\nfunction resolveAppOrigin(request: NextRequest, appUrl?: string): string {\r\n const configured =\r\n appUrl ??\r\n env(\"GEEKAPPS_APP_URL\");\r\n if (configured) return configured.replace(/\\/$/, \"\");\r\n\r\n throw new Error(\r\n \"Cannot determine app origin for redirect. Set GEEKAPPS_APP_URL.\",\r\n );\r\n}\r\n\r\nconst _jwksCache = new Map<string, ReturnType<typeof createRemoteJWKSet>>();\r\n\r\nfunction getRemoteJWKS(issuer: string) {\r\n if (!_jwksCache.has(issuer)) {\r\n _jwksCache.set(issuer, createRemoteJWKSet(new URL(`${issuer}/.well-known/jwks.json`)));\r\n }\r\n return _jwksCache.get(issuer)!;\r\n}\r\n\r\ntype VerifyResult = \"valid\" | \"invalid\" | \"network_error\";\r\n\r\nasync function verifyAccessToken(token: string, issuer: string): Promise<VerifyResult> {\r\n try {\r\n await jwtVerify(token, getRemoteJWKS(issuer), { algorithms: [\"RS256\"], issuer });\r\n return \"valid\";\r\n } catch (err) {\r\n // TypeError is thrown by fetch when the JWKS endpoint is unreachable.\r\n if (err instanceof TypeError) return \"network_error\";\r\n // ERR_JWKS_* means the JWKS endpoint returned something unexpected\r\n // (e.g. no matching key yet after a key rotation, or a fetch-level error\r\n // wrapped by jose). Treat as transient so we don't destroy the session.\r\n const code = (err as { code?: string }).code ?? \"\";\r\n if (code.startsWith(\"ERR_JWKS\")) return \"network_error\";\r\n // Everything else (ERR_JWS_SIGNATURE_VERIFICATION_FAILED, ERR_JWT_EXPIRED,\r\n // ERR_JWT_CLAIM_VALIDATION_FAILED, …) means the token is genuinely invalid.\r\n return \"invalid\";\r\n }\r\n}\r\n\r\nexport function createAuthMiddleware(input: AuthMiddlewareConfig = {}) {\r\n const issuer = resolveIssuer(input);\r\n const basePath = normalizeBasePath(input.basePath);\r\n const clientId = env(\"GEEKAPPS_AUTH_CLIENT_ID\");\r\n const prefix = deriveCookiePrefix(clientId);\r\n\r\n const activeCookie = `${prefix}_active`;\r\n const countCookie = `${prefix}_count`;\r\n const tokenPubCookie = `${prefix}_tp`; // legacy — cleared on next refresh\r\n const slotAt = (n: number) => `${prefix}_at${n}`;\r\n const slotRt = (n: number) => `${prefix}_rt${n}`;\r\n\r\n // RSC/prefetch requests are cross-origin fetches — a 302 redirect triggers CORS.\r\n // Return a redirect response that Next.js understands instead.\r\n function redirectResponse(request: NextRequest, url: URL): NextResponse {\r\n const isRsc =\r\n request.headers.has(\"rsc\") ||\r\n request.headers.has(\"next-router-prefetch\") ||\r\n request.nextUrl.searchParams.has(\"_rsc\");\r\n if (isRsc) {\r\n return new NextResponse(null, {\r\n status: 302,\r\n headers: {\r\n location: url.toString(),\r\n \"x-action-redirect\": url.toString(),\r\n vary: \"RSC, Next-Router-State-Tree, Next-Router-Prefetch\",\r\n },\r\n }) as NextResponse;\r\n }\r\n return NextResponse.redirect(url);\r\n }\r\n\r\n return async function authMiddleware(request: NextRequest): Promise<NextResponse> {\r\n // Always allow the SDK's own routes through — never redirect /api/auth/* to sign-in.\r\n if (request.nextUrl.pathname.startsWith(basePath + \"/\") || request.nextUrl.pathname === basePath) {\r\n return NextResponse.next();\r\n }\r\n\r\n const countRaw = request.cookies.get(countCookie)?.value;\r\n const count = countRaw ? Number.parseInt(countRaw, 10) : 0;\r\n\r\n const activeRaw = request.cookies.get(activeCookie)?.value;\r\n let activeIdx = activeRaw ? Number.parseInt(activeRaw, 10) : 1;\r\n if (Number.isNaN(activeIdx) || activeIdx < 1 || activeIdx > count) {\r\n activeIdx = count > 0 ? 1 : 0;\r\n }\r\n\r\n // Fallback: if count cookie is missing/zero but slot 1 tokens exist, use slot 1.\r\n // This handles the case where the count/active control cookies expired before the token cookies.\r\n if (activeIdx === 0) {\r\n const fallbackAt = request.cookies.get(slotAt(1))?.value;\r\n const fallbackRt = request.cookies.get(slotRt(1))?.value;\r\n if (fallbackAt || fallbackRt) activeIdx = 1;\r\n }\r\n\r\n const accessToken = activeIdx > 0 ? (request.cookies.get(slotAt(activeIdx))?.value ?? null) : null;\r\n const refreshToken = activeIdx > 0 ? (request.cookies.get(slotRt(activeIdx))?.value ?? null) : null;\r\n\r\n const origin = resolveAppOrigin(request, input.appUrl);\r\n const signInUrl = new URL(`${origin}${basePath}/sign-in`);\r\n const redirectTo = request.nextUrl.pathname + request.nextUrl.search;\r\n if (redirectTo && redirectTo !== \"/\") {\r\n signInUrl.searchParams.set(\"redirectTo\", redirectTo);\r\n }\r\n\r\n if (!accessToken && !refreshToken) {\r\n if (input.forceLoginOnUnauthenticated) return redirectResponse(request, signInUrl);\r\n return NextResponse.next();\r\n }\r\n\r\n // Fast-path: check exp before hitting JWKS (avoids network on every request)\r\n const exp = accessToken ? decodeJwtExp(accessToken) : null;\r\n const nowSec = Math.floor(Date.now() / 1000);\r\n const expiredOrMissing = !exp || exp - nowSec < 60;\r\n\r\n if (!expiredOrMissing) {\r\n // Verify signature against app-api JWKS\r\n const verifyResult = await verifyAccessToken(accessToken!, issuer);\r\n if (verifyResult === \"valid\") return NextResponse.next();\r\n // If JWKS endpoint is unreachable, fail-open to avoid destroying the session.\r\n if (verifyResult === \"network_error\") return NextResponse.next();\r\n // \"invalid\" — fall through to refresh attempt below.\r\n }\r\n\r\n if (!refreshToken) {\r\n if (input.forceLoginOnUnauthenticated) return redirectResponse(request, signInUrl);\r\n return NextResponse.next();\r\n }\r\n\r\n const clientSecret = env(\"GEEKAPPS_AUTH_CLIENT_SECRET\") ?? \"\";\r\n const basicAuth = btoa(`${clientId ?? \"\"}:${clientSecret}`);\r\n\r\n let newAccessToken: string | null = null;\r\n let newRefreshToken: string | null = null;\r\n\r\n for (let attempt = 1; attempt <= MAX_REFRESH_ATTEMPTS; attempt++) {\r\n try {\r\n const refreshResponse = await fetch(`${issuer}/auth/token`, {\r\n method: \"POST\",\r\n headers: {\r\n \"content-type\": \"application/x-www-form-urlencoded\",\r\n authorization: `Basic ${basicAuth}`,\r\n },\r\n body: new URLSearchParams({\r\n grant_type: \"refresh_token\",\r\n refresh_token: refreshToken,\r\n }),\r\n cache: \"no-store\",\r\n });\r\n\r\n if (!refreshResponse.ok) break;\r\n\r\n const data = (await refreshResponse.json()) as TokenLikeResponse;\r\n newAccessToken = data.accessToken ?? data.access_token ?? null;\r\n newRefreshToken = data.refreshToken ?? data.refresh_token ?? null;\r\n if (newAccessToken) break;\r\n } catch {\r\n if (attempt === MAX_REFRESH_ATTEMPTS) break;\r\n }\r\n }\r\n\r\n if (!newAccessToken) {\r\n // Refresh failed — clear the active slot and optionally redirect to sign-in\r\n const response = input.forceLoginOnUnauthenticated\r\n ? redirectResponse(request, signInUrl)\r\n : NextResponse.next();\r\n const clearOpts = {\r\n httpOnly: true,\r\n secure: input.secureCookies ?? process.env.NODE_ENV === \"production\",\r\n sameSite: \"lax\" as const,\r\n path: \"/\",\r\n maxAge: 0,\r\n };\r\n response.cookies.set(slotAt(activeIdx), \"\", clearOpts);\r\n response.cookies.set(slotRt(activeIdx), \"\", clearOpts);\r\n response.cookies.set(`${prefix}_m${activeIdx}`, \"\", clearOpts);\r\n response.cookies.set(tokenPubCookie, \"\", { ...clearOpts, httpOnly: false });\r\n response.cookies.set(activeCookie, \"0\", clearOpts);\r\n return response;\r\n }\r\n\r\n const cookieOptions = {\r\n httpOnly: true,\r\n secure: input.secureCookies ?? process.env.NODE_ENV === \"production\",\r\n sameSite: \"lax\" as const,\r\n path: \"/\",\r\n maxAge: 60 * 60 * 24 * 30,\r\n };\r\n\r\n // Forward new token via request header so server actions in the same\r\n // request can read it — response cookies are not visible within the same request.\r\n const requestHeaders = new Headers(request.headers);\r\n requestHeaders.set(\"authorization\", `Bearer ${newAccessToken}`);\r\n\r\n const response = NextResponse.next({ request: { headers: requestHeaders } });\r\n response.cookies.set(slotAt(activeIdx), newAccessToken, cookieOptions);\r\n if (newRefreshToken) response.cookies.set(slotRt(activeIdx), newRefreshToken, cookieOptions);\r\n // Clear legacy _tp cookie to reduce header size\r\n if (request.cookies.get(tokenPubCookie)?.value) {\r\n response.cookies.set(tokenPubCookie, \"\", { httpOnly: false, maxAge: 0, path: \"/\" });\r\n }\r\n\r\n return response;\r\n };\r\n}\r\n","import { createHash, randomBytes } from \"node:crypto\";\r\nimport { cookies, headers } from \"next/headers\";\r\nimport { redirect } from \"next/navigation\";\r\nimport { NextResponse, type NextRequest } from \"next/server\";\r\nimport { createRemoteJWKSet, jwtVerify, type JWTPayload } from \"jose\";\r\n\r\n/**\r\n * A sessão não vale mais: expirou, foi revogada no painel ou o refresh token\r\n * foi invalidado. Os cookies já foram limpos quando este erro é lançado, então\r\n * o tratamento normal é redirecionar para o login.\r\n */\r\nexport class GeekappsSessionExpiredError extends Error {\r\n readonly code = \"session_expired\" as const;\r\n\r\n constructor(message = \"A sessão expirou ou foi revogada. Entre novamente.\") {\r\n super(message);\r\n this.name = \"GeekappsSessionExpiredError\";\r\n }\r\n}\r\n\r\n/**\r\n * Fixed URL of the Geekapps Auth Server (OIDC issuer + API). This never\r\n * changes per-environment, so it is hardcoded rather than read from env.\r\n */\r\nexport const GEEKAPPS_AUTH_SERVER_URL = \"https://accounts-api.geekapps.com.br\";\r\n\r\nexport type GeekappsAuthConfig = {\r\n /**\r\n * Auth Server (OIDC) issuer URL. Defaults to GEEKAPPS_AUTH_SERVER_URL and\r\n * should not need overriding in normal use.\r\n *\r\n * Deprecated (still supported as a fallback): GEEKAPPS_AUTH_SERVER_ISSUER,\r\n * GEEKAPPS_AUTH_ISSUER, GEEKAPPS_AUTH_SERVER_URL env, GEEKAPPS_AUTH_API_URL\r\n */\r\n issuer?: string;\r\n\r\n /**\r\n * OAuth client id. Optional when GEEKAPPS_AUTH_CLIENT_ID is defined.\r\n */\r\n clientId?: string;\r\n\r\n /**\r\n * OAuth client secret for confidential clients. Optional when\r\n * GEEKAPPS_AUTH_CLIENT_SECRET is defined.\r\n */\r\n clientSecret?: string;\r\n\r\n /**\r\n * Callback URL. Optional when GEEKAPPS_AUTH_REDIRECT_URI is defined.\r\n * If omitted, it is inferred from the current request origin + basePath/callback.\r\n */\r\n redirectUri?: string;\r\n\r\n scopes?: string[];\r\n basePath?: string;\r\n /**\r\n * @deprecated cookiePrefix is now auto-derived from clientId.\r\n * This field is kept for backwards compatibility but is ignored.\r\n */\r\n cookiePrefix?: string;\r\n secureCookies?: boolean;\r\n\r\n /**\r\n * Friendly alias used by apps. Same as afterSignInRedirectTo.\r\n */\r\n redirectTo?: string;\r\n\r\n afterSignInRedirectTo?: string;\r\n afterSignOutRedirectTo?: string;\r\n};\r\n\r\ntype ResolvedGeekappsAuthConfig = GeekappsAuthConfig & {\r\n issuer: string;\r\n clientId: string;\r\n basePath: string;\r\n cookiePrefix: string;\r\n};\r\n\r\nexport type SessionTokens = {\r\n accessToken: string | null;\r\n refreshToken: string | null;\r\n idToken: string | null;\r\n sessionId: string | null;\r\n};\r\n\r\nexport type StoredAccount = {\r\n /** Stable identifier — `sub` from id_token or fallback to sessionId. */\r\n id: string;\r\n accessToken: string;\r\n refreshToken: string | null;\r\n idToken: string | null;\r\n sessionId: string | null;\r\n user: {\r\n sub: string;\r\n name?: string | null;\r\n email?: string | null;\r\n picture?: string | null;\r\n phone?: string | null;\r\n };\r\n};\r\n\r\nexport type ServerAuthApiClient = {\r\n get: <T>(path: string) => Promise<T>;\r\n post: <T>(path: string, body?: unknown) => Promise<T>;\r\n patch: <T>(path: string, body?: unknown) => Promise<T>;\r\n put: <T>(path: string, body?: unknown) => Promise<T>;\r\n delete: <T>(path: string) => Promise<T>;\r\n};\r\n\r\ntype JsonObject = Record<string, unknown>;\r\n\r\ntype TokenLikeResponse = JsonObject & {\r\n access_token?: string;\r\n accessToken?: string;\r\n refresh_token?: string;\r\n refreshToken?: string;\r\n id_token?: string;\r\n idToken?: string;\r\n session_id?: string;\r\n sessionId?: string;\r\n tokens?: {\r\n access_token?: string;\r\n accessToken?: string;\r\n refresh_token?: string;\r\n refreshToken?: string;\r\n id_token?: string;\r\n idToken?: string;\r\n session_id?: string;\r\n sessionId?: string;\r\n };\r\n};\r\n\r\nconst MAX_ACCOUNTS = 8;\r\n\r\nfunction normalizeBaseUrl(url: string) {\r\n return url.replace(/\\/$/, \"\");\r\n}\r\n\r\nfunction normalizeBasePath(path = \"/api/auth\") {\r\n if (!path.startsWith(\"/\")) return `/${path}`;\r\n return path.replace(/\\/$/, \"\");\r\n}\r\n\r\nfunction createUrl(baseUrl: string, path: string) {\r\n if (/^https?:\\/\\//.test(path)) return path;\r\n const normalizedPath = path.startsWith(\"/\") ? path : `/${path}`;\r\n return `${normalizeBaseUrl(baseUrl)}${normalizedPath}`;\r\n}\r\n\r\nfunction env(name: string) {\r\n const value = process.env[name];\r\n return value && value.trim().length > 0 ? value : undefined;\r\n}\r\n\r\nfunction resolveIssuer(input: GeekappsAuthConfig) {\r\n return normalizeBaseUrl(\r\n input.issuer ??\r\n env(\"GEEKAPPS_AUTH_SERVER_ISSUER\") ??\r\n env(\"GEEKAPPS_AUTH_SERVER_URL\") ??\r\n // Deprecated: kept for backwards compatibility during the auth-server rename.\r\n env(\"GEEKAPPS_AUTH_ISSUER\") ??\r\n env(\"GEEKAPPS_AUTH_API_URL\") ??\r\n GEEKAPPS_AUTH_SERVER_URL,\r\n );\r\n}\r\n\r\nfunction resolveClientId(input: GeekappsAuthConfig) {\r\n return input.clientId ?? env(\"GEEKAPPS_AUTH_CLIENT_ID\") ?? \"\";\r\n}\r\n\r\nfunction resolveClientSecret(input: GeekappsAuthConfig) {\r\n return input.clientSecret ?? env(\"GEEKAPPS_AUTH_CLIENT_SECRET\");\r\n}\r\n\r\nfunction applyClientCredentials(url: URL, config: ResolvedGeekappsAuthConfig) {\r\n if (!config.clientId || !config.clientSecret) return;\r\n url.searchParams.set(\"client_id\", config.clientId);\r\n url.searchParams.set(\"client_secret\", config.clientSecret);\r\n}\r\n\r\nfunction resolveConfiguredRedirectUri(input: GeekappsAuthConfig) {\r\n return input.redirectUri ?? env(\"GEEKAPPS_AUTH_REDIRECT_URI\");\r\n}\r\n\r\n/**\r\n * Derives a short stable 8-char hex hash from a clientId string.\r\n * Uses a simple djb2-style hash — no crypto dependency needed.\r\n */\r\nfunction hashClientId(clientId: string): string {\r\n let h = 5381;\r\n for (let i = 0; i < clientId.length; i++) {\r\n h = ((h << 5) + h) ^ (clientId.charCodeAt(i) & 0xff);\r\n h = h >>> 0; // keep unsigned 32-bit\r\n }\r\n return h.toString(16).padStart(8, \"0\");\r\n}\r\n\r\n/**\r\n * Auto-derives the cookie prefix from clientId.\r\n * Format: `gk_{8-char-hash}` — guaranteed unique per OAuth client.\r\n * Falls back to `gk` when clientId is empty (dev/test environments).\r\n */\r\nfunction deriveCookiePrefix(clientId: string): string {\r\n if (!clientId) return \"gk\";\r\n return `gk_${hashClientId(clientId)}`;\r\n}\r\n\r\n// GEEKAPPS_APP_URL é o override de produção: em prod o app roda atrás de\r\n// Docker sem proxy setando x-forwarded-host/x-forwarded-proto, então nem o\r\n// header Host nem request.nextUrl.origin são confiáveis (refletem o host\r\n// INTERNO do container, não o domínio público real) — por isso, quando\r\n// configurada, essa env var sempre vence. Sem ela (dev local, onde não está\r\n// setada no .env), deriva do próprio request — em localhost sem proxy o\r\n// Host já é localhost:PORTA mesmo, então funciona sem precisar de override.\r\nfunction requestOrigin(request: NextRequest) {\r\n const configured = env(\"GEEKAPPS_APP_URL\");\r\n if (configured) return configured.replace(/\\/$/, \"\");\r\n\r\n if (request.nextUrl?.origin) return request.nextUrl.origin;\r\n\r\n throw new Error(\r\n \"Cannot determine app origin. Set GEEKAPPS_APP_URL or ensure request.nextUrl is available.\",\r\n );\r\n}\r\n\r\nfunction resolveRedirectUri(config: ResolvedGeekappsAuthConfig, request: NextRequest) {\r\n return config.redirectUri ?? `${requestOrigin(request)}${config.basePath}/callback`;\r\n}\r\n\r\nfunction tokenFromResponse(data: TokenLikeResponse): SessionTokens {\r\n return {\r\n accessToken: data.accessToken ?? data.access_token ?? data.tokens?.accessToken ?? data.tokens?.access_token ?? null,\r\n refreshToken:\r\n data.refreshToken ?? data.refresh_token ?? data.tokens?.refreshToken ?? data.tokens?.refresh_token ?? null,\r\n idToken: data.idToken ?? data.id_token ?? data.tokens?.idToken ?? data.tokens?.id_token ?? null,\r\n sessionId: data.sessionId ?? data.session_id ?? data.tokens?.sessionId ?? data.tokens?.session_id ?? null,\r\n };\r\n}\r\n\r\n/** Decode a JWT payload without verifying signature (safe for client-visible claims). */\r\nfunction decodeJwtPayload(token: string): Record<string, unknown> | null {\r\n try {\r\n const parts = token.split(\".\");\r\n if (parts.length < 2) return null;\r\n const payload = parts[1];\r\n if (!payload) return null;\r\n return JSON.parse(Buffer.from(payload, \"base64url\").toString(\"utf8\")) as Record<string, unknown>;\r\n } catch {\r\n return null;\r\n }\r\n}\r\n\r\nfunction parseAccountFromJwt(tokens: SessionTokens): StoredAccount | null {\r\n if (!tokens.accessToken) return null;\r\n\r\n const jwtToParse = tokens.idToken ?? tokens.accessToken;\r\n const payload = decodeJwtPayload(jwtToParse);\r\n if (!payload) return null;\r\n\r\n const sub = typeof payload.sub === \"string\" ? payload.sub : null;\r\n const id = sub ?? tokens.sessionId;\r\n if (!id) return null;\r\n\r\n return {\r\n id,\r\n accessToken: tokens.accessToken,\r\n refreshToken: tokens.refreshToken,\r\n idToken: tokens.idToken ?? null,\r\n sessionId: tokens.sessionId,\r\n user: {\r\n sub: id,\r\n name: typeof payload.name === \"string\" ? payload.name : null,\r\n email: typeof payload.email === \"string\" ? payload.email : null,\r\n picture: typeof payload.picture === \"string\" ? payload.picture : null,\r\n phone: typeof payload.phone_number === \"string\" ? payload.phone_number : null,\r\n },\r\n };\r\n}\r\n\r\n/**\r\n * Cookie name scheme — 3 cookies per slot, each well under 4096 bytes:\r\n *\r\n * {prefix}_m{n} — metadata blob (base64url JSON): { id, sid?, n?, e?, p? }\r\n * Claims extracted from id_token at login. Never stores tokens.\r\n * {prefix}_at{n} — access token (httpOnly). ~1-2 KB.\r\n * {prefix}_rt{n} — refresh token (httpOnly). ~1 KB.\r\n * {prefix}_active — active slot index (\"1\", \"2\", …)\r\n * {prefix}_count — total stored slots\r\n * {prefix}_ao — active org id for this app\r\n * {prefix}_tp — non-httpOnly copy of active access token for client-side axios\r\n *\r\n * id_token is NOT stored in cookies — claims are extracted at login and cached in\r\n * the metadata cookie. This is the key size saving.\r\n */\r\nfunction getCookieNames(prefix: string) {\r\n return {\r\n active: `${prefix}_active`,\r\n count: `${prefix}_count`,\r\n activeOrganizationId: `${prefix}_ao`,\r\n tokenPub: `${prefix}_tp`,\r\n pkceVerifier: `${prefix}_pkce`,\r\n slotMeta: (n: number) => `${prefix}_m${n}`,\r\n slotAt: (n: number) => `${prefix}_at${n}`,\r\n slotRt: (n: number) => `${prefix}_rt${n}`,\r\n };\r\n}\r\n\r\nfunction generateCodeVerifier(): string {\r\n return randomBytes(64).toString(\"base64url\");\r\n}\r\n\r\nfunction createCodeChallenge(codeVerifier: string): string {\r\n return createHash(\"sha256\").update(codeVerifier).digest(\"base64url\");\r\n}\r\n\r\ntype SlotMeta = {\r\n id: string;\r\n sid?: string;\r\n n?: string;\r\n e?: string;\r\n p?: string;\r\n};\r\n\r\nfunction encodeMeta(account: StoredAccount): string {\r\n const m: SlotMeta = { id: account.id };\r\n if (account.sessionId) m.sid = account.sessionId;\r\n if (account.user.name) m.n = account.user.name;\r\n if (account.user.email) m.e = account.user.email;\r\n if (account.user.picture) m.p = account.user.picture;\r\n return Buffer.from(JSON.stringify(m), \"utf8\").toString(\"base64url\");\r\n}\r\n\r\nfunction decodeMeta(value: string): SlotMeta | null {\r\n try {\r\n const m = JSON.parse(Buffer.from(value, \"base64url\").toString(\"utf8\")) as SlotMeta;\r\n return m.id ? m : null;\r\n } catch {\r\n return null;\r\n }\r\n}\r\n\r\nfunction getCookieOptions(config: ResolvedGeekappsAuthConfig) {\r\n return {\r\n httpOnly: true,\r\n secure: config.secureCookies ?? process.env.NODE_ENV === \"production\",\r\n sameSite: \"lax\" as const,\r\n path: \"/\",\r\n };\r\n}\r\n\r\nasync function readBody(request: NextRequest) {\r\n const text = await request.text();\r\n if (!text) return undefined;\r\n try {\r\n return JSON.parse(text);\r\n } catch {\r\n return text;\r\n }\r\n}\r\n\r\nfunction appendSearchParams(url: string, request: NextRequest) {\r\n const target = new URL(url);\r\n request.nextUrl.searchParams.forEach((value, key) => {\r\n target.searchParams.set(key, value);\r\n });\r\n return target.toString();\r\n}\r\n\r\nfunction routeFromRequest(request: NextRequest, basePath: string) {\r\n const pathname = request.nextUrl.pathname;\r\n if (!pathname.startsWith(basePath)) return pathname;\r\n const route = pathname.slice(basePath.length);\r\n return route || \"/\";\r\n}\r\n\r\nfunction isJsonResponse(response: Response) {\r\n return response.headers.get(\"content-type\")?.includes(\"application/json\");\r\n}\r\n\r\nasync function responseFromApi(response: Response) {\r\n if (response.status === 204) return new NextResponse(null, { status: 204 });\r\n const body = isJsonResponse(response) ? await response.json().catch(() => null) : await response.text();\r\n return NextResponse.json(body, { status: response.status });\r\n}\r\n\r\nconst _remoteJWKSCache = new Map<string, ReturnType<typeof createRemoteJWKSet>>();\r\n\r\ntype ServiceTokenCache = { token: string; expiresAt: number };\r\nconst _serviceTokenCache = new Map<string, ServiceTokenCache>();\r\n\r\nasync function fetchServiceToken(issuer: string, clientId: string, clientSecret: string): Promise<string | null> {\r\n const credentials = Buffer.from(`${clientId}:${clientSecret}`).toString(\"base64\");\r\n try {\r\n const res = await fetch(createUrl(issuer, \"/auth/token\"), {\r\n method: \"POST\",\r\n headers: {\r\n \"content-type\": \"application/x-www-form-urlencoded\",\r\n authorization: `Basic ${credentials}`,\r\n },\r\n body: \"grant_type=service_account\",\r\n cache: \"no-store\",\r\n });\r\n if (!res.ok) return null;\r\n const data = (await res.json()) as { access_token?: string };\r\n return data.access_token ?? null;\r\n } catch {\r\n return null;\r\n }\r\n}\r\n\r\nasync function getServiceToken(issuer: string, clientId: string, clientSecret: string): Promise<string | null> {\r\n const key = `${issuer}:${clientId}`;\r\n const now = Date.now();\r\n const cached = _serviceTokenCache.get(key);\r\n if (cached && cached.expiresAt - 30_000 > now) return cached.token;\r\n const token = await fetchServiceToken(issuer, clientId, clientSecret);\r\n if (!token) return null;\r\n _serviceTokenCache.set(key, { token, expiresAt: now + 420_000 });\r\n return token;\r\n}\r\n\r\ntype ServiceAccountConfig = {\r\n issuer?: string;\r\n clientId?: string;\r\n clientSecret?: string;\r\n};\r\n\r\n/**\r\n * Returns a service account access token.\r\n * The issuer is always the Geekapps Auth Server (GEEKAPPS_AUTH_SERVER_URL) —\r\n * it does not need to be configured. Reads GEEKAPPS_SERVICE_ACCOUNT_CLIENT_ID,\r\n * GEEKAPPS_SERVICE_ACCOUNT_CLIENT_SECRET by default.\r\n * Any field can be overridden via the optional config argument.\r\n * Tokens are cached per client and auto-renewed before expiry (~7 min TTL).\r\n */\r\nexport function getServiceAccountToken(override: ServiceAccountConfig = {}): Promise<string | null> {\r\n const issuer = normalizeBaseUrl(\r\n override.issuer ??\r\n // Deprecated: kept for backwards compatibility during the auth-server rename.\r\n env(\"GEEKAPPS_AUTH_ISSUER\") ??\r\n env(\"GEEKAPPS_AUTH_API_URL\") ??\r\n GEEKAPPS_AUTH_SERVER_URL,\r\n );\r\n const clientId = override.clientId ?? env(\"GEEKAPPS_SERVICE_ACCOUNT_CLIENT_ID\") ?? \"\";\r\n const clientSecret = override.clientSecret ?? env(\"GEEKAPPS_SERVICE_ACCOUNT_CLIENT_SECRET\") ?? \"\";\r\n if (!clientId || !clientSecret) return Promise.resolve(null);\r\n return getServiceToken(issuer, clientId, clientSecret);\r\n}\r\n\r\nfunction getRemoteJWKS(issuer: string) {\r\n if (!_remoteJWKSCache.has(issuer)) {\r\n _remoteJWKSCache.set(issuer, createRemoteJWKSet(new URL(`${issuer}/.well-known/jwks.json`)));\r\n }\r\n return _remoteJWKSCache.get(issuer)!;\r\n}\r\n\r\nexport async function verifyToken(\r\n token: string,\r\n issuer: string,\r\n): Promise<JWTPayload | null> {\r\n try {\r\n const { payload } = await jwtVerify(token, getRemoteJWKS(issuer), {\r\n algorithms: [\"RS256\"],\r\n issuer,\r\n });\r\n return payload;\r\n } catch {\r\n return null;\r\n }\r\n}\r\n\r\nexport function configureGeekappsAuth(input: GeekappsAuthConfig = {}) {\r\n const clientId = resolveClientId(input);\r\n const config: ResolvedGeekappsAuthConfig = {\r\n ...input,\r\n issuer: resolveIssuer(input),\r\n clientId,\r\n clientSecret: resolveClientSecret(input),\r\n redirectUri: resolveConfiguredRedirectUri(input),\r\n afterSignInRedirectTo:\r\n input.afterSignInRedirectTo ?? input.redirectTo ?? \"/\",\r\n basePath: normalizeBasePath(input.basePath),\r\n cookiePrefix: deriveCookiePrefix(clientId),\r\n };\r\n\r\n const cn = getCookieNames(config.cookiePrefix);\r\n\r\n // ── Slot helpers ──────────────────────────────────────────────────────────\r\n\r\n async function getCount(): Promise<number> {\r\n const cookieStore = await cookies();\r\n const raw = cookieStore.get(cn.count)?.value;\r\n const n = raw ? Number.parseInt(raw, 10) : 0;\r\n return Number.isNaN(n) ? 0 : n;\r\n }\r\n\r\n async function getActiveIndex(): Promise<number> {\r\n const cookieStore = await cookies();\r\n const raw = cookieStore.get(cn.active)?.value;\r\n if (!raw) return 0;\r\n const n = Number.parseInt(raw, 10);\r\n return Number.isNaN(n) ? 0 : n;\r\n }\r\n\r\n function readSlot(\r\n cookieStore: Awaited<ReturnType<typeof cookies>>,\r\n n: number,\r\n ): StoredAccount | null {\r\n const at = cookieStore.get(cn.slotAt(n))?.value;\r\n if (!at) return null;\r\n const meta = decodeMeta(cookieStore.get(cn.slotMeta(n))?.value ?? \"\");\r\n if (!meta) return null;\r\n return {\r\n id: meta.id,\r\n accessToken: at,\r\n refreshToken: cookieStore.get(cn.slotRt(n))?.value ?? null,\r\n idToken: null, // not stored in cookies; claims are in meta\r\n sessionId: meta.sid ?? null,\r\n user: {\r\n sub: meta.id,\r\n name: meta.n ?? null,\r\n email: meta.e ?? null,\r\n picture: meta.p ?? null,\r\n },\r\n };\r\n }\r\n\r\n function writeSlot(\r\n cookieStore: Awaited<ReturnType<typeof cookies>>,\r\n n: number,\r\n account: StoredAccount,\r\n ) {\r\n const opts = { ...getCookieOptions(config), maxAge: 60 * 60 * 24 * 30 };\r\n cookieStore.set(cn.slotMeta(n), encodeMeta(account), opts);\r\n cookieStore.set(cn.slotAt(n), account.accessToken, opts);\r\n if (account.refreshToken) cookieStore.set(cn.slotRt(n), account.refreshToken, opts);\r\n }\r\n\r\n function clearSlot(\r\n cookieStore: Awaited<ReturnType<typeof cookies>>,\r\n n: number,\r\n ) {\r\n const clear = { ...getCookieOptions(config), maxAge: 0 };\r\n cookieStore.set(cn.slotMeta(n), \"\", clear);\r\n cookieStore.set(cn.slotAt(n), \"\", clear);\r\n cookieStore.set(cn.slotRt(n), \"\", clear);\r\n }\r\n\r\n // ── Public account helpers ─────────────────────────────────────────────────\r\n\r\n async function getAccountsList(): Promise<StoredAccount[]> {\r\n const [cookieStore, count] = await Promise.all([cookies(), getCount()]);\r\n const accounts: StoredAccount[] = [];\r\n for (let i = 1; i <= count; i++) {\r\n const slot = readSlot(cookieStore, i);\r\n if (slot) accounts.push(slot);\r\n }\r\n return accounts;\r\n }\r\n\r\n async function getActiveAccount(): Promise<StoredAccount | null> {\r\n const [cookieStore, activeIdx, count] = await Promise.all([cookies(), getActiveIndex(), getCount()]);\r\n if (count === 0) return null;\r\n if (activeIdx >= 1 && activeIdx <= count) {\r\n const slot = readSlot(cookieStore, activeIdx);\r\n if (slot) return slot;\r\n }\r\n // Fall back to slot 1\r\n return readSlot(cookieStore, 1);\r\n }\r\n\r\n async function getAccessToken() {\r\n // Prefer the token injected by the middleware via request header (same-request refresh)\r\n try {\r\n const hdrs = await headers();\r\n const auth = hdrs.get(\"authorization\");\r\n if (auth?.startsWith(\"Bearer \")) return auth.slice(7);\r\n } catch {}\r\n const active = await getActiveAccount();\r\n return active?.accessToken ?? null;\r\n }\r\n\r\n async function getRefreshToken() {\r\n const active = await getActiveAccount();\r\n return active?.refreshToken ?? null;\r\n }\r\n\r\n async function getSession(): Promise<SessionTokens | null> {\r\n const active = await getActiveAccount();\r\n if (!active) return null;\r\n return {\r\n accessToken: active.accessToken,\r\n refreshToken: active.refreshToken,\r\n idToken: active.idToken,\r\n sessionId: active.sessionId,\r\n };\r\n }\r\n\r\n async function getUser(): Promise<StoredAccount[\"user\"] | null> {\r\n const active = await getActiveAccount();\r\n return active?.user ?? null;\r\n }\r\n\r\n async function getUserById(userId: string, applicationId?: string): Promise<unknown | null> {\r\n const qs = new URLSearchParams({ ...(applicationId ? { applicationId } : {}) });\r\n try {\r\n return await apiRequest<unknown>(`/users/${userId}?${qs}`);\r\n } catch {\r\n return null;\r\n }\r\n }\r\n\r\n async function getOrganizations(): Promise<{ organizations: unknown[]; currentOrgId: string | null }> {\r\n const cookieStore = await cookies();\r\n const currentOrgId = cookieStore.get(cn.activeOrganizationId)?.value ?? null;\r\n try {\r\n const data = await apiRequest<unknown[] | { organizations: unknown[] }>(\"/organizations\");\r\n const organizations = Array.isArray(data) ? data : (data as { organizations: unknown[] }).organizations;\r\n return { organizations, currentOrgId };\r\n } catch {\r\n return { organizations: [], currentOrgId };\r\n }\r\n }\r\n\r\n function clearPublicTokenCookie(\r\n cookieStore: Awaited<ReturnType<typeof cookies>>,\r\n ) {\r\n // Clear legacy _tp cookie if present\r\n if (cookieStore.get(cn.tokenPub)?.value) {\r\n cookieStore.set(cn.tokenPub, \"\", { httpOnly: false, maxAge: 0, path: \"/\" });\r\n }\r\n }\r\n\r\n async function saveAccountToList(tokens: SessionTokens) {\r\n const account = parseAccountFromJwt(tokens);\r\n if (!account) return;\r\n\r\n const cookieStore = await cookies();\r\n const opts = { ...getCookieOptions(config), maxAge: 60 * 60 * 24 * 30 };\r\n const count = await getCount();\r\n\r\n // Find existing slot for this account id\r\n let targetSlot = 0;\r\n for (let i = 1; i <= count; i++) {\r\n const existing = readSlot(cookieStore, i);\r\n if (existing?.id === account.id) {\r\n targetSlot = i;\r\n break;\r\n }\r\n }\r\n\r\n if (targetSlot === 0) {\r\n if (count >= MAX_ACCOUNTS) return;\r\n targetSlot = count + 1;\r\n cookieStore.set(cn.count, String(targetSlot), opts);\r\n }\r\n\r\n writeSlot(cookieStore, targetSlot, account);\r\n cookieStore.set(cn.active, String(targetSlot), opts);\r\n clearPublicTokenCookie(cookieStore);\r\n }\r\n\r\n // Alias for backwards compat\r\n const setSessionCookies = saveAccountToList;\r\n\r\n async function switchAccount(accountId: string): Promise<boolean> {\r\n const [cookieStore, count] = await Promise.all([cookies(), getCount()]);\r\n const opts = { ...getCookieOptions(config), maxAge: 60 * 60 * 24 * 30 };\r\n\r\n for (let i = 1; i <= count; i++) {\r\n const slot = readSlot(cookieStore, i);\r\n if (slot && (slot.id === accountId || slot.sessionId === accountId)) {\r\n cookieStore.set(cn.active, String(i), opts);\r\n clearPublicTokenCookie(cookieStore);\r\n return true;\r\n }\r\n }\r\n return false;\r\n }\r\n\r\n async function removeActiveAccount() {\r\n const [cookieStore, activeIdx, count] = await Promise.all([cookies(), getActiveIndex(), getCount()]);\r\n const opts = { ...getCookieOptions(config), maxAge: 60 * 60 * 24 * 30 };\r\n const clear = { ...getCookieOptions(config), maxAge: 0 };\r\n\r\n if (count === 0) return;\r\n\r\n const remaining: StoredAccount[] = [];\r\n for (let i = 1; i <= count; i++) {\r\n if (i === activeIdx) continue;\r\n const slot = readSlot(cookieStore, i);\r\n if (slot) remaining.push(slot);\r\n }\r\n\r\n for (let i = 1; i <= count; i++) {\r\n clearSlot(cookieStore, i);\r\n }\r\n\r\n if (remaining.length === 0) {\r\n cookieStore.set(cn.count, \"0\", clear);\r\n cookieStore.set(cn.active, \"0\", clear);\r\n await clearPublicTokenCookie(cookieStore);\r\n return;\r\n }\r\n\r\n // Re-compact remaining accounts into slots 1..n\r\n for (let i = 0; i < remaining.length; i++) {\r\n await writeSlot(cookieStore, i + 1, remaining[i]!);\r\n }\r\n cookieStore.set(cn.count, String(remaining.length), opts);\r\n cookieStore.set(cn.active, \"1\", opts);\r\n clearPublicTokenCookie(cookieStore);\r\n }\r\n\r\n async function clearAllSessionCookies() {\r\n const [cookieStore, count] = await Promise.all([cookies(), getCount()]);\r\n const clear = { ...getCookieOptions(config), maxAge: 0 };\r\n for (let i = 1; i <= count; i++) {\r\n clearSlot(cookieStore, i);\r\n }\r\n cookieStore.set(cn.count, \"0\", clear);\r\n cookieStore.set(cn.active, \"0\", clear);\r\n clearPublicTokenCookie(cookieStore);\r\n }\r\n\r\n async function tryRefreshTokens(): Promise<string | null> {\r\n const refreshToken = await getRefreshToken();\r\n if (!refreshToken || !config.clientId) return null;\r\n\r\n const basicAuth = Buffer.from(`${config.clientId}:${config.clientSecret ?? \"\"}`).toString(\"base64\");\r\n\r\n const response = await fetch(createUrl(config.issuer, \"/auth/token\"), {\r\n method: \"POST\",\r\n headers: {\r\n \"content-type\": \"application/x-www-form-urlencoded\",\r\n authorization: `Basic ${basicAuth}`,\r\n },\r\n body: new URLSearchParams({\r\n grant_type: \"refresh_token\",\r\n refresh_token: refreshToken,\r\n }),\r\n cache: \"no-store\",\r\n });\r\n\r\n if (!response.ok) return null;\r\n\r\n const data = (await response.json().catch(() => null)) as TokenLikeResponse | null;\r\n if (!data) return null;\r\n\r\n const tokens = tokenFromResponse(data);\r\n if (!tokens.accessToken) return null;\r\n\r\n await saveAccountToList(tokens);\r\n return tokens.accessToken;\r\n }\r\n\r\n async function apiRequest<T>(path: string, init: RequestInit = {}): Promise<T> {\r\n const accessToken = await getAccessToken();\r\n const requestHeaders = new Headers(init.headers);\r\n\r\n if (accessToken) requestHeaders.set(\"authorization\", `Bearer ${accessToken}`);\r\n if (init.body !== undefined && !requestHeaders.has(\"content-type\")) {\r\n requestHeaders.set(\"content-type\", \"application/json\");\r\n }\r\n\r\n const url = new URL(createUrl(config.issuer, path));\r\n applyClientCredentials(url, config);\r\n\r\n const response = await fetch(url, {\r\n ...init,\r\n headers: requestHeaders,\r\n cache: \"no-store\",\r\n });\r\n\r\n if (response.status === 401) {\r\n const newAccessToken = await tryRefreshTokens();\r\n\r\n if (!newAccessToken) {\r\n // Sem renovar depois de um 401, a sessão acabou: expirou, foi revogada\r\n // no painel ou o refresh token caiu. Os cookies apontam para algo que\r\n // não existe mais, então saem — senão toda requisição seguinte repete\r\n // o mesmo ciclo. Quem chamou recebe um erro que dá para tratar, em vez\r\n // de \"Auth API error 401\" vindo do fundo da pilha.\r\n await clearAllSessionCookies().catch(() => {});\r\n throw new GeekappsSessionExpiredError();\r\n }\r\n\r\n if (newAccessToken) {\r\n const retryHeaders = new Headers(init.headers);\r\n retryHeaders.set(\"authorization\", `Bearer ${newAccessToken}`);\r\n if (init.body !== undefined && !retryHeaders.has(\"content-type\")) {\r\n retryHeaders.set(\"content-type\", \"application/json\");\r\n }\r\n const retryUrl = new URL(createUrl(config.issuer, path));\r\n applyClientCredentials(retryUrl, config);\r\n const retryResponse = await fetch(retryUrl, {\r\n ...init,\r\n headers: retryHeaders,\r\n cache: \"no-store\",\r\n });\r\n if (!retryResponse.ok) {\r\n const error = await retryResponse.json().catch(() => null);\r\n throw new Error(error?.error_description ?? error?.error ?? error?.message ?? `Auth API error ${retryResponse.status}`);\r\n }\r\n if (retryResponse.status === 204) return null as T;\r\n return (await retryResponse.json()) as T;\r\n }\r\n }\r\n\r\n if (!response.ok) {\r\n const error = await response.json().catch(() => null);\r\n throw new Error(error?.error_description ?? error?.error ?? error?.message ?? `Auth API error ${response.status}`);\r\n }\r\n\r\n if (response.status === 204) return null as T;\r\n return (await response.json()) as T;\r\n }\r\n\r\n function createApiClient(): ServerAuthApiClient {\r\n return {\r\n get: (path) => apiRequest(path),\r\n post: (path, body) => apiRequest(path, { method: \"POST\", body: body === undefined ? undefined : JSON.stringify(body) }),\r\n patch: (path, body) => apiRequest(path, { method: \"PATCH\", body: body === undefined ? undefined : JSON.stringify(body) }),\r\n put: (path, body) => apiRequest(path, { method: \"PUT\", body: body === undefined ? undefined : JSON.stringify(body) }),\r\n delete: (path) => apiRequest(path, { method: \"DELETE\" }),\r\n };\r\n }\r\n\r\n async function requireUser(options: { redirectTo?: string } = {}) {\r\n const accessToken = await getAccessToken();\r\n if (!accessToken) {\r\n redirect(options.redirectTo ?? \"/\");\r\n }\r\n return accessToken;\r\n }\r\n\r\n function escapeHtml(str: string): string {\r\n return str.replace(/&/g, \"&\").replace(/</g, \"<\").replace(/>/g, \">\").replace(/\"/g, \""\");\r\n }\r\n\r\n function sanitizeRedirectTo(value: string | null | undefined): string {\r\n const raw = value ?? \"/\";\r\n // Never redirect back into the auth basePath — that would loop or replay a stale code.\r\n if (raw.startsWith(config.basePath)) return \"/\";\r\n // Only allow relative paths to prevent open-redirect.\r\n if (raw.startsWith(\"/\") && !raw.startsWith(\"//\")) return raw;\r\n return \"/\";\r\n }\r\n\r\n async function createAuthorizeRedirect(request: NextRequest, mode: \"login\" | \"register\" | \"add-account\" | \"google\") {\r\n const redirectTo = sanitizeRedirectTo(request.nextUrl.searchParams.get(\"redirectTo\") ?? config.afterSignInRedirectTo);\r\n const flowId = request.nextUrl.searchParams.get(\"flow_id\");\r\n const url = new URL(createUrl(config.issuer, mode === \"google\" ? \"/auth/google\" : \"/authorize\"));\r\n\r\n if (!config.clientId) {\r\n return NextResponse.json(\r\n { error: \"missing_client_id\", message: \"Missing GEEKAPPS_AUTH_CLIENT_ID or clientId.\" },\r\n { status: 500 },\r\n );\r\n }\r\n\r\n url.searchParams.set(\"client_id\", config.clientId);\r\n url.searchParams.set(\"redirect_uri\", resolveRedirectUri(config, request));\r\n url.searchParams.set(\"response_type\", \"code\");\r\n const scopes = config.scopes ?? [\"openid\", \"profile\", \"email\"];\r\n if (!scopes.includes(\"offline_access\")) scopes.push(\"offline_access\");\r\n url.searchParams.set(\"scope\", scopes.join(\" \"));\r\n url.searchParams.set(\"state\", Buffer.from(JSON.stringify({ redirectTo, mode })).toString(\"base64url\"));\r\n if (mode === \"register\") url.searchParams.set(\"screen_hint\", \"signup\");\r\n if (mode === \"add-account\") {\r\n // Adding an account always needs a fresh login, regardless of any existing session.\r\n url.searchParams.set(\"prompt\", \"login\");\r\n } else {\r\n // `prompt` (login | select_account | none | consent) is forwarded as-is to\r\n // the IDP — see OidcPrompt in client.tsx for what each value does.\r\n const prompt = request.nextUrl.searchParams.get(\"prompt\");\r\n // Legacy fallback: silentLogin=false is equivalent to prompt=login.\r\n const silentLogin = request.nextUrl.searchParams.get(\"silentLogin\");\r\n const resolvedPrompt = prompt ?? (silentLogin === \"false\" ? \"login\" : null);\r\n if (resolvedPrompt) url.searchParams.set(\"prompt\", resolvedPrompt);\r\n }\r\n if (flowId) url.searchParams.set(\"flow_id\", flowId);\r\n\r\n const codeVerifier = generateCodeVerifier();\r\n url.searchParams.set(\"code_challenge\", createCodeChallenge(codeVerifier));\r\n url.searchParams.set(\"code_challenge_method\", \"S256\");\r\n\r\n const response = NextResponse.redirect(url);\r\n const cn = getCookieNames(config.cookiePrefix);\r\n response.cookies.set(cn.pkceVerifier, codeVerifier, {\r\n ...getCookieOptions(config),\r\n maxAge: 60 * 10,\r\n });\r\n return response;\r\n }\r\n\r\n async function exchangeCode(request: NextRequest) {\r\n const code = request.nextUrl.searchParams.get(\"code\");\r\n const state = request.nextUrl.searchParams.get(\"state\");\r\n\r\n const origin = requestOrigin(request);\r\n\r\n if (!code) {\r\n const err = request.nextUrl.searchParams.get(\"error\") ?? \"missing_code\";\r\n const desc = request.nextUrl.searchParams.get(\"error_description\") ?? request.nextUrl.searchParams.get(\"error_message\") ?? \"No authorization code received\";\r\n return NextResponse.redirect(new URL(`${config.basePath}/callback?error=${encodeURIComponent(err)}&error_description=${encodeURIComponent(desc)}`, origin));\r\n }\r\n\r\n if (!config.clientId) {\r\n return NextResponse.redirect(new URL(`${config.basePath}/callback?error=missing_client_id&error_description=Auth+client+is+not+configured`, origin));\r\n }\r\n\r\n const basicAuth = Buffer.from(\r\n `${config.clientId}:${config.clientSecret ?? \"\"}`,\r\n ).toString(\"base64\");\r\n\r\n const cn = getCookieNames(config.cookiePrefix);\r\n const cookieStore = await cookies();\r\n const codeVerifier = cookieStore.get(cn.pkceVerifier)?.value;\r\n cookieStore.set(cn.pkceVerifier, \"\", { ...getCookieOptions(config), maxAge: 0 });\r\n\r\n const tokenBody = new URLSearchParams({\r\n grant_type: \"authorization_code\",\r\n code,\r\n redirect_uri: resolveRedirectUri(config, request),\r\n });\r\n if (codeVerifier) tokenBody.set(\"code_verifier\", codeVerifier);\r\n\r\n const response = await fetch(createUrl(config.issuer, \"/auth/token\"), {\r\n method: \"POST\",\r\n headers: {\r\n \"content-type\": \"application/x-www-form-urlencoded\",\r\n authorization: `Basic ${basicAuth}`,\r\n },\r\n body: tokenBody,\r\n cache: \"no-store\",\r\n });\r\n\r\n const data = (await response.json().catch(() => ({}))) as TokenLikeResponse & { error?: string; error_description?: string };\r\n if (!response.ok) {\r\n const error = encodeURIComponent((data.error as string | undefined) ?? \"token_exchange_failed\");\r\n const desc = encodeURIComponent((data.error_description as string | undefined) ?? `Token exchange failed with status ${response.status}`);\r\n return NextResponse.redirect(new URL(`${config.basePath}/callback?error=${error}&error_description=${desc}`, origin));\r\n }\r\n\r\n await saveAccountToList(tokenFromResponse(data));\r\n\r\n let redirectTo = sanitizeRedirectTo(config.afterSignInRedirectTo);\r\n if (state) {\r\n try {\r\n const parsed = JSON.parse(Buffer.from(state, \"base64url\").toString(\"utf8\")) as { redirectTo?: string };\r\n redirectTo = sanitizeRedirectTo(parsed.redirectTo);\r\n } catch {\r\n // ignore malformed state\r\n }\r\n }\r\n\r\n return NextResponse.redirect(new URL(redirectTo, origin));\r\n }\r\n\r\n function mapLocalPathToApiPath(route: string) {\r\n if (route === \"/me\") return \"/auth/me\";\r\n if (route === \"/accounts/switch\") return \"/auth/accounts/switch\";\r\n if (route === \"/organizations/active\") return \"/auth/organizations/active\";\r\n if (route === \"/permissions/check\") return \"/auth/permissions/check\";\r\n if (route === \"/login/password\") return \"/auth/login/password\";\r\n if (route === \"/register/password\") return \"/auth/register/password\";\r\n if (route === \"/passwordless/link\") return \"/auth/passwordless/link\";\r\n return route;\r\n }\r\n\r\n async function handleAccountsLocal(): Promise<NextResponse> {\r\n const [accounts, activeIdx] = await Promise.all([getAccountsList(), getActiveIndex()]);\r\n\r\n return NextResponse.json({\r\n accounts: accounts.map((a, i) => ({\r\n id: a.id,\r\n sessionId: a.sessionId ?? a.id,\r\n isActive: i + 1 === activeIdx,\r\n user: {\r\n id: a.user.sub,\r\n name: a.user.name ?? null,\r\n email: a.user.email ?? null,\r\n imageUrl: a.user.picture ?? null,\r\n phone: a.user.phone ?? null,\r\n },\r\n })),\r\n });\r\n }\r\n\r\n async function handleOrganizationsLocal(): Promise<NextResponse> {\r\n const cookieStore = await cookies();\r\n const activeOrgId = cookieStore.get(cn.activeOrganizationId)?.value ?? null;\r\n try {\r\n const data = await apiRequest<unknown[]|{ organizations: unknown[] }>(\"/organizations\");\r\n const organizations = Array.isArray(data) ? data : (data as { organizations: unknown[] }).organizations;\r\n return NextResponse.json({ organizations, currentOrgId: activeOrgId });\r\n } catch {\r\n return NextResponse.json({ organizations: [], currentOrgId: activeOrgId });\r\n }\r\n }\r\n\r\n async function handleSwitchAccountLocal(request: NextRequest): Promise<NextResponse> {\r\n const body = await readBody(request);\r\n const targetId =\r\n body && typeof body === \"object\"\r\n ? String(\r\n (body as Record<string, unknown>).accountId ??\r\n (body as Record<string, unknown>).sessionId ??\r\n \"\",\r\n )\r\n : \"\";\r\n\r\n if (!targetId) {\r\n return NextResponse.json({ error: \"accountId required\" }, { status: 400 });\r\n }\r\n\r\n const switched = await switchAccount(targetId);\r\n if (!switched) {\r\n return NextResponse.json({ error: \"Account not found\" }, { status: 404 });\r\n }\r\n\r\n return NextResponse.json({ ok: true });\r\n }\r\n\r\n async function proxy(request: NextRequest, route: string) {\r\n const accessToken = await getAccessToken();\r\n const body = [\"GET\", \"HEAD\"].includes(request.method) ? undefined : await readBody(request);\r\n const url = new URL(appendSearchParams(createUrl(config.issuer, mapLocalPathToApiPath(route)), request));\r\n applyClientCredentials(url, config);\r\n const requestHeaders = new Headers();\r\n\r\n if (accessToken) requestHeaders.set(\"authorization\", `Bearer ${accessToken}`);\r\n if (body !== undefined) requestHeaders.set(\"content-type\", \"application/json\");\r\n\r\n let response = await fetch(url, {\r\n method: request.method,\r\n headers: requestHeaders,\r\n body: body === undefined ? undefined : typeof body === \"string\" ? body : JSON.stringify(body),\r\n cache: \"no-store\",\r\n });\r\n\r\n if (response.status === 401) {\r\n // Mesmo tratamento do apiRequest: tenta renovar uma vez e, se a sessão\r\n // já não existe (expirada ou revogada no painel), limpa os cookies. Sem\r\n // isso o cliente repetiria o mesmo token morto a cada chamada.\r\n const refreshed = await tryRefreshTokens();\r\n\r\n if (refreshed) {\r\n requestHeaders.set(\"authorization\", `Bearer ${refreshed}`);\r\n response = await fetch(url, {\r\n method: request.method,\r\n headers: requestHeaders,\r\n body:\r\n body === undefined\r\n ? undefined\r\n : typeof body === \"string\"\r\n ? body\r\n : JSON.stringify(body),\r\n cache: \"no-store\",\r\n });\r\n } else {\r\n await clearAllSessionCookies().catch(() => {});\r\n return NextResponse.json(\r\n { error: \"session_expired\" },\r\n { status: 401 },\r\n );\r\n }\r\n }\r\n\r\n const data = isJsonResponse(response) ? ((await response.json().catch(() => null)) as TokenLikeResponse | null) : null;\r\n if (data) {\r\n const tokens = tokenFromResponse(data);\r\n if (tokens.accessToken || tokens.refreshToken || tokens.idToken || tokens.sessionId) {\r\n await saveAccountToList(tokens);\r\n }\r\n return NextResponse.json(data, { status: response.status });\r\n }\r\n\r\n return responseFromApi(response);\r\n }\r\n\r\n async function handle(request: NextRequest) {\r\n const route = routeFromRequest(request, config.basePath);\r\n\r\n if (request.method === \"GET\" && route === \"/sign-in\") return createAuthorizeRedirect(request, \"login\");\r\n if (request.method === \"GET\" && route === \"/sign-up\") return createAuthorizeRedirect(request, \"register\");\r\n if (request.method === \"GET\" && route === \"/add-account\") return createAuthorizeRedirect(request, \"add-account\");\r\n if (request.method === \"GET\" && route === \"/login/google\") return createAuthorizeRedirect(request, \"google\");\r\n if (request.method === \"GET\" && route === \"/callback\") {\r\n const error = request.nextUrl.searchParams.get(\"error\");\r\n if (error) {\r\n const description = request.nextUrl.searchParams.get(\"error_description\") ?? request.nextUrl.searchParams.get(\"error_message\") ?? \"An authentication error occurred.\";\r\n return new NextResponse(\r\n `<!doctype html><html><head><meta charset=\"utf-8\"><title>Authentication Error</title></head><body style=\"font-family:sans-serif;max-width:480px;margin:80px auto;padding:0 16px\"><h2>Authentication Error</h2><p><strong>${escapeHtml(error)}</strong></p><p>${escapeHtml(description)}</p><a href=\"/\">Go back</a></body></html>`,\r\n { status: 400, headers: { \"content-type\": \"text/html; charset=utf-8\" } },\r\n );\r\n }\r\n return exchangeCode(request);\r\n }\r\n if (request.method === \"GET\" && route === \"/token\") {\r\n const accessToken = await getAccessToken();\r\n if (!accessToken) return NextResponse.json({ error: \"missing_token\" }, { status: 401 });\r\n return NextResponse.json({ accessToken });\r\n }\r\n\r\n\r\n if (request.method === \"GET\" && route === \"/accounts\") return handleAccountsLocal();\r\n if (request.method === \"POST\" && route === \"/accounts/switch\") return handleSwitchAccountLocal(request);\r\n if (request.method === \"GET\" && route === \"/organizations\") return handleOrganizationsLocal();\r\n\r\n if (request.method === \"POST\" && route === \"/organizations\") {\r\n const accessToken = await getAccessToken();\r\n const body = (await readBody(request)) as Record<string, unknown> | undefined;\r\n if (body && accessToken && !body.applicationId) {\r\n const claims = decodeJwtPayload(accessToken);\r\n const applicationId =\r\n (typeof claims?.application_id === \"string\" ? claims.application_id : null) ??\r\n (typeof claims?.client_id === \"string\" ? claims.client_id : null) ??\r\n (typeof claims?.applicationId === \"string\" ? claims.applicationId : null);\r\n if (applicationId) body.applicationId = applicationId;\r\n }\r\n const orgUrl = new URL(createUrl(config.issuer, \"/organizations\"));\r\n applyClientCredentials(orgUrl, config);\r\n const requestHeaders = new Headers();\r\n if (accessToken) requestHeaders.set(\"authorization\", `Bearer ${accessToken}`);\r\n if (body !== undefined) requestHeaders.set(\"content-type\", \"application/json\");\r\n const response = await fetch(orgUrl, {\r\n method: \"POST\",\r\n headers: requestHeaders,\r\n body: JSON.stringify(body),\r\n cache: \"no-store\",\r\n });\r\n return responseFromApi(response);\r\n }\r\n\r\n if (request.method === \"POST\" && route === \"/sign-out\") {\r\n await apiRequest(\"/auth/sign-out\", { method: \"POST\" }).catch(() => null);\r\n await removeActiveAccount();\r\n return NextResponse.json({ ok: true });\r\n }\r\n\r\n if (request.method === \"POST\" && route === \"/sign-out-all\") {\r\n await apiRequest(\"/auth/sign-out-all\", { method: \"POST\" }).catch(() => null);\r\n await clearAllSessionCookies();\r\n return NextResponse.json({ ok: true });\r\n }\r\n\r\n if (request.method === \"POST\" && route === \"/organizations/active\") {\r\n const body = await readBody(request);\r\n const organizationId =\r\n body && typeof body === \"object\" && \"organizationId\" in body\r\n ? (body as Record<string, unknown>).organizationId\r\n : null;\r\n const response = NextResponse.json({ ok: true });\r\n if (typeof organizationId === \"string\") {\r\n const cookieOpts = {\r\n httpOnly: true,\r\n sameSite: \"lax\" as const,\r\n path: \"/\",\r\n secure: process.env.NODE_ENV === \"production\",\r\n maxAge: 60 * 60 * 24 * 7,\r\n };\r\n response.cookies.set(cn.activeOrganizationId, organizationId, cookieOpts);\r\n }\r\n return response;\r\n }\r\n\r\n return proxy(request, route);\r\n }\r\n\r\n return {\r\n handlers: {\r\n GET: handle,\r\n POST: handle,\r\n PATCH: handle,\r\n DELETE: handle,\r\n },\r\n GetSession: getSession,\r\n GetUser: getUser,\r\n GetUserById: getUserById,\r\n GetOrganizations: getOrganizations,\r\n SaveSession: saveAccountToList,\r\n GetAccessToken: getAccessToken,\r\n GetRefreshToken: getRefreshToken,\r\n IsAuthenticated: async () => (await getAccessToken()) !== null,\r\n TryRefreshTokens: tryRefreshTokens,\r\n RequireUser: requireUser,\r\n CreateAuthApiClient: createApiClient,\r\n cookiePrefix: config.cookiePrefix,\r\n VerifyToken: (token: string) => verifyToken(token, config.issuer),\r\n SignInUrl: async (redirectTo?: string) => {\r\n const configured = env(\"GEEKAPPS_APP_URL\");\r\n if (!configured) throw new Error(\"Cannot determine app origin. Set GEEKAPPS_APP_URL.\");\r\n const origin = configured.replace(/\\/$/, \"\");\r\n const url = new URL(`${origin}${config.basePath}/sign-in`);\r\n if (redirectTo) url.searchParams.set(\"redirectTo\", redirectTo);\r\n return url.toString();\r\n },\r\n };\r\n}\r\n\r\nexport function handlers(options?: GeekappsAuthConfig) {\r\n return configureGeekappsAuth(options).handlers;\r\n}\r\n\r\n/**\r\n * Returns true if the current request has a valid access token in cookies.\r\n * Safe to call in Server Components, Server Actions, and Route Handlers.\r\n */\r\nexport async function isAuthenticated(options?: GeekappsAuthConfig): Promise<boolean> {\r\n const { GetAccessToken } = configureGeekappsAuth(options);\r\n return (await GetAccessToken()) !== null;\r\n}\r\n"],"mappings":"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","names":["NextResponse","createRemoteJWKSet","jwtVerify","createRemoteJWKSet","jwtVerify","NextResponse","response"]}
|
|
1
|
+
{"version":3,"sources":["../src/middleware.ts","../src/server.ts"],"sourcesContent":["import { NextResponse, type NextRequest } from \"next/server\";\nimport { createRemoteJWKSet, jwtVerify } from \"jose\";\nimport { GEEKAPPS_AUTH_SERVER_URL } from \"./server\";\n\nexport type AuthMiddlewareConfig = {\n issuer?: string;\n basePath?: string;\n /**\n * @deprecated cookiePrefix is now auto-derived from GEEKAPPS_AUTH_CLIENT_ID.\n * This field is kept for backwards compatibility but is ignored.\n */\n cookiePrefix?: string;\n secureCookies?: boolean;\n /** Base URL of this app (e.g. \"https://meuapp.com\"). Overrides the GEEKAPPS_APP_URL env var. */\n appUrl?: string;\n /**\n * When true, unauthenticated requests are redirected to sign-in.\n * Defaults to false — the app controls its own auth-gating.\n */\n forceLoginOnUnauthenticated?: boolean;\n};\n\ntype TokenLikeResponse = {\n access_token?: string;\n accessToken?: string;\n refresh_token?: string;\n refreshToken?: string;\n id_token?: string;\n idToken?: string;\n};\n\nfunction env(name: string) {\n const value = process.env[name];\n return value && value.trim().length > 0 ? value : undefined;\n}\n\nfunction normalizeBaseUrl(url: string) {\n return url.replace(/\\/$/, \"\");\n}\n\nfunction normalizeBasePath(path = \"/api/auth\") {\n if (!path.startsWith(\"/\")) return `/${path}`;\n return path.replace(/\\/$/, \"\");\n}\n\nfunction resolveIssuer(input: AuthMiddlewareConfig) {\n return normalizeBaseUrl(\n input.issuer ??\n // Deprecated: kept for backwards compatibility during the auth-server rename.\n env(\"GEEKAPPS_AUTH_ISSUER\") ??\n env(\"GEEKAPPS_AUTH_API_URL\") ??\n GEEKAPPS_AUTH_SERVER_URL,\n );\n}\n\nfunction hashClientId(clientId: string): string {\n let h = 5381;\n for (let i = 0; i < clientId.length; i++) {\n h = ((h << 5) + h) ^ (clientId.charCodeAt(i) & 0xff);\n h = h >>> 0;\n }\n return h.toString(16).padStart(8, \"0\");\n}\n\nfunction deriveCookiePrefix(clientId: string | undefined): string {\n if (!clientId) return \"gk\";\n return `gk_${hashClientId(clientId)}`;\n}\n\n\nconst MAX_REFRESH_ATTEMPTS = 3;\n\n// Várias requisições da mesma página chegam juntas com o access token vencido\n// (página, prefetch, server actions). Sem isto, cada uma renovava com o MESMO\n// refresh token — o auth-server via a segunda como reuso. Aqui elas dividem\n// uma única renovação por refresh token (por instância).\nconst inflightRefreshes = new Map<string, Promise<{ accessToken: string | null; refreshToken: string | null }>>();\n\nfunction decodeJwtExp(token: string): number | null {\n try {\n const parts = token.split(\".\");\n if (parts.length !== 3) return null;\n const payload = parts[1]!;\n const padded = payload + \"=\".repeat((4 - (payload.length % 4)) % 4);\n const decoded = JSON.parse(atob(padded)) as { exp?: number };\n return typeof decoded.exp === \"number\" ? decoded.exp : null;\n } catch {\n return null;\n }\n}\n\nfunction resolveAppOrigin(request: NextRequest, appUrl?: string): string {\n const configured =\n appUrl ??\n env(\"GEEKAPPS_APP_URL\");\n if (configured) return configured.replace(/\\/$/, \"\");\n\n throw new Error(\n \"Cannot determine app origin for redirect. Set GEEKAPPS_APP_URL.\",\n );\n}\n\nconst _jwksCache = new Map<string, ReturnType<typeof createRemoteJWKSet>>();\n\nfunction getRemoteJWKS(issuer: string) {\n if (!_jwksCache.has(issuer)) {\n _jwksCache.set(issuer, createRemoteJWKSet(new URL(`${issuer}/.well-known/jwks.json`)));\n }\n return _jwksCache.get(issuer)!;\n}\n\ntype VerifyResult = \"valid\" | \"invalid\" | \"network_error\";\n\nasync function verifyAccessToken(token: string, issuer: string, audience?: string): Promise<VerifyResult> {\n try {\n const { payload } = await jwtVerify(token, getRemoteJWKS(issuer), {\n algorithms: [\"RS256\"],\n issuer,\n // O cookie de sessão precisa ser desta app: token emitido para outra app\n // da Geekapps não abre esta.\n // O issuer também é aceito porque os tokens do painel do Auth saem com\n // aud = issuer (issueDashboardTokens).\n ...(audience ? { audience: [audience, issuer] } : {}),\n });\n // Sessão no navegador é sempre login do próprio usuário. Token de App\n // Connection num cookie é tentativa de uso indevido.\n if (payload.token_type === \"app_connection\") return \"invalid\";\n return \"valid\";\n } catch (err) {\n // TypeError is thrown by fetch when the JWKS endpoint is unreachable.\n if (err instanceof TypeError) return \"network_error\";\n // ERR_JWKS_* means the JWKS endpoint returned something unexpected\n // (e.g. no matching key yet after a key rotation, or a fetch-level error\n // wrapped by jose). Treat as transient so we don't destroy the session.\n const code = (err as { code?: string }).code ?? \"\";\n if (code.startsWith(\"ERR_JWKS\")) return \"network_error\";\n // Everything else (ERR_JWS_SIGNATURE_VERIFICATION_FAILED, ERR_JWT_EXPIRED,\n // ERR_JWT_CLAIM_VALIDATION_FAILED, …) means the token is genuinely invalid.\n return \"invalid\";\n }\n}\n\nexport function createAuthMiddleware(input: AuthMiddlewareConfig = {}) {\n const issuer = resolveIssuer(input);\n const basePath = normalizeBasePath(input.basePath);\n const clientId = env(\"GEEKAPPS_AUTH_CLIENT_ID\");\n const prefix = deriveCookiePrefix(clientId);\n\n const activeCookie = `${prefix}_active`;\n const countCookie = `${prefix}_count`;\n const tokenPubCookie = `${prefix}_tp`; // legacy — cleared on next refresh\n const slotAt = (n: number) => `${prefix}_at${n}`;\n const slotRt = (n: number) => `${prefix}_rt${n}`;\n\n // RSC/prefetch requests are cross-origin fetches — a 302 redirect triggers CORS.\n // Return a redirect response that Next.js understands instead.\n function redirectResponse(request: NextRequest, url: URL): NextResponse {\n const isRsc =\n request.headers.has(\"rsc\") ||\n request.headers.has(\"next-router-prefetch\") ||\n request.nextUrl.searchParams.has(\"_rsc\");\n if (isRsc) {\n return new NextResponse(null, {\n status: 302,\n headers: {\n location: url.toString(),\n \"x-action-redirect\": url.toString(),\n vary: \"RSC, Next-Router-State-Tree, Next-Router-Prefetch\",\n },\n }) as NextResponse;\n }\n return NextResponse.redirect(url);\n }\n\n return async function authMiddleware(request: NextRequest): Promise<NextResponse> {\n // Always allow the SDK's own routes through — never redirect /api/auth/* to sign-in.\n if (request.nextUrl.pathname.startsWith(basePath + \"/\") || request.nextUrl.pathname === basePath) {\n return NextResponse.next();\n }\n\n const countRaw = request.cookies.get(countCookie)?.value;\n const count = countRaw ? Number.parseInt(countRaw, 10) : 0;\n\n const activeRaw = request.cookies.get(activeCookie)?.value;\n let activeIdx = activeRaw ? Number.parseInt(activeRaw, 10) : 1;\n if (Number.isNaN(activeIdx) || activeIdx < 1 || activeIdx > count) {\n activeIdx = count > 0 ? 1 : 0;\n }\n\n // Fallback: if count cookie is missing/zero but slot 1 tokens exist, use slot 1.\n // This handles the case where the count/active control cookies expired before the token cookies.\n if (activeIdx === 0) {\n const fallbackAt = request.cookies.get(slotAt(1))?.value;\n const fallbackRt = request.cookies.get(slotRt(1))?.value;\n if (fallbackAt || fallbackRt) activeIdx = 1;\n }\n\n const accessToken = activeIdx > 0 ? (request.cookies.get(slotAt(activeIdx))?.value ?? null) : null;\n const refreshToken = activeIdx > 0 ? (request.cookies.get(slotRt(activeIdx))?.value ?? null) : null;\n\n const origin = resolveAppOrigin(request, input.appUrl);\n const signInUrl = new URL(`${origin}${basePath}/sign-in`);\n const redirectTo = request.nextUrl.pathname + request.nextUrl.search;\n if (redirectTo && redirectTo !== \"/\") {\n signInUrl.searchParams.set(\"redirectTo\", redirectTo);\n }\n\n if (!accessToken && !refreshToken) {\n if (input.forceLoginOnUnauthenticated) return redirectResponse(request, signInUrl);\n return NextResponse.next();\n }\n\n // Fast-path: check exp before hitting JWKS (avoids network on every request)\n const exp = accessToken ? decodeJwtExp(accessToken) : null;\n const nowSec = Math.floor(Date.now() / 1000);\n const expiredOrMissing = !exp || exp - nowSec < 60;\n\n if (!expiredOrMissing) {\n // Verify signature against app-api JWKS\n const verifyResult = await verifyAccessToken(accessToken!, issuer, clientId || undefined);\n if (verifyResult === \"valid\") return NextResponse.next();\n // If JWKS endpoint is unreachable, fail-open to avoid destroying the session.\n if (verifyResult === \"network_error\") return NextResponse.next();\n // \"invalid\" — fall through to refresh attempt below.\n }\n\n if (!refreshToken) {\n if (input.forceLoginOnUnauthenticated) return redirectResponse(request, signInUrl);\n return NextResponse.next();\n }\n\n const clientSecret = env(\"GEEKAPPS_AUTH_CLIENT_SECRET\") ?? \"\";\n const basicAuth = btoa(`${clientId ?? \"\"}:${clientSecret}`);\n\n const runRefresh = async () => {\n let newAccessToken: string | null = null;\n let newRefreshToken: string | null = null;\n\n for (let attempt = 1; attempt <= MAX_REFRESH_ATTEMPTS; attempt++) {\n try {\n const refreshResponse = await fetch(`${issuer}/auth/token`, {\n method: \"POST\",\n headers: {\n \"content-type\": \"application/x-www-form-urlencoded\",\n authorization: `Basic ${basicAuth}`,\n },\n body: new URLSearchParams({\n grant_type: \"refresh_token\",\n refresh_token: refreshToken,\n }),\n cache: \"no-store\",\n });\n\n if (!refreshResponse.ok) break;\n\n const data = (await refreshResponse.json()) as TokenLikeResponse;\n newAccessToken = data.accessToken ?? data.access_token ?? null;\n newRefreshToken = data.refreshToken ?? data.refresh_token ?? null;\n if (newAccessToken) break;\n } catch {\n if (attempt === MAX_REFRESH_ATTEMPTS) break;\n }\n }\n return { accessToken: newAccessToken, refreshToken: newRefreshToken };\n };\n\n let pending = inflightRefreshes.get(refreshToken);\n if (!pending) {\n pending = runRefresh().finally(() => {\n // Mantém o resultado por alguns segundos para as requisições que\n // chegam logo depois, ainda com o cookie antigo.\n setTimeout(() => inflightRefreshes.delete(refreshToken), 10_000);\n });\n inflightRefreshes.set(refreshToken, pending);\n }\n const { accessToken: newAccessToken, refreshToken: newRefreshToken } = await pending;\n\n if (!newAccessToken) {\n // Refresh failed — clear the active slot and optionally redirect to sign-in\n const response = input.forceLoginOnUnauthenticated\n ? redirectResponse(request, signInUrl)\n : NextResponse.next();\n const clearOpts = {\n httpOnly: true,\n secure: input.secureCookies ?? process.env.NODE_ENV === \"production\",\n sameSite: \"lax\" as const,\n path: \"/\",\n maxAge: 0,\n };\n response.cookies.set(slotAt(activeIdx), \"\", clearOpts);\n response.cookies.set(slotRt(activeIdx), \"\", clearOpts);\n response.cookies.set(`${prefix}_m${activeIdx}`, \"\", clearOpts);\n response.cookies.set(tokenPubCookie, \"\", { ...clearOpts, httpOnly: false });\n response.cookies.set(activeCookie, \"0\", clearOpts);\n return response;\n }\n\n const cookieOptions = {\n httpOnly: true,\n secure: input.secureCookies ?? process.env.NODE_ENV === \"production\",\n sameSite: \"lax\" as const,\n path: \"/\",\n maxAge: 60 * 60 * 24 * 30,\n };\n\n // Forward new token via request header so server actions in the same\n // request can read it — response cookies are not visible within the same request.\n const requestHeaders = new Headers(request.headers);\n requestHeaders.set(\"authorization\", `Bearer ${newAccessToken}`);\n\n const response = NextResponse.next({ request: { headers: requestHeaders } });\n response.cookies.set(slotAt(activeIdx), newAccessToken, cookieOptions);\n if (newRefreshToken) response.cookies.set(slotRt(activeIdx), newRefreshToken, cookieOptions);\n // Clear legacy _tp cookie to reduce header size\n if (request.cookies.get(tokenPubCookie)?.value) {\n response.cookies.set(tokenPubCookie, \"\", { httpOnly: false, maxAge: 0, path: \"/\" });\n }\n\n return response;\n };\n}\n","import { createHash, randomBytes } from \"node:crypto\";\nimport { cookies, headers } from \"next/headers\";\nimport { redirect } from \"next/navigation\";\nimport { NextResponse, type NextRequest } from \"next/server\";\nimport { createRemoteJWKSet, jwtVerify, type JWTPayload } from \"jose\";\n\n/**\n * A sessão não vale mais: expirou, foi revogada no painel ou o refresh token\n * foi invalidado. Os cookies já foram limpos quando este erro é lançado, então\n * o tratamento normal é redirecionar para o login.\n */\nexport class GeekappsSessionExpiredError extends Error {\n readonly code = \"session_expired\" as const;\n\n constructor(message = \"A sessão expirou ou foi revogada. Entre novamente.\") {\n super(message);\n this.name = \"GeekappsSessionExpiredError\";\n }\n}\n\n/**\n * Fixed URL of the Geekapps Auth Server (OIDC issuer + API). This never\n * changes per-environment, so it is hardcoded rather than read from env.\n */\nexport const GEEKAPPS_AUTH_SERVER_URL = \"https://accounts-api.geekapps.com.br\";\n\nexport type GeekappsAuthConfig = {\n /**\n * Auth Server (OIDC) issuer URL. Defaults to GEEKAPPS_AUTH_SERVER_URL and\n * should not need overriding in normal use.\n *\n * Deprecated (still supported as a fallback): GEEKAPPS_AUTH_SERVER_ISSUER,\n * GEEKAPPS_AUTH_ISSUER, GEEKAPPS_AUTH_SERVER_URL env, GEEKAPPS_AUTH_API_URL\n */\n issuer?: string;\n\n /**\n * OAuth client id. Optional when GEEKAPPS_AUTH_CLIENT_ID is defined.\n */\n clientId?: string;\n\n /**\n * OAuth client secret for confidential clients. Optional when\n * GEEKAPPS_AUTH_CLIENT_SECRET is defined.\n */\n clientSecret?: string;\n\n /**\n * Callback URL. Optional when GEEKAPPS_AUTH_REDIRECT_URI is defined.\n * If omitted, it is inferred from the current request origin + basePath/callback.\n */\n redirectUri?: string;\n\n scopes?: string[];\n basePath?: string;\n /**\n * @deprecated cookiePrefix is now auto-derived from clientId.\n * This field is kept for backwards compatibility but is ignored.\n */\n cookiePrefix?: string;\n secureCookies?: boolean;\n\n /**\n * Friendly alias used by apps. Same as afterSignInRedirectTo.\n */\n redirectTo?: string;\n\n afterSignInRedirectTo?: string;\n afterSignOutRedirectTo?: string;\n};\n\ntype ResolvedGeekappsAuthConfig = GeekappsAuthConfig & {\n issuer: string;\n clientId: string;\n basePath: string;\n cookiePrefix: string;\n};\n\nexport type SessionTokens = {\n accessToken: string | null;\n refreshToken: string | null;\n idToken: string | null;\n sessionId: string | null;\n};\n\nexport type StoredAccount = {\n /** Stable identifier — `sub` from id_token or fallback to sessionId. */\n id: string;\n accessToken: string;\n refreshToken: string | null;\n idToken: string | null;\n sessionId: string | null;\n user: {\n sub: string;\n name?: string | null;\n email?: string | null;\n picture?: string | null;\n phone?: string | null;\n };\n};\n\nexport type ServerAuthApiClient = {\n get: <T>(path: string) => Promise<T>;\n post: <T>(path: string, body?: unknown) => Promise<T>;\n patch: <T>(path: string, body?: unknown) => Promise<T>;\n put: <T>(path: string, body?: unknown) => Promise<T>;\n delete: <T>(path: string) => Promise<T>;\n};\n\ntype JsonObject = Record<string, unknown>;\n\ntype TokenLikeResponse = JsonObject & {\n access_token?: string;\n accessToken?: string;\n refresh_token?: string;\n refreshToken?: string;\n id_token?: string;\n idToken?: string;\n session_id?: string;\n sessionId?: string;\n tokens?: {\n access_token?: string;\n accessToken?: string;\n refresh_token?: string;\n refreshToken?: string;\n id_token?: string;\n idToken?: string;\n session_id?: string;\n sessionId?: string;\n };\n};\n\nconst MAX_ACCOUNTS = 8;\n\nfunction normalizeBaseUrl(url: string) {\n return url.replace(/\\/$/, \"\");\n}\n\nfunction normalizeBasePath(path = \"/api/auth\") {\n if (!path.startsWith(\"/\")) return `/${path}`;\n return path.replace(/\\/$/, \"\");\n}\n\nfunction createUrl(baseUrl: string, path: string) {\n if (/^https?:\\/\\//.test(path)) return path;\n const normalizedPath = path.startsWith(\"/\") ? path : `/${path}`;\n return `${normalizeBaseUrl(baseUrl)}${normalizedPath}`;\n}\n\nfunction env(name: string) {\n const value = process.env[name];\n return value && value.trim().length > 0 ? value : undefined;\n}\n\nfunction resolveIssuer(input: GeekappsAuthConfig) {\n return normalizeBaseUrl(\n input.issuer ??\n env(\"GEEKAPPS_AUTH_SERVER_ISSUER\") ??\n env(\"GEEKAPPS_AUTH_SERVER_URL\") ??\n // Deprecated: kept for backwards compatibility during the auth-server rename.\n env(\"GEEKAPPS_AUTH_ISSUER\") ??\n env(\"GEEKAPPS_AUTH_API_URL\") ??\n GEEKAPPS_AUTH_SERVER_URL,\n );\n}\n\nfunction resolveClientId(input: GeekappsAuthConfig) {\n return input.clientId ?? env(\"GEEKAPPS_AUTH_CLIENT_ID\") ?? \"\";\n}\n\nfunction resolveClientSecret(input: GeekappsAuthConfig) {\n return input.clientSecret ?? env(\"GEEKAPPS_AUTH_CLIENT_SECRET\");\n}\n\n\nfunction resolveConfiguredRedirectUri(input: GeekappsAuthConfig) {\n return input.redirectUri ?? env(\"GEEKAPPS_AUTH_REDIRECT_URI\");\n}\n\n/**\n * Derives a short stable 8-char hex hash from a clientId string.\n * Uses a simple djb2-style hash — no crypto dependency needed.\n */\nfunction hashClientId(clientId: string): string {\n let h = 5381;\n for (let i = 0; i < clientId.length; i++) {\n h = ((h << 5) + h) ^ (clientId.charCodeAt(i) & 0xff);\n h = h >>> 0; // keep unsigned 32-bit\n }\n return h.toString(16).padStart(8, \"0\");\n}\n\n/**\n * Auto-derives the cookie prefix from clientId.\n * Format: `gk_{8-char-hash}` — guaranteed unique per OAuth client.\n * Falls back to `gk` when clientId is empty (dev/test environments).\n */\nfunction deriveCookiePrefix(clientId: string): string {\n if (!clientId) return \"gk\";\n return `gk_${hashClientId(clientId)}`;\n}\n\n// GEEKAPPS_APP_URL é o override de produção: em prod o app roda atrás de\n// Docker sem proxy setando x-forwarded-host/x-forwarded-proto, então nem o\n// header Host nem request.nextUrl.origin são confiáveis (refletem o host\n// INTERNO do container, não o domínio público real) — por isso, quando\n// configurada, essa env var sempre vence. Sem ela (dev local, onde não está\n// setada no .env), deriva do próprio request — em localhost sem proxy o\n// Host já é localhost:PORTA mesmo, então funciona sem precisar de override.\nfunction requestOrigin(request: NextRequest) {\n const configured = env(\"GEEKAPPS_APP_URL\");\n if (configured) return configured.replace(/\\/$/, \"\");\n\n if (request.nextUrl?.origin) return request.nextUrl.origin;\n\n throw new Error(\n \"Cannot determine app origin. Set GEEKAPPS_APP_URL or ensure request.nextUrl is available.\",\n );\n}\n\nfunction resolveRedirectUri(config: ResolvedGeekappsAuthConfig, request: NextRequest) {\n return config.redirectUri ?? `${requestOrigin(request)}${config.basePath}/callback`;\n}\n\nfunction tokenFromResponse(data: TokenLikeResponse): SessionTokens {\n return {\n accessToken: data.accessToken ?? data.access_token ?? data.tokens?.accessToken ?? data.tokens?.access_token ?? null,\n refreshToken:\n data.refreshToken ?? data.refresh_token ?? data.tokens?.refreshToken ?? data.tokens?.refresh_token ?? null,\n idToken: data.idToken ?? data.id_token ?? data.tokens?.idToken ?? data.tokens?.id_token ?? null,\n sessionId: data.sessionId ?? data.session_id ?? data.tokens?.sessionId ?? data.tokens?.session_id ?? null,\n };\n}\n\n/** Decode a JWT payload without verifying signature (safe for client-visible claims). */\nfunction decodeJwtPayload(token: string): Record<string, unknown> | null {\n try {\n const parts = token.split(\".\");\n if (parts.length < 2) return null;\n const payload = parts[1];\n if (!payload) return null;\n return JSON.parse(Buffer.from(payload, \"base64url\").toString(\"utf8\")) as Record<string, unknown>;\n } catch {\n return null;\n }\n}\n\nfunction parseAccountFromJwt(tokens: SessionTokens): StoredAccount | null {\n if (!tokens.accessToken) return null;\n\n const jwtToParse = tokens.idToken ?? tokens.accessToken;\n const payload = decodeJwtPayload(jwtToParse);\n if (!payload) return null;\n\n const sub = typeof payload.sub === \"string\" ? payload.sub : null;\n const id = sub ?? tokens.sessionId;\n if (!id) return null;\n\n return {\n id,\n accessToken: tokens.accessToken,\n refreshToken: tokens.refreshToken,\n idToken: tokens.idToken ?? null,\n sessionId: tokens.sessionId,\n user: {\n sub: id,\n name: typeof payload.name === \"string\" ? payload.name : null,\n email: typeof payload.email === \"string\" ? payload.email : null,\n picture: typeof payload.picture === \"string\" ? payload.picture : null,\n phone: typeof payload.phone_number === \"string\" ? payload.phone_number : null,\n },\n };\n}\n\n/**\n * Cookie name scheme — 3 cookies per slot, each well under 4096 bytes:\n *\n * {prefix}_m{n} — metadata blob (base64url JSON): { id, sid?, n?, e?, p? }\n * Claims extracted from id_token at login. Never stores tokens.\n * {prefix}_at{n} — access token (httpOnly). ~1-2 KB.\n * {prefix}_rt{n} — refresh token (httpOnly). ~1 KB.\n * {prefix}_active — active slot index (\"1\", \"2\", …)\n * {prefix}_count — total stored slots\n * {prefix}_ao — active org id for this app\n * {prefix}_tp — non-httpOnly copy of active access token for client-side axios\n *\n * id_token is NOT stored in cookies — claims are extracted at login and cached in\n * the metadata cookie. This is the key size saving.\n */\nfunction getCookieNames(prefix: string) {\n return {\n active: `${prefix}_active`,\n count: `${prefix}_count`,\n activeOrganizationId: `${prefix}_ao`,\n tokenPub: `${prefix}_tp`,\n pkceVerifier: `${prefix}_pkce`,\n slotMeta: (n: number) => `${prefix}_m${n}`,\n slotAt: (n: number) => `${prefix}_at${n}`,\n slotRt: (n: number) => `${prefix}_rt${n}`,\n };\n}\n\nfunction generateCodeVerifier(): string {\n return randomBytes(64).toString(\"base64url\");\n}\n\nfunction createCodeChallenge(codeVerifier: string): string {\n return createHash(\"sha256\").update(codeVerifier).digest(\"base64url\");\n}\n\ntype SlotMeta = {\n id: string;\n sid?: string;\n n?: string;\n e?: string;\n p?: string;\n};\n\nfunction encodeMeta(account: StoredAccount): string {\n const m: SlotMeta = { id: account.id };\n if (account.sessionId) m.sid = account.sessionId;\n if (account.user.name) m.n = account.user.name;\n if (account.user.email) m.e = account.user.email;\n if (account.user.picture) m.p = account.user.picture;\n return Buffer.from(JSON.stringify(m), \"utf8\").toString(\"base64url\");\n}\n\nfunction decodeMeta(value: string): SlotMeta | null {\n try {\n const m = JSON.parse(Buffer.from(value, \"base64url\").toString(\"utf8\")) as SlotMeta;\n return m.id ? m : null;\n } catch {\n return null;\n }\n}\n\nfunction getCookieOptions(config: ResolvedGeekappsAuthConfig) {\n return {\n httpOnly: true,\n secure: config.secureCookies ?? process.env.NODE_ENV === \"production\",\n sameSite: \"lax\" as const,\n path: \"/\",\n };\n}\n\nasync function readBody(request: NextRequest) {\n const text = await request.text();\n if (!text) return undefined;\n try {\n return JSON.parse(text);\n } catch {\n return text;\n }\n}\n\nfunction appendSearchParams(url: string, request: NextRequest) {\n const target = new URL(url);\n request.nextUrl.searchParams.forEach((value, key) => {\n target.searchParams.set(key, value);\n });\n return target.toString();\n}\n\nfunction routeFromRequest(request: NextRequest, basePath: string) {\n const pathname = request.nextUrl.pathname;\n if (!pathname.startsWith(basePath)) return pathname;\n const route = pathname.slice(basePath.length);\n return route || \"/\";\n}\n\nfunction isJsonResponse(response: Response) {\n return response.headers.get(\"content-type\")?.includes(\"application/json\");\n}\n\nasync function responseFromApi(response: Response) {\n if (response.status === 204) return new NextResponse(null, { status: 204 });\n const body = isJsonResponse(response) ? await response.json().catch(() => null) : await response.text();\n return NextResponse.json(body, { status: response.status });\n}\n\nconst _remoteJWKSCache = new Map<string, ReturnType<typeof createRemoteJWKSet>>();\n\ntype ServiceTokenCache = { token: string; expiresAt: number };\nconst _serviceTokenCache = new Map<string, ServiceTokenCache>();\n\nasync function fetchServiceToken(issuer: string, clientId: string, clientSecret: string): Promise<string | null> {\n const credentials = Buffer.from(`${clientId}:${clientSecret}`).toString(\"base64\");\n try {\n const res = await fetch(createUrl(issuer, \"/auth/token\"), {\n method: \"POST\",\n headers: {\n \"content-type\": \"application/x-www-form-urlencoded\",\n authorization: `Basic ${credentials}`,\n },\n body: \"grant_type=service_account\",\n cache: \"no-store\",\n });\n if (!res.ok) return null;\n const data = (await res.json()) as { access_token?: string };\n return data.access_token ?? null;\n } catch {\n return null;\n }\n}\n\nasync function getServiceToken(issuer: string, clientId: string, clientSecret: string): Promise<string | null> {\n const key = `${issuer}:${clientId}`;\n const now = Date.now();\n const cached = _serviceTokenCache.get(key);\n if (cached && cached.expiresAt - 30_000 > now) return cached.token;\n const token = await fetchServiceToken(issuer, clientId, clientSecret);\n if (!token) return null;\n _serviceTokenCache.set(key, { token, expiresAt: now + 420_000 });\n return token;\n}\n\ntype ServiceAccountConfig = {\n issuer?: string;\n clientId?: string;\n clientSecret?: string;\n};\n\n/**\n * Returns a service account access token.\n * The issuer is always the Geekapps Auth Server (GEEKAPPS_AUTH_SERVER_URL) —\n * it does not need to be configured. Reads GEEKAPPS_SERVICE_ACCOUNT_CLIENT_ID,\n * GEEKAPPS_SERVICE_ACCOUNT_CLIENT_SECRET by default.\n * Any field can be overridden via the optional config argument.\n * Tokens are cached per client and auto-renewed before expiry (~7 min TTL).\n */\nexport function getServiceAccountToken(override: ServiceAccountConfig = {}): Promise<string | null> {\n const issuer = normalizeBaseUrl(\n override.issuer ??\n // Deprecated: kept for backwards compatibility during the auth-server rename.\n env(\"GEEKAPPS_AUTH_ISSUER\") ??\n env(\"GEEKAPPS_AUTH_API_URL\") ??\n GEEKAPPS_AUTH_SERVER_URL,\n );\n const clientId = override.clientId ?? env(\"GEEKAPPS_SERVICE_ACCOUNT_CLIENT_ID\") ?? \"\";\n const clientSecret = override.clientSecret ?? env(\"GEEKAPPS_SERVICE_ACCOUNT_CLIENT_SECRET\") ?? \"\";\n if (!clientId || !clientSecret) return Promise.resolve(null);\n return getServiceToken(issuer, clientId, clientSecret);\n}\n\nfunction getRemoteJWKS(issuer: string) {\n if (!_remoteJWKSCache.has(issuer)) {\n _remoteJWKSCache.set(issuer, createRemoteJWKSet(new URL(`${issuer}/.well-known/jwks.json`)));\n }\n return _remoteJWKSCache.get(issuer)!;\n}\n\nexport type VerifyTokenOptions = {\n /** client_id desta app. Quando informado, o token precisa ter sido emitido para ela (`aud`). */\n audience?: string;\n /** Credenciais desta app, para conferir se uma App Connection ainda está ativa. */\n clientId?: string;\n clientSecret?: string;\n};\n\n/** Informações de App Connection lidas de um token já verificado. */\nexport type TokenConnectionInfo = {\n /** true quando outra app está agindo em nome do usuário, sem ele presente. */\n isAppConnection: boolean;\n /** Application que está agindo (claim `act.sub`). */\n actorAppId: string | null;\n /** Id da App Connection. */\n connectionId: string | null;\n};\n\nexport function getTokenConnectionInfo(payload: JWTPayload): TokenConnectionInfo {\n const isAppConnection = payload.token_type === \"app_connection\";\n const act = payload.act as { sub?: unknown } | undefined;\n return {\n isAppConnection,\n actorAppId: isAppConnection && typeof act?.sub === \"string\" ? act.sub : null,\n connectionId: isAppConnection && typeof payload.connection_id === \"string\" ? payload.connection_id : null,\n };\n}\n\n/**\n * Pergunta ao Auth se a App Connection segue ativa. Sem cache: revogar\n * corta o acesso na próxima requisição.\n */\nasync function isAppConnectionActive(\n issuer: string,\n connectionId: string,\n clientId: string,\n clientSecret: string,\n): Promise<boolean> {\n try {\n const response = await fetch(`${issuer}/connections/introspect`, {\n method: \"POST\",\n headers: {\n authorization: `Basic ${Buffer.from(`${encodeURIComponent(clientId)}:${encodeURIComponent(clientSecret)}`).toString(\"base64\")}`,\n \"content-type\": \"application/json\",\n },\n body: JSON.stringify({ connection_id: connectionId }),\n cache: \"no-store\",\n });\n const body = (await response.json().catch(() => null)) as { active?: boolean } | null;\n return response.ok && body?.active === true;\n } catch {\n return false;\n }\n}\n\nexport async function verifyToken(\n token: string,\n issuer: string,\n options: VerifyTokenOptions = {},\n): Promise<JWTPayload | null> {\n let payload: JWTPayload;\n try {\n ({ payload } = await jwtVerify(token, getRemoteJWKS(issuer), {\n algorithms: [\"RS256\"],\n issuer,\n // aud = issuer também passa: é o que os tokens do painel do Auth usam.\n ...(options.audience ? { audience: [options.audience, issuer] } : {}),\n }));\n } catch {\n return null;\n }\n\n // Token de App Connection: só vale se a conexão continua ativa agora.\n const info = getTokenConnectionInfo(payload);\n if (info.isAppConnection) {\n if (!info.connectionId || !options.clientId || !options.clientSecret) return null;\n const active = await isAppConnectionActive(issuer, info.connectionId, options.clientId, options.clientSecret);\n if (!active) return null;\n }\n\n return payload;\n}\n\n// Renovações em andamento por refresh token (por processo) — ver tryRefreshTokens.\nconst inflightServerRefreshes = new Map<string, Promise<SessionTokens | null>>();\n\nexport function configureGeekappsAuth(input: GeekappsAuthConfig = {}) {\n const clientId = resolveClientId(input);\n const config: ResolvedGeekappsAuthConfig = {\n ...input,\n issuer: resolveIssuer(input),\n clientId,\n clientSecret: resolveClientSecret(input),\n redirectUri: resolveConfiguredRedirectUri(input),\n afterSignInRedirectTo:\n input.afterSignInRedirectTo ?? input.redirectTo ?? \"/\",\n basePath: normalizeBasePath(input.basePath),\n cookiePrefix: deriveCookiePrefix(clientId),\n };\n\n const cn = getCookieNames(config.cookiePrefix);\n\n // ── Slot helpers ──────────────────────────────────────────────────────────\n\n async function getCount(): Promise<number> {\n const cookieStore = await cookies();\n const raw = cookieStore.get(cn.count)?.value;\n const n = raw ? Number.parseInt(raw, 10) : 0;\n return Number.isNaN(n) ? 0 : n;\n }\n\n async function getActiveIndex(): Promise<number> {\n const cookieStore = await cookies();\n const raw = cookieStore.get(cn.active)?.value;\n if (!raw) return 0;\n const n = Number.parseInt(raw, 10);\n return Number.isNaN(n) ? 0 : n;\n }\n\n function readSlot(\n cookieStore: Awaited<ReturnType<typeof cookies>>,\n n: number,\n ): StoredAccount | null {\n const at = cookieStore.get(cn.slotAt(n))?.value;\n if (!at) return null;\n const meta = decodeMeta(cookieStore.get(cn.slotMeta(n))?.value ?? \"\");\n if (!meta) return null;\n return {\n id: meta.id,\n accessToken: at,\n refreshToken: cookieStore.get(cn.slotRt(n))?.value ?? null,\n idToken: null, // not stored in cookies; claims are in meta\n sessionId: meta.sid ?? null,\n user: {\n sub: meta.id,\n name: meta.n ?? null,\n email: meta.e ?? null,\n picture: meta.p ?? null,\n },\n };\n }\n\n function writeSlot(\n cookieStore: Awaited<ReturnType<typeof cookies>>,\n n: number,\n account: StoredAccount,\n ) {\n const opts = { ...getCookieOptions(config), maxAge: 60 * 60 * 24 * 30 };\n cookieStore.set(cn.slotMeta(n), encodeMeta(account), opts);\n cookieStore.set(cn.slotAt(n), account.accessToken, opts);\n if (account.refreshToken) cookieStore.set(cn.slotRt(n), account.refreshToken, opts);\n }\n\n function clearSlot(\n cookieStore: Awaited<ReturnType<typeof cookies>>,\n n: number,\n ) {\n const clear = { ...getCookieOptions(config), maxAge: 0 };\n cookieStore.set(cn.slotMeta(n), \"\", clear);\n cookieStore.set(cn.slotAt(n), \"\", clear);\n cookieStore.set(cn.slotRt(n), \"\", clear);\n }\n\n // ── Public account helpers ─────────────────────────────────────────────────\n\n async function getAccountsList(): Promise<StoredAccount[]> {\n const [cookieStore, count] = await Promise.all([cookies(), getCount()]);\n const accounts: StoredAccount[] = [];\n for (let i = 1; i <= count; i++) {\n const slot = readSlot(cookieStore, i);\n if (slot) accounts.push(slot);\n }\n return accounts;\n }\n\n async function getActiveAccount(): Promise<StoredAccount | null> {\n const [cookieStore, activeIdx, count] = await Promise.all([cookies(), getActiveIndex(), getCount()]);\n if (count === 0) return null;\n if (activeIdx >= 1 && activeIdx <= count) {\n const slot = readSlot(cookieStore, activeIdx);\n if (slot) return slot;\n }\n // Fall back to slot 1\n return readSlot(cookieStore, 1);\n }\n\n async function getAccessToken() {\n // Prefer the token injected by the middleware via request header (same-request refresh)\n try {\n const hdrs = await headers();\n const auth = hdrs.get(\"authorization\");\n if (auth?.startsWith(\"Bearer \")) return auth.slice(7);\n } catch {}\n const active = await getActiveAccount();\n return active?.accessToken ?? null;\n }\n\n async function getRefreshToken() {\n const active = await getActiveAccount();\n return active?.refreshToken ?? null;\n }\n\n async function getSession(): Promise<SessionTokens | null> {\n const active = await getActiveAccount();\n if (!active) return null;\n return {\n accessToken: active.accessToken,\n refreshToken: active.refreshToken,\n idToken: active.idToken,\n sessionId: active.sessionId,\n };\n }\n\n async function getUser(): Promise<StoredAccount[\"user\"] | null> {\n const active = await getActiveAccount();\n return active?.user ?? null;\n }\n\n async function getUserById(userId: string, applicationId?: string): Promise<unknown | null> {\n const qs = new URLSearchParams({ ...(applicationId ? { applicationId } : {}) });\n try {\n return await apiRequest<unknown>(`/users/${userId}?${qs}`);\n } catch {\n return null;\n }\n }\n\n async function getOrganizations(): Promise<{ organizations: unknown[]; currentOrgId: string | null }> {\n const cookieStore = await cookies();\n const currentOrgId = cookieStore.get(cn.activeOrganizationId)?.value ?? null;\n try {\n const data = await apiRequest<unknown[] | { organizations: unknown[] }>(\"/organizations\");\n const organizations = Array.isArray(data) ? data : (data as { organizations: unknown[] }).organizations;\n return { organizations, currentOrgId };\n } catch {\n return { organizations: [], currentOrgId };\n }\n }\n\n function clearPublicTokenCookie(\n cookieStore: Awaited<ReturnType<typeof cookies>>,\n ) {\n // Clear legacy _tp cookie if present\n if (cookieStore.get(cn.tokenPub)?.value) {\n cookieStore.set(cn.tokenPub, \"\", { httpOnly: false, maxAge: 0, path: \"/\" });\n }\n }\n\n async function saveAccountToList(tokens: SessionTokens) {\n const account = parseAccountFromJwt(tokens);\n if (!account) return;\n\n const cookieStore = await cookies();\n const opts = { ...getCookieOptions(config), maxAge: 60 * 60 * 24 * 30 };\n const count = await getCount();\n\n // Find existing slot for this account id\n let targetSlot = 0;\n for (let i = 1; i <= count; i++) {\n const existing = readSlot(cookieStore, i);\n if (existing?.id === account.id) {\n targetSlot = i;\n break;\n }\n }\n\n if (targetSlot === 0) {\n if (count >= MAX_ACCOUNTS) return;\n targetSlot = count + 1;\n cookieStore.set(cn.count, String(targetSlot), opts);\n }\n\n writeSlot(cookieStore, targetSlot, account);\n cookieStore.set(cn.active, String(targetSlot), opts);\n clearPublicTokenCookie(cookieStore);\n }\n\n // Alias for backwards compat\n const setSessionCookies = saveAccountToList;\n\n async function switchAccount(accountId: string): Promise<boolean> {\n const [cookieStore, count] = await Promise.all([cookies(), getCount()]);\n const opts = { ...getCookieOptions(config), maxAge: 60 * 60 * 24 * 30 };\n\n for (let i = 1; i <= count; i++) {\n const slot = readSlot(cookieStore, i);\n if (slot && (slot.id === accountId || slot.sessionId === accountId)) {\n cookieStore.set(cn.active, String(i), opts);\n clearPublicTokenCookie(cookieStore);\n return true;\n }\n }\n return false;\n }\n\n async function removeActiveAccount() {\n const [cookieStore, activeIdx, count] = await Promise.all([cookies(), getActiveIndex(), getCount()]);\n const opts = { ...getCookieOptions(config), maxAge: 60 * 60 * 24 * 30 };\n const clear = { ...getCookieOptions(config), maxAge: 0 };\n\n if (count === 0) return;\n\n const remaining: StoredAccount[] = [];\n for (let i = 1; i <= count; i++) {\n if (i === activeIdx) continue;\n const slot = readSlot(cookieStore, i);\n if (slot) remaining.push(slot);\n }\n\n for (let i = 1; i <= count; i++) {\n clearSlot(cookieStore, i);\n }\n\n if (remaining.length === 0) {\n cookieStore.set(cn.count, \"0\", clear);\n cookieStore.set(cn.active, \"0\", clear);\n await clearPublicTokenCookie(cookieStore);\n return;\n }\n\n // Re-compact remaining accounts into slots 1..n\n for (let i = 0; i < remaining.length; i++) {\n await writeSlot(cookieStore, i + 1, remaining[i]!);\n }\n cookieStore.set(cn.count, String(remaining.length), opts);\n cookieStore.set(cn.active, \"1\", opts);\n clearPublicTokenCookie(cookieStore);\n }\n\n async function clearAllSessionCookies() {\n const [cookieStore, count] = await Promise.all([cookies(), getCount()]);\n const clear = { ...getCookieOptions(config), maxAge: 0 };\n for (let i = 1; i <= count; i++) {\n clearSlot(cookieStore, i);\n }\n cookieStore.set(cn.count, \"0\", clear);\n cookieStore.set(cn.active, \"0\", clear);\n clearPublicTokenCookie(cookieStore);\n }\n\n async function tryRefreshTokens(): Promise<string | null> {\n const refreshToken = await getRefreshToken();\n if (!refreshToken || !config.clientId) return null;\n\n // Chamadas simultâneas com o mesmo refresh token dividem uma renovação só\n // (ver middleware): renovar duas vezes com o mesmo token parece reuso.\n let pending = inflightServerRefreshes.get(refreshToken);\n if (!pending) {\n pending = refreshWithToken(refreshToken).finally(() => {\n setTimeout(() => inflightServerRefreshes.delete(refreshToken), 10_000);\n });\n inflightServerRefreshes.set(refreshToken, pending);\n }\n const tokens = await pending;\n if (!tokens?.accessToken) return null;\n\n // Em Server Component o Next não deixa gravar cookie: a renovação vale\n // para esta requisição, e o middleware grava o par novo na próxima. O\n // auth-server aceita a reapresentação do token antigo nesse caso.\n try {\n await saveAccountToList(tokens);\n } catch {\n // cookies() somente leitura neste contexto\n }\n return tokens.accessToken;\n }\n\n async function refreshWithToken(refreshToken: string) {\n\n const basicAuth = Buffer.from(`${config.clientId}:${config.clientSecret ?? \"\"}`).toString(\"base64\");\n\n const response = await fetch(createUrl(config.issuer, \"/auth/token\"), {\n method: \"POST\",\n headers: {\n \"content-type\": \"application/x-www-form-urlencoded\",\n authorization: `Basic ${basicAuth}`,\n },\n body: new URLSearchParams({\n grant_type: \"refresh_token\",\n refresh_token: refreshToken,\n }),\n cache: \"no-store\",\n });\n\n if (!response.ok) return null;\n\n const data = (await response.json().catch(() => null)) as TokenLikeResponse | null;\n if (!data) return null;\n\n const tokens = tokenFromResponse(data);\n return tokens.accessToken ? tokens : null;\n }\n\n async function apiRequest<T>(path: string, init: RequestInit = {}): Promise<T> {\n const accessToken = await getAccessToken();\n const requestHeaders = new Headers(init.headers);\n\n if (accessToken) requestHeaders.set(\"authorization\", `Bearer ${accessToken}`);\n if (init.body !== undefined && !requestHeaders.has(\"content-type\")) {\n requestHeaders.set(\"content-type\", \"application/json\");\n }\n\n const url = new URL(createUrl(config.issuer, path));\n\n const response = await fetch(url, {\n ...init,\n headers: requestHeaders,\n cache: \"no-store\",\n });\n\n if (response.status === 401) {\n const newAccessToken = await tryRefreshTokens();\n\n if (!newAccessToken) {\n // Sem renovar depois de um 401, a sessão acabou: expirou, foi revogada\n // no painel ou o refresh token caiu. Os cookies apontam para algo que\n // não existe mais, então saem — senão toda requisição seguinte repete\n // o mesmo ciclo. Quem chamou recebe um erro que dá para tratar, em vez\n // de \"Auth API error 401\" vindo do fundo da pilha.\n await clearAllSessionCookies().catch(() => {});\n throw new GeekappsSessionExpiredError();\n }\n\n if (newAccessToken) {\n const retryHeaders = new Headers(init.headers);\n retryHeaders.set(\"authorization\", `Bearer ${newAccessToken}`);\n if (init.body !== undefined && !retryHeaders.has(\"content-type\")) {\n retryHeaders.set(\"content-type\", \"application/json\");\n }\n const retryUrl = new URL(createUrl(config.issuer, path));\n const retryResponse = await fetch(retryUrl, {\n ...init,\n headers: retryHeaders,\n cache: \"no-store\",\n });\n if (!retryResponse.ok) {\n const error = await retryResponse.json().catch(() => null);\n throw new Error(error?.error_description ?? error?.error ?? error?.message ?? `Auth API error ${retryResponse.status}`);\n }\n if (retryResponse.status === 204) return null as T;\n return (await retryResponse.json()) as T;\n }\n }\n\n if (!response.ok) {\n const error = await response.json().catch(() => null);\n throw new Error(error?.error_description ?? error?.error ?? error?.message ?? `Auth API error ${response.status}`);\n }\n\n if (response.status === 204) return null as T;\n return (await response.json()) as T;\n }\n\n function createApiClient(): ServerAuthApiClient {\n return {\n get: (path) => apiRequest(path),\n post: (path, body) => apiRequest(path, { method: \"POST\", body: body === undefined ? undefined : JSON.stringify(body) }),\n patch: (path, body) => apiRequest(path, { method: \"PATCH\", body: body === undefined ? undefined : JSON.stringify(body) }),\n put: (path, body) => apiRequest(path, { method: \"PUT\", body: body === undefined ? undefined : JSON.stringify(body) }),\n delete: (path) => apiRequest(path, { method: \"DELETE\" }),\n };\n }\n\n async function requireUser(options: { redirectTo?: string } = {}) {\n const accessToken = await getAccessToken();\n if (!accessToken) {\n redirect(options.redirectTo ?? \"/\");\n }\n return accessToken;\n }\n\n function escapeHtml(str: string): string {\n return str.replace(/&/g, \"&\").replace(/</g, \"<\").replace(/>/g, \">\").replace(/\"/g, \""\");\n }\n\n function sanitizeRedirectTo(value: string | null | undefined): string {\n const raw = value ?? \"/\";\n // Never redirect back into the auth basePath — that would loop or replay a stale code.\n if (raw.startsWith(config.basePath)) return \"/\";\n // Only allow relative paths to prevent open-redirect.\n if (raw.startsWith(\"/\") && !raw.startsWith(\"//\")) return raw;\n return \"/\";\n }\n\n async function createAuthorizeRedirect(request: NextRequest, mode: \"login\" | \"register\" | \"add-account\" | \"google\") {\n const redirectTo = sanitizeRedirectTo(request.nextUrl.searchParams.get(\"redirectTo\") ?? config.afterSignInRedirectTo);\n const flowId = request.nextUrl.searchParams.get(\"flow_id\");\n const url = new URL(createUrl(config.issuer, mode === \"google\" ? \"/auth/google\" : \"/authorize\"));\n\n if (!config.clientId) {\n return NextResponse.json(\n { error: \"missing_client_id\", message: \"Missing GEEKAPPS_AUTH_CLIENT_ID or clientId.\" },\n { status: 500 },\n );\n }\n\n url.searchParams.set(\"client_id\", config.clientId);\n url.searchParams.set(\"redirect_uri\", resolveRedirectUri(config, request));\n url.searchParams.set(\"response_type\", \"code\");\n const scopes = config.scopes ?? [\"openid\", \"profile\", \"email\"];\n if (!scopes.includes(\"offline_access\")) scopes.push(\"offline_access\");\n url.searchParams.set(\"scope\", scopes.join(\" \"));\n url.searchParams.set(\"state\", Buffer.from(JSON.stringify({ redirectTo, mode })).toString(\"base64url\"));\n if (mode === \"register\") url.searchParams.set(\"screen_hint\", \"signup\");\n if (mode === \"add-account\") {\n // Adding an account always needs a fresh login, regardless of any existing session.\n url.searchParams.set(\"prompt\", \"login\");\n } else {\n // `prompt` (login | select_account | none | consent) is forwarded as-is to\n // the IDP — see OidcPrompt in client.tsx for what each value does.\n const prompt = request.nextUrl.searchParams.get(\"prompt\");\n // Legacy fallback: silentLogin=false is equivalent to prompt=login.\n const silentLogin = request.nextUrl.searchParams.get(\"silentLogin\");\n const resolvedPrompt = prompt ?? (silentLogin === \"false\" ? \"login\" : null);\n if (resolvedPrompt) url.searchParams.set(\"prompt\", resolvedPrompt);\n }\n if (flowId) url.searchParams.set(\"flow_id\", flowId);\n\n const codeVerifier = generateCodeVerifier();\n url.searchParams.set(\"code_challenge\", createCodeChallenge(codeVerifier));\n url.searchParams.set(\"code_challenge_method\", \"S256\");\n\n const response = NextResponse.redirect(url);\n const cn = getCookieNames(config.cookiePrefix);\n response.cookies.set(cn.pkceVerifier, codeVerifier, {\n ...getCookieOptions(config),\n maxAge: 60 * 10,\n });\n return response;\n }\n\n async function exchangeCode(request: NextRequest) {\n const code = request.nextUrl.searchParams.get(\"code\");\n const state = request.nextUrl.searchParams.get(\"state\");\n\n const origin = requestOrigin(request);\n\n if (!code) {\n const err = request.nextUrl.searchParams.get(\"error\") ?? \"missing_code\";\n const desc = request.nextUrl.searchParams.get(\"error_description\") ?? request.nextUrl.searchParams.get(\"error_message\") ?? \"No authorization code received\";\n return NextResponse.redirect(new URL(`${config.basePath}/callback?error=${encodeURIComponent(err)}&error_description=${encodeURIComponent(desc)}`, origin));\n }\n\n if (!config.clientId) {\n return NextResponse.redirect(new URL(`${config.basePath}/callback?error=missing_client_id&error_description=Auth+client+is+not+configured`, origin));\n }\n\n const basicAuth = Buffer.from(\n `${config.clientId}:${config.clientSecret ?? \"\"}`,\n ).toString(\"base64\");\n\n const cn = getCookieNames(config.cookiePrefix);\n const cookieStore = await cookies();\n const codeVerifier = cookieStore.get(cn.pkceVerifier)?.value;\n cookieStore.set(cn.pkceVerifier, \"\", { ...getCookieOptions(config), maxAge: 0 });\n\n const tokenBody = new URLSearchParams({\n grant_type: \"authorization_code\",\n code,\n redirect_uri: resolveRedirectUri(config, request),\n });\n if (codeVerifier) tokenBody.set(\"code_verifier\", codeVerifier);\n\n const response = await fetch(createUrl(config.issuer, \"/auth/token\"), {\n method: \"POST\",\n headers: {\n \"content-type\": \"application/x-www-form-urlencoded\",\n authorization: `Basic ${basicAuth}`,\n },\n body: tokenBody,\n cache: \"no-store\",\n });\n\n const data = (await response.json().catch(() => ({}))) as TokenLikeResponse & { error?: string; error_description?: string };\n if (!response.ok) {\n const error = encodeURIComponent((data.error as string | undefined) ?? \"token_exchange_failed\");\n const desc = encodeURIComponent((data.error_description as string | undefined) ?? `Token exchange failed with status ${response.status}`);\n return NextResponse.redirect(new URL(`${config.basePath}/callback?error=${error}&error_description=${desc}`, origin));\n }\n\n await saveAccountToList(tokenFromResponse(data));\n\n let redirectTo = sanitizeRedirectTo(config.afterSignInRedirectTo);\n if (state) {\n try {\n const parsed = JSON.parse(Buffer.from(state, \"base64url\").toString(\"utf8\")) as { redirectTo?: string };\n redirectTo = sanitizeRedirectTo(parsed.redirectTo);\n } catch {\n // ignore malformed state\n }\n }\n\n return NextResponse.redirect(new URL(redirectTo, origin));\n }\n\n function mapLocalPathToApiPath(route: string) {\n if (route === \"/me\") return \"/auth/me\";\n if (route === \"/accounts/switch\") return \"/auth/accounts/switch\";\n if (route === \"/organizations/active\") return \"/auth/organizations/active\";\n if (route === \"/permissions/check\") return \"/auth/permissions/check\";\n if (route === \"/login/password\") return \"/auth/login/password\";\n if (route === \"/register/password\") return \"/auth/register/password\";\n if (route === \"/passwordless/link\") return \"/auth/passwordless/link\";\n return route;\n }\n\n async function handleAccountsLocal(): Promise<NextResponse> {\n const [accounts, activeIdx] = await Promise.all([getAccountsList(), getActiveIndex()]);\n\n return NextResponse.json({\n accounts: accounts.map((a, i) => ({\n id: a.id,\n sessionId: a.sessionId ?? a.id,\n isActive: i + 1 === activeIdx,\n user: {\n id: a.user.sub,\n name: a.user.name ?? null,\n email: a.user.email ?? null,\n imageUrl: a.user.picture ?? null,\n phone: a.user.phone ?? null,\n },\n })),\n });\n }\n\n async function handleOrganizationsLocal(): Promise<NextResponse> {\n const cookieStore = await cookies();\n const activeOrgId = cookieStore.get(cn.activeOrganizationId)?.value ?? null;\n try {\n const data = await apiRequest<unknown[]|{ organizations: unknown[] }>(\"/organizations\");\n const organizations = Array.isArray(data) ? data : (data as { organizations: unknown[] }).organizations;\n return NextResponse.json({ organizations, currentOrgId: activeOrgId });\n } catch {\n return NextResponse.json({ organizations: [], currentOrgId: activeOrgId });\n }\n }\n\n async function handleSwitchAccountLocal(request: NextRequest): Promise<NextResponse> {\n const body = await readBody(request);\n const targetId =\n body && typeof body === \"object\"\n ? String(\n (body as Record<string, unknown>).accountId ??\n (body as Record<string, unknown>).sessionId ??\n \"\",\n )\n : \"\";\n\n if (!targetId) {\n return NextResponse.json({ error: \"accountId required\" }, { status: 400 });\n }\n\n const switched = await switchAccount(targetId);\n if (!switched) {\n return NextResponse.json({ error: \"Account not found\" }, { status: 404 });\n }\n\n return NextResponse.json({ ok: true });\n }\n\n async function proxy(request: NextRequest, route: string) {\n const accessToken = await getAccessToken();\n const body = [\"GET\", \"HEAD\"].includes(request.method) ? undefined : await readBody(request);\n const url = new URL(appendSearchParams(createUrl(config.issuer, mapLocalPathToApiPath(route)), request));\n const requestHeaders = new Headers();\n\n if (accessToken) requestHeaders.set(\"authorization\", `Bearer ${accessToken}`);\n if (body !== undefined) requestHeaders.set(\"content-type\", \"application/json\");\n\n let response = await fetch(url, {\n method: request.method,\n headers: requestHeaders,\n body: body === undefined ? undefined : typeof body === \"string\" ? body : JSON.stringify(body),\n cache: \"no-store\",\n });\n\n if (response.status === 401) {\n // Mesmo tratamento do apiRequest: tenta renovar uma vez e, se a sessão\n // já não existe (expirada ou revogada no painel), limpa os cookies. Sem\n // isso o cliente repetiria o mesmo token morto a cada chamada.\n const refreshed = await tryRefreshTokens();\n\n if (refreshed) {\n requestHeaders.set(\"authorization\", `Bearer ${refreshed}`);\n response = await fetch(url, {\n method: request.method,\n headers: requestHeaders,\n body:\n body === undefined\n ? undefined\n : typeof body === \"string\"\n ? body\n : JSON.stringify(body),\n cache: \"no-store\",\n });\n } else {\n await clearAllSessionCookies().catch(() => {});\n return NextResponse.json(\n { error: \"session_expired\" },\n { status: 401 },\n );\n }\n }\n\n const data = isJsonResponse(response) ? ((await response.json().catch(() => null)) as TokenLikeResponse | null) : null;\n if (data) {\n const tokens = tokenFromResponse(data);\n if (tokens.accessToken || tokens.refreshToken || tokens.idToken || tokens.sessionId) {\n await saveAccountToList(tokens);\n }\n return NextResponse.json(data, { status: response.status });\n }\n\n return responseFromApi(response);\n }\n\n async function handle(request: NextRequest) {\n const route = routeFromRequest(request, config.basePath);\n\n if (request.method === \"GET\" && route === \"/sign-in\") return createAuthorizeRedirect(request, \"login\");\n if (request.method === \"GET\" && route === \"/sign-up\") return createAuthorizeRedirect(request, \"register\");\n if (request.method === \"GET\" && route === \"/add-account\") return createAuthorizeRedirect(request, \"add-account\");\n if (request.method === \"GET\" && route === \"/login/google\") return createAuthorizeRedirect(request, \"google\");\n if (request.method === \"GET\" && route === \"/callback\") {\n const error = request.nextUrl.searchParams.get(\"error\");\n if (error) {\n const description = request.nextUrl.searchParams.get(\"error_description\") ?? request.nextUrl.searchParams.get(\"error_message\") ?? \"An authentication error occurred.\";\n return new NextResponse(\n `<!doctype html><html><head><meta charset=\"utf-8\"><title>Authentication Error</title></head><body style=\"font-family:sans-serif;max-width:480px;margin:80px auto;padding:0 16px\"><h2>Authentication Error</h2><p><strong>${escapeHtml(error)}</strong></p><p>${escapeHtml(description)}</p><a href=\"/\">Go back</a></body></html>`,\n { status: 400, headers: { \"content-type\": \"text/html; charset=utf-8\" } },\n );\n }\n return exchangeCode(request);\n }\n if (request.method === \"GET\" && route === \"/token\") {\n const accessToken = await getAccessToken();\n if (!accessToken) return NextResponse.json({ error: \"missing_token\" }, { status: 401 });\n return NextResponse.json({ accessToken });\n }\n\n\n if (request.method === \"GET\" && route === \"/accounts\") return handleAccountsLocal();\n if (request.method === \"POST\" && route === \"/accounts/switch\") return handleSwitchAccountLocal(request);\n if (request.method === \"GET\" && route === \"/organizations\") return handleOrganizationsLocal();\n\n if (request.method === \"POST\" && route === \"/organizations\") {\n const accessToken = await getAccessToken();\n const body = (await readBody(request)) as Record<string, unknown> | undefined;\n if (body && accessToken && !body.applicationId) {\n const claims = decodeJwtPayload(accessToken);\n const applicationId =\n (typeof claims?.application_id === \"string\" ? claims.application_id : null) ??\n (typeof claims?.client_id === \"string\" ? claims.client_id : null) ??\n (typeof claims?.applicationId === \"string\" ? claims.applicationId : null);\n if (applicationId) body.applicationId = applicationId;\n }\n const orgUrl = new URL(createUrl(config.issuer, \"/organizations\"));\n const requestHeaders = new Headers();\n if (accessToken) requestHeaders.set(\"authorization\", `Bearer ${accessToken}`);\n if (body !== undefined) requestHeaders.set(\"content-type\", \"application/json\");\n const response = await fetch(orgUrl, {\n method: \"POST\",\n headers: requestHeaders,\n body: JSON.stringify(body),\n cache: \"no-store\",\n });\n return responseFromApi(response);\n }\n\n if (request.method === \"POST\" && route === \"/sign-out\") {\n await apiRequest(\"/auth/sign-out\", { method: \"POST\" }).catch(() => null);\n await removeActiveAccount();\n return NextResponse.json({ ok: true });\n }\n\n if (request.method === \"POST\" && route === \"/sign-out-all\") {\n await apiRequest(\"/auth/sign-out-all\", { method: \"POST\" }).catch(() => null);\n await clearAllSessionCookies();\n return NextResponse.json({ ok: true });\n }\n\n if (request.method === \"POST\" && route === \"/organizations/active\") {\n const body = await readBody(request);\n const organizationId =\n body && typeof body === \"object\" && \"organizationId\" in body\n ? (body as Record<string, unknown>).organizationId\n : null;\n const response = NextResponse.json({ ok: true });\n if (typeof organizationId === \"string\") {\n const cookieOpts = {\n httpOnly: true,\n sameSite: \"lax\" as const,\n path: \"/\",\n secure: process.env.NODE_ENV === \"production\",\n maxAge: 60 * 60 * 24 * 7,\n };\n response.cookies.set(cn.activeOrganizationId, organizationId, cookieOpts);\n }\n return response;\n }\n\n return proxy(request, route);\n }\n\n return {\n handlers: {\n GET: handle,\n POST: handle,\n PATCH: handle,\n DELETE: handle,\n },\n GetSession: getSession,\n GetUser: getUser,\n GetUserById: getUserById,\n GetOrganizations: getOrganizations,\n SaveSession: saveAccountToList,\n GetAccessToken: getAccessToken,\n GetRefreshToken: getRefreshToken,\n IsAuthenticated: async () => (await getAccessToken()) !== null,\n TryRefreshTokens: tryRefreshTokens,\n RequireUser: requireUser,\n CreateAuthApiClient: createApiClient,\n cookiePrefix: config.cookiePrefix,\n VerifyToken: (token: string) =>\n verifyToken(token, config.issuer, {\n audience: config.clientId || undefined,\n clientId: config.clientId || undefined,\n clientSecret: config.clientSecret,\n }),\n GetTokenConnectionInfo: getTokenConnectionInfo,\n SignInUrl: async (redirectTo?: string) => {\n const configured = env(\"GEEKAPPS_APP_URL\");\n if (!configured) throw new Error(\"Cannot determine app origin. Set GEEKAPPS_APP_URL.\");\n const origin = configured.replace(/\\/$/, \"\");\n const url = new URL(`${origin}${config.basePath}/sign-in`);\n if (redirectTo) url.searchParams.set(\"redirectTo\", redirectTo);\n return url.toString();\n },\n };\n}\n\nexport function handlers(options?: GeekappsAuthConfig) {\n return configureGeekappsAuth(options).handlers;\n}\n\n/**\n * Returns true if the current request has a valid access token in cookies.\n * Safe to call in Server Components, Server Actions, and Route Handlers.\n */\nexport async function isAuthenticated(options?: GeekappsAuthConfig): Promise<boolean> {\n const { GetAccessToken } = configureGeekappsAuth(options);\n return (await GetAccessToken()) !== null;\n}\n"],"mappings":";AAAA,SAAS,gBAAAA,qBAAsC;AAC/C,SAAS,sBAAAC,qBAAoB,aAAAC,kBAAiB;;;ACD9C,SAAS,YAAY,mBAAmB;AACxC,SAAS,SAAS,eAAe;AACjC,SAAS,gBAAgB;AACzB,SAAS,oBAAsC;AAC/C,SAAS,oBAAoB,iBAAkC;AAoBxD,IAAM,2BAA2B;;;ADOxC,SAAS,IAAI,MAAc;AACzB,QAAM,QAAQ,QAAQ,IAAI,IAAI;AAC9B,SAAO,SAAS,MAAM,KAAK,EAAE,SAAS,IAAI,QAAQ;AACpD;AAEA,SAAS,iBAAiB,KAAa;AACrC,SAAO,IAAI,QAAQ,OAAO,EAAE;AAC9B;AAEA,SAAS,kBAAkB,OAAO,aAAa;AAC7C,MAAI,CAAC,KAAK,WAAW,GAAG,EAAG,QAAO,IAAI,IAAI;AAC1C,SAAO,KAAK,QAAQ,OAAO,EAAE;AAC/B;AAEA,SAAS,cAAc,OAA6B;AAClD,SAAO;AAAA,IACL,MAAM;AAAA,IAEJ,IAAI,sBAAsB,KAC1B,IAAI,uBAAuB,KAC3B;AAAA,EACJ;AACF;AAEA,SAAS,aAAa,UAA0B;AAC9C,MAAI,IAAI;AACR,WAAS,IAAI,GAAG,IAAI,SAAS,QAAQ,KAAK;AACxC,SAAM,KAAK,KAAK,IAAM,SAAS,WAAW,CAAC,IAAI;AAC/C,QAAI,MAAM;AAAA,EACZ;AACA,SAAO,EAAE,SAAS,EAAE,EAAE,SAAS,GAAG,GAAG;AACvC;AAEA,SAAS,mBAAmB,UAAsC;AAChE,MAAI,CAAC,SAAU,QAAO;AACtB,SAAO,MAAM,aAAa,QAAQ,CAAC;AACrC;AAGA,IAAM,uBAAuB;AAM7B,IAAM,oBAAoB,oBAAI,IAAkF;AAEhH,SAAS,aAAa,OAA8B;AAClD,MAAI;AACF,UAAM,QAAQ,MAAM,MAAM,GAAG;AAC7B,QAAI,MAAM,WAAW,EAAG,QAAO;AAC/B,UAAM,UAAU,MAAM,CAAC;AACvB,UAAM,SAAS,UAAU,IAAI,QAAQ,IAAK,QAAQ,SAAS,KAAM,CAAC;AAClE,UAAM,UAAU,KAAK,MAAM,KAAK,MAAM,CAAC;AACvC,WAAO,OAAO,QAAQ,QAAQ,WAAW,QAAQ,MAAM;AAAA,EACzD,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAEA,SAAS,iBAAiB,SAAsB,QAAyB;AACvE,QAAM,aACJ,UACA,IAAI,kBAAkB;AACxB,MAAI,WAAY,QAAO,WAAW,QAAQ,OAAO,EAAE;AAEnD,QAAM,IAAI;AAAA,IACR;AAAA,EACF;AACF;AAEA,IAAM,aAAa,oBAAI,IAAmD;AAE1E,SAAS,cAAc,QAAgB;AACrC,MAAI,CAAC,WAAW,IAAI,MAAM,GAAG;AAC3B,eAAW,IAAI,QAAQC,oBAAmB,IAAI,IAAI,GAAG,MAAM,wBAAwB,CAAC,CAAC;AAAA,EACvF;AACA,SAAO,WAAW,IAAI,MAAM;AAC9B;AAIA,eAAe,kBAAkB,OAAe,QAAgB,UAA0C;AACxG,MAAI;AACF,UAAM,EAAE,QAAQ,IAAI,MAAMC,WAAU,OAAO,cAAc,MAAM,GAAG;AAAA,MAChE,YAAY,CAAC,OAAO;AAAA,MACpB;AAAA;AAAA;AAAA;AAAA;AAAA,MAKA,GAAI,WAAW,EAAE,UAAU,CAAC,UAAU,MAAM,EAAE,IAAI,CAAC;AAAA,IACrD,CAAC;AAGD,QAAI,QAAQ,eAAe,iBAAkB,QAAO;AACpD,WAAO;AAAA,EACT,SAAS,KAAK;AAEZ,QAAI,eAAe,UAAW,QAAO;AAIrC,UAAM,OAAQ,IAA0B,QAAQ;AAChD,QAAI,KAAK,WAAW,UAAU,EAAG,QAAO;AAGxC,WAAO;AAAA,EACT;AACF;AAEO,SAAS,qBAAqB,QAA8B,CAAC,GAAG;AACrE,QAAM,SAAS,cAAc,KAAK;AAClC,QAAM,WAAW,kBAAkB,MAAM,QAAQ;AACjD,QAAM,WAAW,IAAI,yBAAyB;AAC9C,QAAM,SAAS,mBAAmB,QAAQ;AAE1C,QAAM,eAAe,GAAG,MAAM;AAC9B,QAAM,cAAc,GAAG,MAAM;AAC7B,QAAM,iBAAiB,GAAG,MAAM;AAChC,QAAM,SAAS,CAAC,MAAc,GAAG,MAAM,MAAM,CAAC;AAC9C,QAAM,SAAS,CAAC,MAAc,GAAG,MAAM,MAAM,CAAC;AAI9C,WAAS,iBAAiB,SAAsB,KAAwB;AACtE,UAAM,QACJ,QAAQ,QAAQ,IAAI,KAAK,KACzB,QAAQ,QAAQ,IAAI,sBAAsB,KAC1C,QAAQ,QAAQ,aAAa,IAAI,MAAM;AACzC,QAAI,OAAO;AACT,aAAO,IAAIC,cAAa,MAAM;AAAA,QAC5B,QAAQ;AAAA,QACR,SAAS;AAAA,UACP,UAAU,IAAI,SAAS;AAAA,UACvB,qBAAqB,IAAI,SAAS;AAAA,UAClC,MAAM;AAAA,QACR;AAAA,MACF,CAAC;AAAA,IACH;AACA,WAAOA,cAAa,SAAS,GAAG;AAAA,EAClC;AAEA,SAAO,eAAe,eAAe,SAA6C;AAEhF,QAAI,QAAQ,QAAQ,SAAS,WAAW,WAAW,GAAG,KAAK,QAAQ,QAAQ,aAAa,UAAU;AAChG,aAAOA,cAAa,KAAK;AAAA,IAC3B;AAEA,UAAM,WAAW,QAAQ,QAAQ,IAAI,WAAW,GAAG;AACnD,UAAM,QAAQ,WAAW,OAAO,SAAS,UAAU,EAAE,IAAI;AAEzD,UAAM,YAAY,QAAQ,QAAQ,IAAI,YAAY,GAAG;AACrD,QAAI,YAAY,YAAY,OAAO,SAAS,WAAW,EAAE,IAAI;AAC7D,QAAI,OAAO,MAAM,SAAS,KAAK,YAAY,KAAK,YAAY,OAAO;AACjE,kBAAY,QAAQ,IAAI,IAAI;AAAA,IAC9B;AAIA,QAAI,cAAc,GAAG;AACnB,YAAM,aAAa,QAAQ,QAAQ,IAAI,OAAO,CAAC,CAAC,GAAG;AACnD,YAAM,aAAa,QAAQ,QAAQ,IAAI,OAAO,CAAC,CAAC,GAAG;AACnD,UAAI,cAAc,WAAY,aAAY;AAAA,IAC5C;AAEA,UAAM,cAAc,YAAY,IAAK,QAAQ,QAAQ,IAAI,OAAO,SAAS,CAAC,GAAG,SAAS,OAAQ;AAC9F,UAAM,eAAe,YAAY,IAAK,QAAQ,QAAQ,IAAI,OAAO,SAAS,CAAC,GAAG,SAAS,OAAQ;AAE/F,UAAM,SAAS,iBAAiB,SAAS,MAAM,MAAM;AACrD,UAAM,YAAY,IAAI,IAAI,GAAG,MAAM,GAAG,QAAQ,UAAU;AACxD,UAAM,aAAa,QAAQ,QAAQ,WAAW,QAAQ,QAAQ;AAC9D,QAAI,cAAc,eAAe,KAAK;AACpC,gBAAU,aAAa,IAAI,cAAc,UAAU;AAAA,IACrD;AAEA,QAAI,CAAC,eAAe,CAAC,cAAc;AACjC,UAAI,MAAM,4BAA6B,QAAO,iBAAiB,SAAS,SAAS;AACjF,aAAOA,cAAa,KAAK;AAAA,IAC3B;AAGA,UAAM,MAAM,cAAc,aAAa,WAAW,IAAI;AACtD,UAAM,SAAS,KAAK,MAAM,KAAK,IAAI,IAAI,GAAI;AAC3C,UAAM,mBAAmB,CAAC,OAAO,MAAM,SAAS;AAEhD,QAAI,CAAC,kBAAkB;AAErB,YAAM,eAAe,MAAM,kBAAkB,aAAc,QAAQ,YAAY,MAAS;AACxF,UAAI,iBAAiB,QAAS,QAAOA,cAAa,KAAK;AAEvD,UAAI,iBAAiB,gBAAiB,QAAOA,cAAa,KAAK;AAAA,IAEjE;AAEA,QAAI,CAAC,cAAc;AACjB,UAAI,MAAM,4BAA6B,QAAO,iBAAiB,SAAS,SAAS;AACjF,aAAOA,cAAa,KAAK;AAAA,IAC3B;AAEA,UAAM,eAAe,IAAI,6BAA6B,KAAK;AAC3D,UAAM,YAAY,KAAK,GAAG,YAAY,EAAE,IAAI,YAAY,EAAE;AAE1D,UAAM,aAAa,YAAY;AAC7B,UAAIC,kBAAgC;AACpC,UAAIC,mBAAiC;AAErC,eAAS,UAAU,GAAG,WAAW,sBAAsB,WAAW;AAChE,YAAI;AACF,gBAAM,kBAAkB,MAAM,MAAM,GAAG,MAAM,eAAe;AAAA,YAC1D,QAAQ;AAAA,YACR,SAAS;AAAA,cACP,gBAAgB;AAAA,cAChB,eAAe,SAAS,SAAS;AAAA,YACnC;AAAA,YACA,MAAM,IAAI,gBAAgB;AAAA,cACxB,YAAY;AAAA,cACZ,eAAe;AAAA,YACjB,CAAC;AAAA,YACD,OAAO;AAAA,UACT,CAAC;AAED,cAAI,CAAC,gBAAgB,GAAI;AAEzB,gBAAM,OAAQ,MAAM,gBAAgB,KAAK;AACzC,UAAAD,kBAAiB,KAAK,eAAe,KAAK,gBAAgB;AAC1D,UAAAC,mBAAkB,KAAK,gBAAgB,KAAK,iBAAiB;AAC7D,cAAID,gBAAgB;AAAA,QACtB,QAAQ;AACN,cAAI,YAAY,qBAAsB;AAAA,QACxC;AAAA,MACF;AACA,aAAO,EAAE,aAAaA,iBAAgB,cAAcC,iBAAgB;AAAA,IACtE;AAEA,QAAI,UAAU,kBAAkB,IAAI,YAAY;AAChD,QAAI,CAAC,SAAS;AACZ,gBAAU,WAAW,EAAE,QAAQ,MAAM;AAGnC,mBAAW,MAAM,kBAAkB,OAAO,YAAY,GAAG,GAAM;AAAA,MACjE,CAAC;AACD,wBAAkB,IAAI,cAAc,OAAO;AAAA,IAC7C;AACA,UAAM,EAAE,aAAa,gBAAgB,cAAc,gBAAgB,IAAI,MAAM;AAE7E,QAAI,CAAC,gBAAgB;AAEnB,YAAMC,YAAW,MAAM,8BACnB,iBAAiB,SAAS,SAAS,IACnCH,cAAa,KAAK;AACtB,YAAM,YAAY;AAAA,QAChB,UAAU;AAAA,QACV,QAAQ,MAAM,iBAAiB,QAAQ,IAAI,aAAa;AAAA,QACxD,UAAU;AAAA,QACV,MAAM;AAAA,QACN,QAAQ;AAAA,MACV;AACA,MAAAG,UAAS,QAAQ,IAAI,OAAO,SAAS,GAAG,IAAI,SAAS;AACrD,MAAAA,UAAS,QAAQ,IAAI,OAAO,SAAS,GAAG,IAAI,SAAS;AACrD,MAAAA,UAAS,QAAQ,IAAI,GAAG,MAAM,KAAK,SAAS,IAAI,IAAI,SAAS;AAC7D,MAAAA,UAAS,QAAQ,IAAI,gBAAgB,IAAI,EAAE,GAAG,WAAW,UAAU,MAAM,CAAC;AAC1E,MAAAA,UAAS,QAAQ,IAAI,cAAc,KAAK,SAAS;AACjD,aAAOA;AAAA,IACT;AAEA,UAAM,gBAAgB;AAAA,MACpB,UAAU;AAAA,MACV,QAAQ,MAAM,iBAAiB,QAAQ,IAAI,aAAa;AAAA,MACxD,UAAU;AAAA,MACV,MAAM;AAAA,MACN,QAAQ,KAAK,KAAK,KAAK;AAAA,IACzB;AAIA,UAAM,iBAAiB,IAAI,QAAQ,QAAQ,OAAO;AAClD,mBAAe,IAAI,iBAAiB,UAAU,cAAc,EAAE;AAE9D,UAAM,WAAWH,cAAa,KAAK,EAAE,SAAS,EAAE,SAAS,eAAe,EAAE,CAAC;AAC3E,aAAS,QAAQ,IAAI,OAAO,SAAS,GAAG,gBAAgB,aAAa;AACrE,QAAI,gBAAiB,UAAS,QAAQ,IAAI,OAAO,SAAS,GAAG,iBAAiB,aAAa;AAE3F,QAAI,QAAQ,QAAQ,IAAI,cAAc,GAAG,OAAO;AAC9C,eAAS,QAAQ,IAAI,gBAAgB,IAAI,EAAE,UAAU,OAAO,QAAQ,GAAG,MAAM,IAAI,CAAC;AAAA,IACpF;AAEA,WAAO;AAAA,EACT;AACF;","names":["NextResponse","createRemoteJWKSet","jwtVerify","createRemoteJWKSet","jwtVerify","NextResponse","newAccessToken","newRefreshToken","response"]}
|
package/dist/server.d.ts
CHANGED
|
@@ -95,7 +95,24 @@ type ServiceAccountConfig = {
|
|
|
95
95
|
* Tokens are cached per client and auto-renewed before expiry (~7 min TTL).
|
|
96
96
|
*/
|
|
97
97
|
declare function getServiceAccountToken(override?: ServiceAccountConfig): Promise<string | null>;
|
|
98
|
-
|
|
98
|
+
type VerifyTokenOptions = {
|
|
99
|
+
/** client_id desta app. Quando informado, o token precisa ter sido emitido para ela (`aud`). */
|
|
100
|
+
audience?: string;
|
|
101
|
+
/** Credenciais desta app, para conferir se uma App Connection ainda está ativa. */
|
|
102
|
+
clientId?: string;
|
|
103
|
+
clientSecret?: string;
|
|
104
|
+
};
|
|
105
|
+
/** Informações de App Connection lidas de um token já verificado. */
|
|
106
|
+
type TokenConnectionInfo = {
|
|
107
|
+
/** true quando outra app está agindo em nome do usuário, sem ele presente. */
|
|
108
|
+
isAppConnection: boolean;
|
|
109
|
+
/** Application que está agindo (claim `act.sub`). */
|
|
110
|
+
actorAppId: string | null;
|
|
111
|
+
/** Id da App Connection. */
|
|
112
|
+
connectionId: string | null;
|
|
113
|
+
};
|
|
114
|
+
declare function getTokenConnectionInfo(payload: JWTPayload): TokenConnectionInfo;
|
|
115
|
+
declare function verifyToken(token: string, issuer: string, options?: VerifyTokenOptions): Promise<JWTPayload | null>;
|
|
99
116
|
declare function configureGeekappsAuth(input?: GeekappsAuthConfig): {
|
|
100
117
|
handlers: {
|
|
101
118
|
GET: (request: NextRequest) => Promise<NextResponse<any>>;
|
|
@@ -121,6 +138,7 @@ declare function configureGeekappsAuth(input?: GeekappsAuthConfig): {
|
|
|
121
138
|
CreateAuthApiClient: () => ServerAuthApiClient;
|
|
122
139
|
cookiePrefix: string;
|
|
123
140
|
VerifyToken: (token: string) => Promise<JWTPayload | null>;
|
|
141
|
+
GetTokenConnectionInfo: typeof getTokenConnectionInfo;
|
|
124
142
|
SignInUrl: (redirectTo?: string) => Promise<string>;
|
|
125
143
|
};
|
|
126
144
|
declare function handlers(options?: GeekappsAuthConfig): {
|
|
@@ -135,4 +153,4 @@ declare function handlers(options?: GeekappsAuthConfig): {
|
|
|
135
153
|
*/
|
|
136
154
|
declare function isAuthenticated(options?: GeekappsAuthConfig): Promise<boolean>;
|
|
137
155
|
|
|
138
|
-
export { GEEKAPPS_AUTH_SERVER_URL, type GeekappsAuthConfig, GeekappsSessionExpiredError, type ServerAuthApiClient, type SessionTokens, type StoredAccount, configureGeekappsAuth, getServiceAccountToken, handlers, isAuthenticated, verifyToken };
|
|
156
|
+
export { GEEKAPPS_AUTH_SERVER_URL, type GeekappsAuthConfig, GeekappsSessionExpiredError, type ServerAuthApiClient, type SessionTokens, type StoredAccount, type TokenConnectionInfo, type VerifyTokenOptions, configureGeekappsAuth, getServiceAccountToken, getTokenConnectionInfo, handlers, isAuthenticated, verifyToken };
|
package/dist/server.js
CHANGED
|
@@ -41,11 +41,6 @@ function resolveClientId(input) {
|
|
|
41
41
|
function resolveClientSecret(input) {
|
|
42
42
|
return input.clientSecret ?? env("GEEKAPPS_AUTH_CLIENT_SECRET");
|
|
43
43
|
}
|
|
44
|
-
function applyClientCredentials(url, config) {
|
|
45
|
-
if (!config.clientId || !config.clientSecret) return;
|
|
46
|
-
url.searchParams.set("client_id", config.clientId);
|
|
47
|
-
url.searchParams.set("client_secret", config.clientSecret);
|
|
48
|
-
}
|
|
49
44
|
function resolveConfiguredRedirectUri(input) {
|
|
50
45
|
return input.redirectUri ?? env("GEEKAPPS_AUTH_REDIRECT_URI");
|
|
51
46
|
}
|
|
@@ -233,17 +228,53 @@ function getRemoteJWKS(issuer) {
|
|
|
233
228
|
}
|
|
234
229
|
return _remoteJWKSCache.get(issuer);
|
|
235
230
|
}
|
|
236
|
-
|
|
231
|
+
function getTokenConnectionInfo(payload) {
|
|
232
|
+
const isAppConnection = payload.token_type === "app_connection";
|
|
233
|
+
const act = payload.act;
|
|
234
|
+
return {
|
|
235
|
+
isAppConnection,
|
|
236
|
+
actorAppId: isAppConnection && typeof act?.sub === "string" ? act.sub : null,
|
|
237
|
+
connectionId: isAppConnection && typeof payload.connection_id === "string" ? payload.connection_id : null
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
async function isAppConnectionActive(issuer, connectionId, clientId, clientSecret) {
|
|
237
241
|
try {
|
|
238
|
-
const
|
|
239
|
-
|
|
240
|
-
|
|
242
|
+
const response = await fetch(`${issuer}/connections/introspect`, {
|
|
243
|
+
method: "POST",
|
|
244
|
+
headers: {
|
|
245
|
+
authorization: `Basic ${Buffer.from(`${encodeURIComponent(clientId)}:${encodeURIComponent(clientSecret)}`).toString("base64")}`,
|
|
246
|
+
"content-type": "application/json"
|
|
247
|
+
},
|
|
248
|
+
body: JSON.stringify({ connection_id: connectionId }),
|
|
249
|
+
cache: "no-store"
|
|
241
250
|
});
|
|
242
|
-
|
|
251
|
+
const body = await response.json().catch(() => null);
|
|
252
|
+
return response.ok && body?.active === true;
|
|
253
|
+
} catch {
|
|
254
|
+
return false;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
async function verifyToken(token, issuer, options = {}) {
|
|
258
|
+
let payload;
|
|
259
|
+
try {
|
|
260
|
+
({ payload } = await jwtVerify(token, getRemoteJWKS(issuer), {
|
|
261
|
+
algorithms: ["RS256"],
|
|
262
|
+
issuer,
|
|
263
|
+
// aud = issuer também passa: é o que os tokens do painel do Auth usam.
|
|
264
|
+
...options.audience ? { audience: [options.audience, issuer] } : {}
|
|
265
|
+
}));
|
|
243
266
|
} catch {
|
|
244
267
|
return null;
|
|
245
268
|
}
|
|
269
|
+
const info = getTokenConnectionInfo(payload);
|
|
270
|
+
if (info.isAppConnection) {
|
|
271
|
+
if (!info.connectionId || !options.clientId || !options.clientSecret) return null;
|
|
272
|
+
const active = await isAppConnectionActive(issuer, info.connectionId, options.clientId, options.clientSecret);
|
|
273
|
+
if (!active) return null;
|
|
274
|
+
}
|
|
275
|
+
return payload;
|
|
246
276
|
}
|
|
277
|
+
var inflightServerRefreshes = /* @__PURE__ */ new Map();
|
|
247
278
|
function configureGeekappsAuth(input = {}) {
|
|
248
279
|
const clientId = resolveClientId(input);
|
|
249
280
|
const config = {
|
|
@@ -449,6 +480,22 @@ function configureGeekappsAuth(input = {}) {
|
|
|
449
480
|
async function tryRefreshTokens() {
|
|
450
481
|
const refreshToken = await getRefreshToken();
|
|
451
482
|
if (!refreshToken || !config.clientId) return null;
|
|
483
|
+
let pending = inflightServerRefreshes.get(refreshToken);
|
|
484
|
+
if (!pending) {
|
|
485
|
+
pending = refreshWithToken(refreshToken).finally(() => {
|
|
486
|
+
setTimeout(() => inflightServerRefreshes.delete(refreshToken), 1e4);
|
|
487
|
+
});
|
|
488
|
+
inflightServerRefreshes.set(refreshToken, pending);
|
|
489
|
+
}
|
|
490
|
+
const tokens = await pending;
|
|
491
|
+
if (!tokens?.accessToken) return null;
|
|
492
|
+
try {
|
|
493
|
+
await saveAccountToList(tokens);
|
|
494
|
+
} catch {
|
|
495
|
+
}
|
|
496
|
+
return tokens.accessToken;
|
|
497
|
+
}
|
|
498
|
+
async function refreshWithToken(refreshToken) {
|
|
452
499
|
const basicAuth = Buffer.from(`${config.clientId}:${config.clientSecret ?? ""}`).toString("base64");
|
|
453
500
|
const response = await fetch(createUrl(config.issuer, "/auth/token"), {
|
|
454
501
|
method: "POST",
|
|
@@ -466,9 +513,7 @@ function configureGeekappsAuth(input = {}) {
|
|
|
466
513
|
const data = await response.json().catch(() => null);
|
|
467
514
|
if (!data) return null;
|
|
468
515
|
const tokens = tokenFromResponse(data);
|
|
469
|
-
|
|
470
|
-
await saveAccountToList(tokens);
|
|
471
|
-
return tokens.accessToken;
|
|
516
|
+
return tokens.accessToken ? tokens : null;
|
|
472
517
|
}
|
|
473
518
|
async function apiRequest(path, init = {}) {
|
|
474
519
|
const accessToken = await getAccessToken();
|
|
@@ -478,7 +523,6 @@ function configureGeekappsAuth(input = {}) {
|
|
|
478
523
|
requestHeaders.set("content-type", "application/json");
|
|
479
524
|
}
|
|
480
525
|
const url = new URL(createUrl(config.issuer, path));
|
|
481
|
-
applyClientCredentials(url, config);
|
|
482
526
|
const response = await fetch(url, {
|
|
483
527
|
...init,
|
|
484
528
|
headers: requestHeaders,
|
|
@@ -498,7 +542,6 @@ function configureGeekappsAuth(input = {}) {
|
|
|
498
542
|
retryHeaders.set("content-type", "application/json");
|
|
499
543
|
}
|
|
500
544
|
const retryUrl = new URL(createUrl(config.issuer, path));
|
|
501
|
-
applyClientCredentials(retryUrl, config);
|
|
502
545
|
const retryResponse = await fetch(retryUrl, {
|
|
503
546
|
...init,
|
|
504
547
|
headers: retryHeaders,
|
|
@@ -689,7 +732,6 @@ function configureGeekappsAuth(input = {}) {
|
|
|
689
732
|
const accessToken = await getAccessToken();
|
|
690
733
|
const body = ["GET", "HEAD"].includes(request.method) ? void 0 : await readBody(request);
|
|
691
734
|
const url = new URL(appendSearchParams(createUrl(config.issuer, mapLocalPathToApiPath(route)), request));
|
|
692
|
-
applyClientCredentials(url, config);
|
|
693
735
|
const requestHeaders = new Headers();
|
|
694
736
|
if (accessToken) requestHeaders.set("authorization", `Bearer ${accessToken}`);
|
|
695
737
|
if (body !== void 0) requestHeaders.set("content-type", "application/json");
|
|
@@ -762,7 +804,6 @@ function configureGeekappsAuth(input = {}) {
|
|
|
762
804
|
if (applicationId) body.applicationId = applicationId;
|
|
763
805
|
}
|
|
764
806
|
const orgUrl = new URL(createUrl(config.issuer, "/organizations"));
|
|
765
|
-
applyClientCredentials(orgUrl, config);
|
|
766
807
|
const requestHeaders = new Headers();
|
|
767
808
|
if (accessToken) requestHeaders.set("authorization", `Bearer ${accessToken}`);
|
|
768
809
|
if (body !== void 0) requestHeaders.set("content-type", "application/json");
|
|
@@ -821,7 +862,12 @@ function configureGeekappsAuth(input = {}) {
|
|
|
821
862
|
RequireUser: requireUser,
|
|
822
863
|
CreateAuthApiClient: createApiClient,
|
|
823
864
|
cookiePrefix: config.cookiePrefix,
|
|
824
|
-
VerifyToken: (token) => verifyToken(token, config.issuer
|
|
865
|
+
VerifyToken: (token) => verifyToken(token, config.issuer, {
|
|
866
|
+
audience: config.clientId || void 0,
|
|
867
|
+
clientId: config.clientId || void 0,
|
|
868
|
+
clientSecret: config.clientSecret
|
|
869
|
+
}),
|
|
870
|
+
GetTokenConnectionInfo: getTokenConnectionInfo,
|
|
825
871
|
SignInUrl: async (redirectTo) => {
|
|
826
872
|
const configured = env("GEEKAPPS_APP_URL");
|
|
827
873
|
if (!configured) throw new Error("Cannot determine app origin. Set GEEKAPPS_APP_URL.");
|
|
@@ -844,6 +890,7 @@ export {
|
|
|
844
890
|
GeekappsSessionExpiredError,
|
|
845
891
|
configureGeekappsAuth,
|
|
846
892
|
getServiceAccountToken,
|
|
893
|
+
getTokenConnectionInfo,
|
|
847
894
|
handlers,
|
|
848
895
|
isAuthenticated,
|
|
849
896
|
verifyToken
|