wcz-layout
Version:
1 lines • 12.1 kB
Source Map (JSON)
{"version":3,"file":"tokens-D8CVIFTb.mjs","names":["createHash","randomBytes","randomUUID","Provider","SessionExpiredError","Error","TokenResponse","access_token","refresh_token","id_token","expires_in","base64url","bytes","Buffer","toString","createPkce","verifier","challenge","update","digest","createState","isSessionDead","error","suberror","requestToken","provider","body","Record","Promise","credentials","from","clientId","clientSecret","response","fetch","tokenEndpoint","method","headers","authorization","URLSearchParams","client_id","payload","json","ok","message","error_description","status","id","buildAuthUrl","opts","redirectUri","state","url","URL","authorizeEndpoint","search","response_type","redirect_uri","scope","loginScopes","join","code_challenge","code_challenge_method","exchangeCode","code","codeVerifier","grant_type","code_verifier","refreshTokens","refreshToken","clientCredentials","createServerOnlyFn","scopes","definedScopes","Scope","ProviderId","SessionExpiredError","clientCredentials","refreshTokens","getProvider","getAppSession","ScopeKey","resolveScope","key","provider","entry","scope","undefined","Error","getAccessToken","scopeKey","Promise","session","providerId","refreshToken","data","result","refresh_token","update","access_token","error","clear","Response","json","message","status","appTokens","Map","token","expiresAt","getAppToken","cached","get","Date","now","defaultScope","slice","lastIndexOf","set","expires_in"],"sources":["../src/lib/auth/oidc.ts","../src/lib/auth/tokens.ts"],"sourcesContent":["import { createHash, randomBytes, randomUUID } from \"node:crypto\";\nimport type { Provider } from \"./providers\";\n\n/**\n * The whole OAuth2/OIDC surface this app needs, as plain form POSTs. Both Entra\n * and Cognito are ordinary OIDC servers, so a second provider is a config object\n * (see `providers.ts`) rather than a second integration.\n */\n\n/** The refresh token is expired/revoked — the user must sign in interactively. */\nexport class SessionExpiredError extends Error {}\n\nexport interface TokenResponse {\n access_token: string;\n refresh_token?: string;\n id_token?: string;\n expires_in: number;\n}\n\nconst base64url = (bytes: Buffer): string => bytes.toString(\"base64url\");\n\n/** PKCE verifier + S256 challenge for the authorization-code flow. */\nexport const createPkce = (): { verifier: string; challenge: string } => {\n const verifier = base64url(randomBytes(32));\n return { verifier, challenge: base64url(createHash(\"sha256\").update(verifier).digest()) };\n};\n\nexport const createState = (): string => randomUUID();\n\n/**\n * Revocation, inactivity expiry and Conditional Access sign-in frequency all\n * surface as `invalid_grant` or `interaction_required` — the session is dead and\n * only an interactive login helps. Missing consent (Entra AADSTS65001) looks the\n * same but is a configuration bug, so it is left to surface as-is.\n */\nconst isSessionDead = (error: { error?: string; suberror?: string }): boolean =>\n (error.error === \"invalid_grant\" || error.error === \"interaction_required\") &&\n error.suberror !== \"consent_required\";\n\nasync function requestToken(\n provider: Provider,\n body: Record<string, string>,\n): Promise<TokenResponse> {\n const credentials = Buffer.from(`${provider.clientId}:${provider.clientSecret}`).toString(\n \"base64\",\n );\n const response = await fetch(provider.tokenEndpoint, {\n method: \"POST\",\n headers: {\n \"content-type\": \"application/x-www-form-urlencoded\",\n // Both providers accept client_secret_basic; it keeps the secret out of the body.\n authorization: `Basic ${credentials}`,\n },\n body: new URLSearchParams({ client_id: provider.clientId, ...body }),\n });\n\n const payload = (await response.json()) as TokenResponse &\n Record<\"error\" | \"error_description\" | \"suberror\", string | undefined>;\n if (response.ok) return payload;\n\n const message = payload.error_description ?? payload.error ?? `HTTP ${response.status}`;\n if (isSessionDead(payload)) throw new SessionExpiredError(message);\n throw new Error(`[${provider.id}] ${message}`);\n}\n\n/** First leg of the auth-code flow: the URL to redirect the browser to. */\nexport function buildAuthUrl(\n provider: Provider,\n opts: { redirectUri: string; state: string; challenge: string },\n): string {\n const url = new URL(provider.authorizeEndpoint);\n url.search = new URLSearchParams({\n client_id: provider.clientId,\n response_type: \"code\",\n redirect_uri: opts.redirectUri,\n state: opts.state,\n scope: provider.loginScopes.join(\" \"),\n code_challenge: opts.challenge,\n code_challenge_method: \"S256\",\n }).toString();\n return url.toString();\n}\n\n/** Second leg: exchange the code for tokens. The refresh token is in the body. */\nexport const exchangeCode = (\n provider: Provider,\n opts: { code: string; redirectUri: string; codeVerifier: string },\n): Promise<TokenResponse> =>\n requestToken(provider, {\n grant_type: \"authorization_code\",\n code: opts.code,\n redirect_uri: opts.redirectUri,\n code_verifier: opts.codeVerifier,\n });\n\n/**\n * Delegated token for `scope`. Entra mints a token for any resource from its\n * multi-resource refresh token; Cognito can only narrow within the scopes already\n * registered on the app client, and ignores the rest.\n */\nexport const refreshTokens = (\n provider: Provider,\n opts: { refreshToken: string; scope: string },\n): Promise<TokenResponse> =>\n requestToken(provider, {\n grant_type: \"refresh_token\",\n refresh_token: opts.refreshToken,\n scope: opts.scope,\n });\n\n/** App-only token: no user context, for background jobs. */\nexport const clientCredentials = (\n provider: Provider,\n opts: { scope: string },\n): Promise<TokenResponse> =>\n requestToken(provider, { grant_type: \"client_credentials\", scope: opts.scope });\n","import { createServerOnlyFn } from \"@tanstack/react-start\";\nimport { scopes as definedScopes } from \"virtual:wcz-layout\";\nimport type { Scope } from \"~/models/Scopes\";\nimport type { ProviderId } from \"~/models/User\";\nimport { SessionExpiredError, clientCredentials, refreshTokens } from \"./oidc\";\nimport { getProvider } from \"./providers\";\nimport { getAppSession } from \"./session\";\n\nexport type ScopeKey = keyof typeof definedScopes;\n\n/**\n * A scope entry is either a bare string (Entra) or `{ entra, aws }`. Throwing on a\n * missing entry fails at the call site instead of sending a token the API rejects.\n */\nexport const resolveScope = (key: ScopeKey, provider: ProviderId): string => {\n const entry: Scope = definedScopes[key];\n const scope =\n typeof entry === \"string\" ? (provider === \"entra\" ? entry : undefined) : entry[provider];\n\n if (!scope) throw new Error(`No \"${key}\" scope is configured for the \"${provider}\" provider.`);\n return scope;\n};\n\n/**\n * Delegated access token for the signed-in user, minted from the session refresh\n * token. Providers that rotate the refresh token return a new one, which is\n * persisted back. Server-only — stripped from the client bundle.\n */\nexport const getAccessToken = createServerOnlyFn(async (scopeKey: ScopeKey): Promise<string> => {\n const session = await getAppSession();\n const { provider: providerId, refreshToken } = session.data;\n if (!providerId || !refreshToken) throw new Error(\"No active session. User not signed in.\");\n\n try {\n const result = await refreshTokens(getProvider(providerId), {\n refreshToken,\n scope: resolveScope(scopeKey, providerId),\n });\n if (result.refresh_token && result.refresh_token !== refreshToken)\n await session.update({ refreshToken: result.refresh_token });\n return result.access_token;\n } catch (error) {\n // Revoked/expired refresh token: clear the dead session and answer 401 so the\n // client can redirect to /login, instead of surfacing a 500.\n if (error instanceof SessionExpiredError) {\n await session.clear();\n throw Response.json(\n { message: \"Unauthorized: Session expired, sign in again\" },\n { status: 401 },\n );\n }\n throw error;\n }\n});\n\n// MSAL used to cache these in memory; without it, a small TTL map keeps parity.\nconst appTokens = new Map<ScopeKey, { token: string; expiresAt: number }>();\n\n/**\n * Client-credentials (app-only) token, for background jobs with no user.\n * Entra-only: the AWS population signs in interactively, so a Cognito app client\n * for M2M has no caller yet (and Cognito bills M2M per token request).\n */\nexport const getAppToken = createServerOnlyFn(async (scopeKey: ScopeKey): Promise<string> => {\n const cached = appTokens.get(scopeKey);\n if (cached && cached.expiresAt > Date.now()) return cached.token;\n\n // Entra client credentials only accept `<resource>/.default`, not user scopes.\n const scope = resolveScope(scopeKey, \"entra\");\n const defaultScope = `${scope.slice(0, scope.lastIndexOf(\"/\"))}/.default`;\n\n const result = await clientCredentials(getProvider(\"entra\"), { scope: defaultScope });\n appTokens.set(scopeKey, {\n token: result.access_token,\n expiresAt: Date.now() + (result.expires_in - 60) * 1000,\n });\n return result.access_token;\n});\n"],"mappings":"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"}