Contract C-8 and C-10: with OAUTH_CLIENT_SECRET set, sign-in goes through the server's page and the session keeps sealed tokens, renewed before they expire, instead of a password. Push keeps a credential that renews itself. A password change signs the session out, since the server revokes its tokens. The mock answers OAuth for tests and development. Eleven new strings, in all nine catalogues.
132 lines
5.6 KiB
TypeScript
132 lines
5.6 KiB
TypeScript
/**
|
|
* The mock's OAuth side, enough to sign in the way INBUXA's server does:
|
|
* metadata, a sign-in page, and a token endpoint for one confidential client.
|
|
*
|
|
* The sign-in page approves the demo user at once: there is no form, since
|
|
* what's being exercised is ihasmail's side of the flow. Tokens are tied to
|
|
* the password they were issued under, so a password change revokes them,
|
|
* as it does on the real server.
|
|
*/
|
|
import { createHash, randomBytes } from "node:crypto";
|
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
import { PORT, USER, account } from "./config.js";
|
|
|
|
export const OAUTH_CLIENT_ID = process.env.MOCK_OAUTH_CLIENT_ID ?? "ihasmail-inbuxa";
|
|
export const OAUTH_CLIENT_SECRET = process.env.MOCK_OAUTH_CLIENT_SECRET ?? "mock-oauth-secret";
|
|
/** Seconds an access token lasts. */
|
|
export let accessTokenTtl = Number(process.env.MOCK_OAUTH_TOKEN_TTL ?? 3600);
|
|
|
|
interface Grant { password: string }
|
|
const codes = new Map<string, { challenge: string; redirectUri: string; issuedAt: number }>();
|
|
const accessTokens = new Map<string, Grant & { expiresAt: number }>();
|
|
const refreshTokens = new Map<string, Grant>();
|
|
|
|
const base = () => `http://127.0.0.1:${PORT}`;
|
|
|
|
/** For tests: how long new access tokens last, and a way to end every token. */
|
|
export const oauthMock = {
|
|
setAccessTokenTtl(seconds: number) { accessTokenTtl = seconds; },
|
|
expireAccessTokens() { for (const t of accessTokens.values()) t.expiresAt = 0; },
|
|
reset() { codes.clear(); accessTokens.clear(); refreshTokens.clear(); accessTokenTtl = 3600; },
|
|
};
|
|
|
|
/** A bearer token the mock issued, still valid under the current password. */
|
|
export function checkBearer(header: string): boolean {
|
|
if (!header.startsWith("Bearer ")) return false;
|
|
const t = accessTokens.get(header.slice(7));
|
|
return Boolean(t && t.expiresAt > Date.now() && t.password === account.password);
|
|
}
|
|
|
|
function json(res: ServerResponse, status: number, body: unknown) {
|
|
res.writeHead(status, { "content-type": "application/json", "cache-control": "no-store" });
|
|
res.end(JSON.stringify(body));
|
|
}
|
|
|
|
function readForm(req: IncomingMessage): Promise<URLSearchParams> {
|
|
return new Promise((resolve) => {
|
|
const chunks: Buffer[] = [];
|
|
req.on("data", (c) => chunks.push(c));
|
|
req.on("end", () => resolve(new URLSearchParams(Buffer.concat(chunks).toString())));
|
|
});
|
|
}
|
|
|
|
function issue(res: ServerResponse, refresh: string | null) {
|
|
const access = `mock-at-${randomBytes(16).toString("hex")}`;
|
|
accessTokens.set(access, { password: account.password, expiresAt: Date.now() + accessTokenTtl * 1000 });
|
|
const body: Record<string, unknown> = { access_token: access, token_type: "bearer", expires_in: accessTokenTtl };
|
|
if (!refresh) {
|
|
const fresh = `mock-rt-${randomBytes(16).toString("hex")}`;
|
|
refreshTokens.set(fresh, { password: account.password });
|
|
body.refresh_token = fresh;
|
|
}
|
|
return json(res, 200, body);
|
|
}
|
|
|
|
/** Handles the OAuth routes; false for anything else. */
|
|
export async function handleOAuth(req: IncomingMessage, res: ServerResponse, url: URL): Promise<boolean> {
|
|
if (url.pathname === "/.well-known/oauth-authorization-server" && req.method === "GET") {
|
|
json(res, 200, {
|
|
issuer: base(),
|
|
authorization_endpoint: `${base()}/login`,
|
|
token_endpoint: `${base()}/auth/token`,
|
|
grant_types_supported: ["authorization_code", "refresh_token"],
|
|
response_types_supported: ["code"],
|
|
scopes_supported: ["openid", "offline_access"],
|
|
token_endpoint_auth_methods_supported: ["client_secret_post"],
|
|
code_challenge_methods_supported: ["S256"],
|
|
});
|
|
return true;
|
|
}
|
|
if (url.pathname === "/login" && req.method === "GET") {
|
|
const q = url.searchParams;
|
|
const redirectUri = q.get("redirect_uri") ?? "";
|
|
if (q.get("client_id") !== OAUTH_CLIENT_ID || q.get("response_type") !== "code" || !redirectUri || q.get("code_challenge_method") !== "S256") {
|
|
json(res, 400, { error: "invalid_request" });
|
|
return true;
|
|
}
|
|
const code = randomBytes(16).toString("hex");
|
|
codes.set(code, { challenge: q.get("code_challenge") ?? "", redirectUri, issuedAt: Date.now() });
|
|
const back = new URL(redirectUri);
|
|
back.searchParams.set("code", code);
|
|
back.searchParams.set("state", q.get("state") ?? "");
|
|
res.writeHead(302, { location: back.toString() });
|
|
res.end();
|
|
return true;
|
|
}
|
|
if (url.pathname === "/auth/token" && req.method === "POST") {
|
|
const form = await readForm(req);
|
|
if (form.get("client_id") !== OAUTH_CLIENT_ID || form.get("client_secret") !== OAUTH_CLIENT_SECRET) {
|
|
json(res, 400, { error: "invalid_client" });
|
|
return true;
|
|
}
|
|
if (form.get("grant_type") === "authorization_code") {
|
|
const code = codes.get(form.get("code") ?? "");
|
|
codes.delete(form.get("code") ?? "");
|
|
const verifier = form.get("code_verifier") ?? "";
|
|
const challenge = createHash("sha256").update(verifier).digest("base64url");
|
|
if (!code || code.challenge !== challenge || code.redirectUri !== form.get("redirect_uri") || Date.now() - code.issuedAt > 600_000) {
|
|
json(res, 400, { error: "invalid_grant" });
|
|
return true;
|
|
}
|
|
issue(res, null);
|
|
return true;
|
|
}
|
|
if (form.get("grant_type") === "refresh_token") {
|
|
const refresh = form.get("refresh_token") ?? "";
|
|
const grant = refreshTokens.get(refresh);
|
|
if (!grant || grant.password !== account.password) {
|
|
json(res, 400, { error: "invalid_grant" });
|
|
return true;
|
|
}
|
|
issue(res, refresh);
|
|
return true;
|
|
}
|
|
json(res, 400, { error: "unsupported_grant_type" });
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/** The username the mock signs in, for tests. */
|
|
export const OAUTH_USER = USER;
|