UNPKG

wcz-layout

Version:

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