wcz-layout
Version:
143 lines (142 loc) • 6 kB
JavaScript
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