Sign in on the mail server's own page (OAuth with PKCE), sessions hold tokens; tenants on every edition
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.
This commit is contained in:
+192
-3
@@ -41,6 +41,7 @@ import {
|
||||
revokeAppPassword,
|
||||
} from "./account.js";
|
||||
import { imageProxyHandler } from "./imageproxy.js";
|
||||
import { SignInError, finish as finishSignIn, needsRefresh, oauthEnabled, refreshTokens, start as startSignIn, type TokenSet } from "./oauth.js";
|
||||
import { icsProxyHandler } from "./icsproxy.js";
|
||||
import { staticHandler } from "./static.js";
|
||||
|
||||
@@ -199,6 +200,9 @@ function compressResponses(basePath: string): MiddlewareHandler {
|
||||
}
|
||||
|
||||
const csrfGuard: MiddlewareHandler = async (c, next) => {
|
||||
// The mail server's sign-in page sends the browser back here, so this one
|
||||
// arrives cross-site by design. Its state, bound to a cookie, stands in.
|
||||
if (c.req.method === "GET" && c.req.path.endsWith("/api/auth/callback")) return next();
|
||||
const site = c.req.header("sec-fetch-site");
|
||||
if (site && site !== "same-origin" && site !== "none") {
|
||||
return c.json({ error: "cross_site_request" }, 403);
|
||||
@@ -229,7 +233,20 @@ const smallBodies: MiddlewareHandler = (c, next) => (LARGE_BODY_ROUTE.test(c.req
|
||||
|
||||
const requireSession: MiddlewareHandler<Env> = async (c, next) => {
|
||||
const cookie = getCookie(c, config.cookieName);
|
||||
const session = sessions.resolve(cookie);
|
||||
let session = sessions.resolve(cookie);
|
||||
if (session?.tokens && needsRefresh(session.tokens)) {
|
||||
try {
|
||||
session = await refreshSession(cookie!, session);
|
||||
} catch (err) {
|
||||
// Couldn't ask the server. The token may still have a few minutes; if
|
||||
// not, the call itself will say so.
|
||||
console.warn("[ihasmail] token refresh failed:", (err as Error).message);
|
||||
}
|
||||
if (!session) {
|
||||
deleteCookie(c, config.cookieName, { path: cookiePath });
|
||||
return c.json({ error: "unauthenticated" }, 401);
|
||||
}
|
||||
}
|
||||
if (!session) {
|
||||
return c.json({ error: "unauthenticated" }, 401);
|
||||
}
|
||||
@@ -237,6 +254,55 @@ const requireSession: MiddlewareHandler<Env> = async (c, next) => {
|
||||
await next();
|
||||
};
|
||||
|
||||
/*
|
||||
* One refresh per session at a time: a page opening does several requests at
|
||||
* once, and each would otherwise renew the same token.
|
||||
*/
|
||||
const refreshing = new Map<string, Promise<LiveSession | null>>();
|
||||
|
||||
/**
|
||||
* Renew an OAuth session's access token and keep the new one. Null when the
|
||||
* server refused the refresh token (a password change revokes it), which
|
||||
* ends the session.
|
||||
*/
|
||||
function refreshSession(cookie: string, session: LiveSession): Promise<LiveSession | null> {
|
||||
let inFlight = refreshing.get(session.id);
|
||||
if (!inFlight) {
|
||||
inFlight = (async () => {
|
||||
const renewed = await refreshTokens(upstreamFor(session.username), session.tokens!);
|
||||
if (!renewed) {
|
||||
sessions.destroy(session.id);
|
||||
forgetUpstreamSession(session.id);
|
||||
return null;
|
||||
}
|
||||
sessions.updateTokens(cookie, renewed);
|
||||
return sessions.resolve(cookie);
|
||||
})().finally(() => refreshing.delete(session.id));
|
||||
refreshing.set(session.id, inFlight);
|
||||
}
|
||||
return inFlight;
|
||||
}
|
||||
|
||||
/**
|
||||
* What push keeps to renew an account's subscription long after the session
|
||||
* that started it. A password is good until it changes; OAuth tokens get a
|
||||
* copy that renews itself, since push outlives any one access token.
|
||||
*/
|
||||
export function pushCredential(session: LiveSession): { get(): Promise<string> } {
|
||||
if (!session.tokens) {
|
||||
const authorization = session.authorization;
|
||||
return { get: async () => authorization };
|
||||
}
|
||||
let tokens: TokenSet = session.tokens;
|
||||
const base = upstreamFor(session.username);
|
||||
return {
|
||||
async get() {
|
||||
if (needsRefresh(tokens)) tokens = (await refreshTokens(base, tokens)) ?? tokens;
|
||||
return `Bearer ${tokens.access}`;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope the session cookie to the mount, not the whole host.
|
||||
*
|
||||
@@ -316,11 +382,85 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
||||
/* Sent before sign-in like the rest of this: it says what the
|
||||
installation has decided, not anything about who is asking. */
|
||||
settingsPolicy: config.settingsPolicy,
|
||||
/* "oauth": sign in on the mail server's own page (see oauth.ts). */
|
||||
signIn: oauthEnabled() ? "oauth" : "password",
|
||||
}),
|
||||
);
|
||||
|
||||
// ---------- Auth ----------
|
||||
/*
|
||||
* Sign-in through the mail server's page. `start` sends the browser there;
|
||||
* `callback` is where the server sends it back. See oauth.ts.
|
||||
*/
|
||||
const OAUTH_STATE_COOKIE = `${config.cookieName}_signin`;
|
||||
|
||||
api.get("/auth/oauth/start", async (c) => {
|
||||
if (!oauthEnabled()) return c.json({ error: "not_found" }, 404);
|
||||
const rateIp = rateLimitKey(clientIp(c));
|
||||
if (!loginFloodLimiter.check(rateIp)) {
|
||||
c.header("Retry-After", String(loginFloodLimiter.retryAfterSeconds(rateIp)));
|
||||
return c.redirect(`${basePath}/?signin_error=rate_limited`, 302);
|
||||
}
|
||||
const username = (c.req.query("username") ?? "").trim().slice(0, 320);
|
||||
try {
|
||||
const { location, state } = await startSignIn({ username, base: upstreamFor(username), remember: c.req.query("remember") === "1" });
|
||||
setCookie(c, OAUTH_STATE_COOKIE, state, { httpOnly: true, sameSite: "Lax", secure: isSecureRequest(c), path: `${basePath}/api/auth`, maxAge: 600 });
|
||||
return c.redirect(location, 302);
|
||||
} catch (err) {
|
||||
console.warn("[ihasmail] could not start sign-in:", (err as Error).message);
|
||||
return c.redirect(`${basePath}/?signin_error=unavailable`, 302);
|
||||
}
|
||||
});
|
||||
|
||||
api.get("/auth/callback", async (c) => {
|
||||
if (!oauthEnabled()) return c.json({ error: "not_found" }, 404);
|
||||
const boundState = getCookie(c, OAUTH_STATE_COOKIE);
|
||||
deleteCookie(c, OAUTH_STATE_COOKIE, { path: `${basePath}/api/auth` });
|
||||
const fail = (code: string) => c.redirect(`${basePath}/?signin_error=${code}`, 302);
|
||||
const rateIp = rateLimitKey(clientIp(c));
|
||||
if (!loginFloodLimiter.check(rateIp)) return fail("rate_limited");
|
||||
const state = c.req.query("state") ?? "";
|
||||
const code = c.req.query("code") ?? "";
|
||||
// The server's page sends `error` when the person cancels or is refused.
|
||||
if (!code || c.req.query("error")) return fail("cancelled");
|
||||
let result;
|
||||
try {
|
||||
result = await finishSignIn({ state, boundState, code });
|
||||
} catch (err) {
|
||||
if (err instanceof SignInError) return fail(err.code);
|
||||
console.warn("[ihasmail] sign-in exchange failed:", (err as Error).message);
|
||||
return fail("unavailable");
|
||||
}
|
||||
const authorization = `Bearer ${result.tokens.access}`;
|
||||
try {
|
||||
const upstream = await fetchUpstreamSession(authorization, result.base);
|
||||
if (!hasStalwartRegistry(upstream)) return fail("unsupported_server");
|
||||
const username = upstream.username || result.username;
|
||||
// Every later call finds the account's server from its name. If the
|
||||
// server signed in an account that routes elsewhere, calls would go to
|
||||
// the wrong server, so refuse it.
|
||||
if (upstreamFor(username) !== result.base) return fail("wrong_account");
|
||||
const { cookie, session } = sessions.create({
|
||||
username,
|
||||
account: accountKey(result.base, username),
|
||||
tokens: result.tokens,
|
||||
remember: result.remember,
|
||||
userAgent: c.req.header("user-agent") ?? "",
|
||||
ip: clientIp(c),
|
||||
});
|
||||
setSessionCookie(c, cookie, session.remember);
|
||||
const mailAccount = upstream.primaryAccounts?.["urn:ietf:params:jmap:mail"];
|
||||
if (mailAccount) pushPrepare(session.username, mailAccount, pushCredential(session));
|
||||
return c.redirect(`${basePath}/`, 302);
|
||||
} catch (err) {
|
||||
console.warn("[ihasmail] sign-in failed after the exchange:", (err as Error).message);
|
||||
return fail("unavailable");
|
||||
}
|
||||
});
|
||||
|
||||
api.post("/auth/login", async (c) => {
|
||||
// With sign-in on the mail server's page, this form never sees a password.
|
||||
if (oauthEnabled()) return c.json({ error: "oauth_required", message: "Sign in on the mail server's page." }, 403);
|
||||
const ip = clientIp(c);
|
||||
// What the limits count under: the address, or its /64 for IPv6.
|
||||
const rateIp = rateLimitKey(ip);
|
||||
@@ -396,7 +536,7 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
||||
// Start the account's push subscription now, so it is usually verified
|
||||
// by the time the browser opens its stream. See push.ts.
|
||||
const mailAccount = upstream.primaryAccounts?.["urn:ietf:params:jmap:mail"];
|
||||
if (mailAccount) pushPrepare(session.username, mailAccount, session.authorization);
|
||||
if (mailAccount) pushPrepare(session.username, mailAccount, pushCredential(session));
|
||||
const info = await getAccountInfo(session.id, session.authorization, upstream);
|
||||
return c.json(localizeSession(upstream, sessionExtras(session, info)));
|
||||
} catch (err) {
|
||||
@@ -535,6 +675,14 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
||||
} catch (err) {
|
||||
return accountFailure(c, err);
|
||||
}
|
||||
if (session.tokens) {
|
||||
// The server revokes every token when the password changes, this
|
||||
// session's included, so there is nothing to keep: sign in again.
|
||||
forgetUpstreamSession(session.id);
|
||||
const revoked = sessions.destroyAllForUser(session.account);
|
||||
deleteCookie(c, config.cookieName, { path: cookiePath });
|
||||
return c.json({ ok: true, revokedSessions: revoked - 1, signedOut: true });
|
||||
}
|
||||
// The old password is now dead: re-seal this session with the new one and
|
||||
// drop the others, whose sealed copies would fail on their next call.
|
||||
const otpCode = body.otpCode?.trim();
|
||||
@@ -625,6 +773,16 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
||||
} catch (err) {
|
||||
return accountFailure(c, err);
|
||||
}
|
||||
if (session.tokens) {
|
||||
// Signed in on the server's page, where two-factor is asked for, so
|
||||
// nothing here needs moving onto an app password.
|
||||
try {
|
||||
await enableOtp(ctx, { url: body.url, code, current: body.current });
|
||||
} catch (err) {
|
||||
return accountFailure(c, err);
|
||||
}
|
||||
return c.json({ ok: true, ...(await afterCredentialChange(c, session)) });
|
||||
}
|
||||
let app: { id: string; secret: string } | null = null;
|
||||
try {
|
||||
app = await createAppPassword(ctx, { description: appPasswordName(c) });
|
||||
@@ -663,6 +821,7 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
||||
} catch (err) {
|
||||
return accountFailure(c, err);
|
||||
}
|
||||
if (session.tokens) return c.json({ ok: true, ...(await afterCredentialChange(c, session)) });
|
||||
// This session may be running on the app password minted when 2FA went on;
|
||||
// the plain password works again now, so put it back.
|
||||
sessions.reseal(getCookie(c, config.cookieName), body.current);
|
||||
@@ -880,7 +1039,7 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
||||
// relay, and is moved to fan-out the moment the account verifies.
|
||||
const accountId = upstream.primaryAccounts?.["urn:ietf:params:jmap:mail"];
|
||||
const out = (c.env as { outgoing: import("node:http").ServerResponse }).outgoing;
|
||||
if (accountId && pushAttach(session.username, accountId, session.authorization, out)) {
|
||||
if (accountId && pushAttach(session.username, accountId, pushCredential(session), out)) {
|
||||
out.writeHead(200, SSE_HEADERS);
|
||||
out.flushHeaders();
|
||||
out.write(": subscribed\n\n");
|
||||
@@ -957,6 +1116,16 @@ async function readJson<T>(c: Context): Promise<T | null> {
|
||||
* server.
|
||||
*/
|
||||
async function confirmsPassword(session: LiveSession, candidate: string): Promise<boolean> {
|
||||
if (session.tokens) {
|
||||
// Holding no password, the only judge is the server.
|
||||
try {
|
||||
const authorization = `Basic ${Buffer.from(`${session.username}:${candidate}`, "utf8").toString("base64")}`;
|
||||
await fetchUpstreamSession(authorization, upstreamFor(session.username));
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
const decoded = Buffer.from(session.authorization.replace(/^Basic /, ""), "base64").toString("utf8");
|
||||
const held = decoded.slice(decoded.indexOf(":") + 1);
|
||||
if (safeEqual(held, candidate)) return true;
|
||||
@@ -974,6 +1143,24 @@ async function confirmsPassword(session: LiveSession, candidate: string): Promis
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* After a two-factor change on a token session: whether the server still
|
||||
* honors this session's token. If it revoked it, end the session here too,
|
||||
* so the web app can send the person to sign in again.
|
||||
*/
|
||||
async function afterCredentialChange(c: Context<Env>, session: LiveSession): Promise<{ signedOut: boolean }> {
|
||||
forgetUpstreamSession(session.id);
|
||||
try {
|
||||
await fetchUpstreamSession(session.authorization, upstreamFor(session.username));
|
||||
return { signedOut: false };
|
||||
} catch (err) {
|
||||
if (!(err instanceof UpstreamError && err.status === 401)) return { signedOut: false };
|
||||
sessions.destroyAllForUser(session.account);
|
||||
deleteCookie(c, config.cookieName, { path: cookiePath });
|
||||
return { signedOut: true };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Direction overrides and isolates, which can make `Invoice_\u202Efdp.exe`
|
||||
* read as a PDF in the downloads list. A filename has no use for them.
|
||||
@@ -999,6 +1186,8 @@ function sessionExtras(session: LiveSession, info: AccountInfo = { locale: null,
|
||||
sessionId: session.id,
|
||||
loginName: session.username,
|
||||
remember: session.remember,
|
||||
/** "oauth": signed in on the mail server's page, holding tokens, not a password. */
|
||||
signIn: session.tokens ? "oauth" : "password",
|
||||
/** Locale configured for the account in Stalwart's directory, if readable. */
|
||||
userLocale: info.locale,
|
||||
/**
|
||||
|
||||
@@ -262,6 +262,20 @@ function httpUrl(raw: string, where: string): string {
|
||||
|
||||
const stalwartServers = readStalwartServers();
|
||||
|
||||
/*
|
||||
* Signing in through the mail server's own page. On when OAUTH_CLIENT_SECRET
|
||||
* is set: the secret of the confidential client the server registers for this
|
||||
* webmail (INBUXA registers `ihasmail-inbuxa` from INBUXA_WEBMAIL_URL and
|
||||
* INBUXA_WEBMAIL_CLIENT_SECRET). PUBLIC_URL is where browsers reach ihasmail,
|
||||
* without BASE_PATH; the redirect URI is built from it and must match the one
|
||||
* registered exactly.
|
||||
*/
|
||||
const oauthClientSecret = process.env.OAUTH_CLIENT_SECRET ?? "";
|
||||
const publicUrl = process.env.PUBLIC_URL ? httpUrl(process.env.PUBLIC_URL, "PUBLIC_URL") : "";
|
||||
if (oauthClientSecret && !publicUrl) {
|
||||
throw new Error("OAUTH_CLIENT_SECRET is set but PUBLIC_URL is not: the sign-in redirect needs ihasmail's public address");
|
||||
}
|
||||
|
||||
export const config = {
|
||||
isProd,
|
||||
appName: env("APP_NAME", "ihasmail"),
|
||||
@@ -314,6 +328,10 @@ export const config = {
|
||||
* should not suggest they come without the license.
|
||||
*/
|
||||
showEnterpriseNotices: bool("SHOW_ENTERPRISE_NOTICES", false),
|
||||
/** See the note above `config`. Empty keeps the password form. */
|
||||
oauthClientSecret,
|
||||
oauthClientId: env("OAUTH_CLIENT_ID", "ihasmail-inbuxa"),
|
||||
publicUrl,
|
||||
appSecret,
|
||||
trustProxy: bool("TRUST_PROXY", true),
|
||||
/**
|
||||
|
||||
@@ -14,6 +14,7 @@ import { MAX_OBJECTS, MethodError, directory, enforceLimits, resolveRefs } from
|
||||
import { handlers } from "./handlers.js";
|
||||
export { account } from "./config.js";
|
||||
import { checkOtp } from "./auth.js";
|
||||
import { checkBearer, handleOAuth } from "./oauth.js";
|
||||
import { sseClients, broadcast } from "./events.js";
|
||||
|
||||
/* ---------- http ---------- */
|
||||
@@ -24,6 +25,7 @@ function unauthorized(res: ServerResponse) {
|
||||
|
||||
function checkAuth(req: IncomingMessage): boolean {
|
||||
const h = req.headers.authorization ?? "";
|
||||
if (checkBearer(h)) return true;
|
||||
if (!h.startsWith("Basic ")) return false;
|
||||
const raw = Buffer.from(h.slice(6), "base64").toString();
|
||||
const sep = raw.indexOf(":");
|
||||
@@ -77,6 +79,7 @@ const session = () => ({
|
||||
/** Exported so tests can drive the mock in-process and shut it down. */
|
||||
export const server = createServer(async (req, res) => {
|
||||
const url = new URL(req.url ?? "/", `http://127.0.0.1:${PORT}`);
|
||||
if (await handleOAuth(req, res, url)) return;
|
||||
if (!checkAuth(req)) return unauthorized(res);
|
||||
if (url.pathname === "/.well-known/jmap" || url.pathname === "/jmap/session") {
|
||||
res.writeHead(200, { "content-type": "application/json" });
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* 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;
|
||||
@@ -0,0 +1,205 @@
|
||||
import { test, before, after, beforeEach } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
/**
|
||||
* Signing in on the mail server's own page, end to end against the mock's
|
||||
* OAuth side: the redirect out, the callback, the session holding tokens
|
||||
* instead of a password, token renewal, and what ends a session.
|
||||
*/
|
||||
|
||||
const PORT = 18811;
|
||||
process.env.MOCK_PORT = String(PORT);
|
||||
process.env.MOCK_USER = "[email protected]";
|
||||
process.env.MOCK_PASS = "demo-password";
|
||||
process.env.STALWART_URL = `http://127.0.0.1:${PORT}`;
|
||||
process.env.APP_SECRET = "test-secret-for-oauth";
|
||||
process.env.OAUTH_CLIENT_SECRET = "mock-oauth-secret";
|
||||
process.env.PUBLIC_URL = "https://webmail.example.test";
|
||||
|
||||
const mock = await import("./mock/index.js");
|
||||
const { oauthMock } = await import("./mock/oauth.js");
|
||||
const { createApp, pushCredential, sessions } = await import("./app.js");
|
||||
const { resetOAuthState } = await import("./oauth.js");
|
||||
|
||||
const app = createApp();
|
||||
const CALLBACK = "https://webmail.example.test/api/auth/callback";
|
||||
|
||||
/** A cookie jar, since sign-in sets two cookies on different paths. */
|
||||
let jar = new Map<string, string>();
|
||||
|
||||
function keepCookies(res: Response) {
|
||||
for (const header of res.headers.getSetCookie()) {
|
||||
const [pair, ...attrs] = header.split(";");
|
||||
const [name, value] = [pair!.slice(0, pair!.indexOf("=")), pair!.slice(pair!.indexOf("=") + 1)];
|
||||
const expired = attrs.some((a) => /max-age=0\b/i.test(a.trim()) || /expires=thu, 01 jan 1970/i.test(a.trim()));
|
||||
if (expired || value === "") jar.delete(name);
|
||||
else jar.set(name, value);
|
||||
}
|
||||
}
|
||||
|
||||
async function call(path: string, init: RequestInit = {}) {
|
||||
const cookie = [...jar].map(([k, v]) => `${k}=${v}`).join("; ");
|
||||
const res = await app.request(path, {
|
||||
...init,
|
||||
headers: { "content-type": "application/json", "x-requested-with": "ihasmail", ...(cookie ? { cookie } : {}), ...(init.headers as Record<string, string>) },
|
||||
});
|
||||
keepCookies(res);
|
||||
return res;
|
||||
}
|
||||
|
||||
async function jsonOf(res: Response) {
|
||||
const text = await res.text();
|
||||
return text ? JSON.parse(text) : null;
|
||||
}
|
||||
|
||||
/** Leave for the server's page and come back: returns the callback URL. */
|
||||
async function goToServerAndBack(username = "[email protected]"): Promise<URL> {
|
||||
const start = await call(`/api/auth/oauth/start?username=${encodeURIComponent(username)}&remember=1`);
|
||||
assert.equal(start.status, 302);
|
||||
const signInPage = new URL(start.headers.get("location")!);
|
||||
const approved = await fetch(signInPage, { redirect: "manual" });
|
||||
assert.equal(approved.status, 302, "the mock's page approves the demo user");
|
||||
return new URL(approved.headers.get("location")!);
|
||||
}
|
||||
|
||||
async function signIn() {
|
||||
const back = await goToServerAndBack();
|
||||
const res = await call(`/api/auth/callback${back.search}`);
|
||||
assert.equal(res.status, 302);
|
||||
assert.equal(res.headers.get("location"), "/");
|
||||
assert.ok(jar.get("ihm_session"), "a session cookie was set");
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jar = new Map();
|
||||
oauthMock.reset();
|
||||
resetOAuthState();
|
||||
});
|
||||
|
||||
before(() => {});
|
||||
after(() => {
|
||||
(mock as { server?: { close(): void } }).server?.close();
|
||||
});
|
||||
|
||||
test("the configuration tells the web app to use the server's page", async () => {
|
||||
const body = await jsonOf(await call("/api/config"));
|
||||
assert.equal(body.signIn, "oauth");
|
||||
});
|
||||
|
||||
test("the password form is refused: ihasmail never sees a password", async () => {
|
||||
const res = await call("/api/auth/login", { method: "POST", body: JSON.stringify({ username: "[email protected]", password: "demo-password" }) });
|
||||
assert.equal(res.status, 403);
|
||||
assert.equal((await jsonOf(res)).error, "oauth_required");
|
||||
});
|
||||
|
||||
test("start sends the browser to the server's page with PKCE and a bound state", async () => {
|
||||
const res = await call("/api/auth/oauth/[email protected]");
|
||||
assert.equal(res.status, 302);
|
||||
const to = new URL(res.headers.get("location")!);
|
||||
assert.equal(`${to.origin}${to.pathname}`, `http://127.0.0.1:${PORT}/login`);
|
||||
assert.equal(to.searchParams.get("client_id"), "ihasmail-inbuxa");
|
||||
assert.equal(to.searchParams.get("redirect_uri"), CALLBACK);
|
||||
assert.equal(to.searchParams.get("response_type"), "code");
|
||||
assert.equal(to.searchParams.get("code_challenge_method"), "S256");
|
||||
assert.match(to.searchParams.get("code_challenge") ?? "", /^[\w-]{43}$/);
|
||||
assert.equal(to.searchParams.get("login_hint"), "[email protected]");
|
||||
assert.equal(to.searchParams.get("scope"), "openid offline_access");
|
||||
assert.equal(jar.get("ihm_session_signin"), to.searchParams.get("state"), "the state is bound to this browser");
|
||||
});
|
||||
|
||||
test("a full sign-in holds tokens, and the session works", async () => {
|
||||
await signIn();
|
||||
const res = await call("/api/auth/session");
|
||||
assert.equal(res.status, 200);
|
||||
const body = await jsonOf(res);
|
||||
assert.equal(body.ihasmail.loginName, "[email protected]");
|
||||
assert.equal(jar.get("ihm_session_signin"), undefined, "the state cookie is cleared");
|
||||
});
|
||||
|
||||
test("the callback comes back cross-site, and is still accepted", async () => {
|
||||
const back = await goToServerAndBack();
|
||||
const res = await call(`/api/auth/callback${back.search}`, { headers: { "sec-fetch-site": "cross-site" } });
|
||||
assert.equal(res.headers.get("location"), "/");
|
||||
});
|
||||
|
||||
test("a callback from a sign-in this browser didn't start is refused", async () => {
|
||||
const back = await goToServerAndBack();
|
||||
jar.delete("ihm_session_signin");
|
||||
const res = await call(`/api/auth/callback${back.search}`);
|
||||
assert.equal(res.headers.get("location"), "/?signin_error=state_mismatch");
|
||||
assert.equal(jar.get("ihm_session"), undefined);
|
||||
});
|
||||
|
||||
test("a state is good for one attempt", async () => {
|
||||
const back = await goToServerAndBack();
|
||||
const state = jar.get("ihm_session_signin")!;
|
||||
await call(`/api/auth/callback${back.search}`);
|
||||
jar = new Map([["ihm_session_signin", state]]);
|
||||
const again = await call(`/api/auth/callback${back.search}`);
|
||||
assert.equal(again.headers.get("location"), "/?signin_error=state_mismatch");
|
||||
});
|
||||
|
||||
test("cancelling on the server's page comes back as an error, not a session", async () => {
|
||||
await call("/api/auth/oauth/[email protected]");
|
||||
const state = jar.get("ihm_session_signin")!;
|
||||
const res = await call(`/api/auth/callback?error=access_denied&state=${state}`);
|
||||
assert.equal(res.headers.get("location"), "/?signin_error=cancelled");
|
||||
assert.equal(jar.get("ihm_session"), undefined);
|
||||
});
|
||||
|
||||
test("a code the server won't exchange is refused", async () => {
|
||||
const back = await goToServerAndBack();
|
||||
back.searchParams.set("code", "not-a-code");
|
||||
const res = await call(`/api/auth/callback${back.search}`);
|
||||
assert.equal(res.headers.get("location"), "/?signin_error=exchange_failed");
|
||||
});
|
||||
|
||||
test("an access token about to expire is renewed without the person noticing", async () => {
|
||||
oauthMock.setAccessTokenTtl(60); // inside the renewal margin from the start
|
||||
await signIn();
|
||||
oauthMock.expireAccessTokens(); // the one the session holds is now dead upstream
|
||||
const res = await call("/api/auth/session?refresh=1");
|
||||
assert.equal(res.status, 200, "renewed before the call went upstream");
|
||||
});
|
||||
|
||||
test("renewal refused by the server ends the session", async () => {
|
||||
await signIn();
|
||||
const cookie = jar.get("ihm_session")!;
|
||||
const session = sessions.resolve(cookie)!;
|
||||
// Pretend the token is about to expire, then make the server refuse to renew it.
|
||||
sessions.updateTokens(cookie, { ...session.tokens!, expiresAt: Date.now() + 1000, refresh: "revoked" });
|
||||
const res = await call("/api/auth/session");
|
||||
assert.equal(res.status, 401);
|
||||
assert.equal(jar.get("ihm_session"), undefined, "and the cookie is cleared");
|
||||
});
|
||||
|
||||
test("a password change signs the session out, since the server revokes its tokens", async () => {
|
||||
await signIn();
|
||||
const res = await call("/api/account/password", { method: "POST", body: JSON.stringify({ current: "demo-password", next: "new-password-123" }) });
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal((await jsonOf(res)).signedOut, true);
|
||||
assert.equal((await call("/api/auth/session")).status, 401);
|
||||
// Put it back for the tests after this one.
|
||||
const { account } = await import("./mock/config.js");
|
||||
account.password = "demo-password";
|
||||
});
|
||||
|
||||
test("creating an app password checks the typed password with the server", async () => {
|
||||
await signIn();
|
||||
const wrong = await call("/api/account/app-passwords", { method: "POST", body: JSON.stringify({ description: "Phone", current: "nope" }) });
|
||||
assert.equal(wrong.status, 403);
|
||||
const right = await call("/api/account/app-passwords", { method: "POST", body: JSON.stringify({ description: "Phone", current: "demo-password" }) });
|
||||
assert.equal(right.status, 200);
|
||||
});
|
||||
|
||||
test("push keeps a credential that renews itself", async () => {
|
||||
oauthMock.setAccessTokenTtl(60);
|
||||
await signIn();
|
||||
const session = sessions.resolve(jar.get("ihm_session"))!;
|
||||
const credential = pushCredential(session);
|
||||
const first = await credential.get();
|
||||
oauthMock.expireAccessTokens();
|
||||
const second = await credential.get();
|
||||
assert.notEqual(second, first, "a fresh access token");
|
||||
assert.match(second, /^Bearer mock-at-/);
|
||||
});
|
||||
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* Signing in through the mail server's own page (OAuth 2.0 authorization code
|
||||
* with PKCE), so ihasmail never handles a password to sign someone in.
|
||||
*
|
||||
* The flow, with ihasmail as a confidential client registered on the server:
|
||||
*
|
||||
* 1. `start()` picks the account's server from the username, reads the
|
||||
* server's OAuth metadata, and sends the browser to its sign-in page with
|
||||
* a PKCE challenge and a one-time `state`. The state is bound to the
|
||||
* browser by a short-lived cookie, so a callback carrying somebody else's
|
||||
* code can't sign this browser into their account.
|
||||
* 2. The person signs in there, two-factor included, and the server sends the
|
||||
* browser back to `/api/auth/callback` with a code.
|
||||
* 3. `finish()` checks the state, exchanges the code (with the PKCE verifier
|
||||
* and this client's secret) for an access and a refresh token, and the
|
||||
* session keeps those, sealed, instead of a password.
|
||||
*
|
||||
* Access tokens last an hour; `refreshTokens()` renews them before they run
|
||||
* out. A password change on the server revokes both tokens, which ends every
|
||||
* session holding them -- the safe result, and the one the web app is told
|
||||
* about.
|
||||
*
|
||||
* Nothing here is taken from another client's implementation; the shapes are
|
||||
* RFC 6749, RFC 7636 and RFC 8414.
|
||||
*/
|
||||
import { createHash } from "node:crypto";
|
||||
import { config } from "./config.js";
|
||||
import { randomToken } from "./crypto.js";
|
||||
import { UpstreamError, absoluteUpstream } from "./upstream.js";
|
||||
|
||||
export interface TokenSet {
|
||||
access: string;
|
||||
refresh: string | null;
|
||||
/** When the access token expires, in ms since the epoch. */
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
interface Metadata {
|
||||
authorizationEndpoint: string;
|
||||
tokenEndpoint: string;
|
||||
scopes: string[];
|
||||
}
|
||||
|
||||
/** Renew an access token this long before it expires. */
|
||||
export const REFRESH_MARGIN_MS = 5 * 60_000;
|
||||
/** How long a sign-in may take between leaving and coming back. */
|
||||
const PENDING_TTL_MS = 10 * 60_000;
|
||||
const METADATA_TTL_MS = 60 * 60_000;
|
||||
const MAX_PENDING = 10_000;
|
||||
|
||||
export function oauthEnabled(): boolean {
|
||||
return Boolean(config.oauthClientSecret);
|
||||
}
|
||||
|
||||
/** The one redirect URI registered for this client on the server. */
|
||||
export function redirectUri(): string {
|
||||
return `${config.publicUrl}${config.basePath}/api/auth/callback`;
|
||||
}
|
||||
|
||||
const metadataCache = new Map<string, { metadata: Metadata; fetchedAt: number }>();
|
||||
|
||||
async function metadataFor(base: string): Promise<Metadata> {
|
||||
const cached = metadataCache.get(base);
|
||||
if (cached && Date.now() - cached.fetchedAt < METADATA_TTL_MS) return cached.metadata;
|
||||
const res = await fetch(`${base}/.well-known/oauth-authorization-server`, {
|
||||
headers: { accept: "application/json" },
|
||||
signal: AbortSignal.timeout(config.upstreamTimeout),
|
||||
});
|
||||
if (!res.ok) throw new UpstreamError(`OAuth metadata request failed (${res.status})`, 502);
|
||||
const doc = (await res.json()) as { authorization_endpoint?: string; token_endpoint?: string; scopes_supported?: string[] };
|
||||
if (!doc.authorization_endpoint || !doc.token_endpoint) {
|
||||
throw new UpstreamError("The mail server's OAuth metadata has no authorization or token endpoint", 502);
|
||||
}
|
||||
const metadata = {
|
||||
// Where the *browser* goes, so the server's public address, as advertised.
|
||||
authorizationEndpoint: new URL(doc.authorization_endpoint, base).toString(),
|
||||
// Where this process goes, so the configured route, like every other call.
|
||||
tokenEndpoint: absoluteUpstream(doc.token_endpoint, base),
|
||||
scopes: doc.scopes_supported ?? [],
|
||||
};
|
||||
metadataCache.set(base, { metadata, fetchedAt: Date.now() });
|
||||
return metadata;
|
||||
}
|
||||
|
||||
interface Pending {
|
||||
verifier: string;
|
||||
base: string;
|
||||
username: string;
|
||||
remember: boolean;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
const pending = new Map<string, Pending>();
|
||||
|
||||
function sweepPending(now = Date.now()) {
|
||||
for (const [state, p] of pending) if (now - p.createdAt > PENDING_TTL_MS) pending.delete(state);
|
||||
}
|
||||
|
||||
function challengeOf(verifier: string): string {
|
||||
return createHash("sha256").update(verifier).digest("base64url");
|
||||
}
|
||||
|
||||
/**
|
||||
* Begin a sign-in. Returns where to send the browser, and the state to bind
|
||||
* to it in a cookie.
|
||||
*/
|
||||
export async function start(params: { username: string; base: string; remember: boolean }): Promise<{ location: string; state: string }> {
|
||||
const metadata = await metadataFor(params.base);
|
||||
sweepPending();
|
||||
if (pending.size >= MAX_PENDING) throw new UpstreamError("Too many sign-ins in progress", 503);
|
||||
const state = randomToken(24);
|
||||
const verifier = randomToken(48);
|
||||
pending.set(state, { verifier, base: params.base, username: params.username, remember: params.remember, createdAt: Date.now() });
|
||||
const scope = ["openid", "offline_access"].filter((s) => metadata.scopes.length === 0 || metadata.scopes.includes(s)).join(" ");
|
||||
const url = new URL(metadata.authorizationEndpoint);
|
||||
url.searchParams.set("response_type", "code");
|
||||
url.searchParams.set("client_id", config.oauthClientId);
|
||||
url.searchParams.set("redirect_uri", redirectUri());
|
||||
if (scope) url.searchParams.set("scope", scope);
|
||||
url.searchParams.set("state", state);
|
||||
url.searchParams.set("code_challenge", challengeOf(verifier));
|
||||
url.searchParams.set("code_challenge_method", "S256");
|
||||
if (params.username) url.searchParams.set("login_hint", params.username);
|
||||
return { location: url.toString(), state };
|
||||
}
|
||||
|
||||
export class SignInError extends Error {
|
||||
constructor(readonly code: "state_mismatch" | "expired" | "denied" | "exchange_failed", message: string) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Finish a sign-in: `state` as it came back in the URL, `boundState` as the
|
||||
* browser's cookie holds it. Each state is good for one attempt.
|
||||
*/
|
||||
export async function finish(params: { state: string; boundState: string | undefined; code: string }): Promise<{ tokens: TokenSet; base: string; username: string; remember: boolean }> {
|
||||
const p = pending.get(params.state);
|
||||
if (!p || !params.boundState || params.boundState !== params.state) {
|
||||
throw new SignInError("state_mismatch", "This sign-in didn't start in this browser. Try again.");
|
||||
}
|
||||
pending.delete(params.state);
|
||||
if (Date.now() - p.createdAt > PENDING_TTL_MS) throw new SignInError("expired", "The sign-in took too long. Try again.");
|
||||
const metadata = await metadataFor(p.base);
|
||||
const tokens = await tokenRequest(metadata.tokenEndpoint, {
|
||||
grant_type: "authorization_code",
|
||||
code: params.code,
|
||||
code_verifier: p.verifier,
|
||||
redirect_uri: redirectUri(),
|
||||
});
|
||||
if (!tokens) throw new SignInError("exchange_failed", "The mail server didn't accept the sign-in. Try again.");
|
||||
return { tokens, base: p.base, username: p.username, remember: p.remember };
|
||||
}
|
||||
|
||||
/**
|
||||
* Renew an access token. Null when the server refuses the refresh token --
|
||||
* revoked by a password change, expired, or the client's secret changed --
|
||||
* which ends the session. Throws when the server couldn't be asked.
|
||||
*/
|
||||
export async function refreshTokens(base: string, tokens: TokenSet): Promise<TokenSet | null> {
|
||||
if (!tokens.refresh) return null;
|
||||
const metadata = await metadataFor(base);
|
||||
const renewed = await tokenRequest(metadata.tokenEndpoint, { grant_type: "refresh_token", refresh_token: tokens.refresh });
|
||||
// The server hands out a new refresh token only when the old one is close
|
||||
// to expiring; otherwise the old one stays good.
|
||||
return renewed && { ...renewed, refresh: renewed.refresh ?? tokens.refresh };
|
||||
}
|
||||
|
||||
async function tokenRequest(endpoint: string, fields: Record<string, string>): Promise<TokenSet | null> {
|
||||
const res = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" },
|
||||
body: new URLSearchParams({ ...fields, client_id: config.oauthClientId, client_secret: config.oauthClientSecret }),
|
||||
signal: AbortSignal.timeout(config.upstreamTimeout),
|
||||
});
|
||||
if (res.status === 400 || res.status === 401) return null;
|
||||
if (!res.ok) throw new UpstreamError(`Token request failed (${res.status})`, 502);
|
||||
const body = (await res.json()) as { access_token?: string; refresh_token?: string; expires_in?: number; token_type?: string };
|
||||
if (!body.access_token || (body.token_type && body.token_type.toLowerCase() !== "bearer")) {
|
||||
throw new UpstreamError("The mail server returned no usable access token", 502);
|
||||
}
|
||||
return {
|
||||
access: body.access_token,
|
||||
refresh: body.refresh_token ?? null,
|
||||
expiresAt: Date.now() + (body.expires_in ?? 3600) * 1000,
|
||||
};
|
||||
}
|
||||
|
||||
export function needsRefresh(tokens: TokenSet, now = Date.now()): boolean {
|
||||
return tokens.expiresAt - now < REFRESH_MARGIN_MS;
|
||||
}
|
||||
|
||||
/** For tests. */
|
||||
export function resetOAuthState(): void {
|
||||
pending.clear();
|
||||
metadataCache.clear();
|
||||
}
|
||||
+13
-10
@@ -5,6 +5,9 @@ process.env.STALWART_URL = "http://127.0.0.1:1";
|
||||
process.env.PUSH_URL = "https://ihasmail.example";
|
||||
const push = await import("./push.js");
|
||||
|
||||
/** A fixed credential, as a password session hands push. */
|
||||
const cred = (authorization: string) => ({ get: async () => authorization });
|
||||
|
||||
// Nothing in this file may reach the network. Background subscribe() calls
|
||||
// outlive the test that started them, so the stub stays in place for the
|
||||
// whole file rather than per test; the per-test stubs below layer on top.
|
||||
@@ -46,7 +49,7 @@ test("a tab opened before verification gets no fan-out, and a subscription is st
|
||||
const restore = stubUpstream();
|
||||
try {
|
||||
const out = fakeOut();
|
||||
const entry = push.attach("[email protected]", "a", "Basic x", out as never);
|
||||
const entry = push.attach("[email protected]", "a", cred("Basic x"), out as never);
|
||||
assert.equal(entry, null, "not verified yet, so the tab must keep its own relay");
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
const st = push.pushStatus();
|
||||
@@ -59,7 +62,7 @@ test("verification then fan-out: one POST reaches every open tab for the account
|
||||
try {
|
||||
// First contact starts the subscription; wait for the stubbed create to land.
|
||||
const first = fakeOut();
|
||||
push.attach("[email protected]", "a", "Basic y", first as never);
|
||||
push.attach("[email protected]", "a", cred("Basic y"), first as never);
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
// Find the token Stalwart would have been given, the way Stalwart learns it: from the subscribe call.
|
||||
// We cannot read it back through the public API, so verify via the status transition instead:
|
||||
@@ -75,7 +78,7 @@ test("a StateChange is written to attached tabs as an SSE frame, and closed tabs
|
||||
const restore = stubUpstream();
|
||||
try {
|
||||
const out1 = fakeOut(), out2 = fakeOut();
|
||||
push.attach("[email protected]", "a", "Basic z", out1 as never);
|
||||
push.attach("[email protected]", "a", cred("Basic z"), out1 as never);
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
// Verify by handing the module its own token: pushStatus does not expose it, so read it from the
|
||||
// subscribe request the stub saw. Simplest faithful route: capture the URL Stalwart would POST to.
|
||||
@@ -88,14 +91,14 @@ test("a StateChange is written to attached tabs as an SSE frame, and closed tabs
|
||||
return real(input, init);
|
||||
}) as typeof fetch;
|
||||
// Force a renewal-style subscribe so the URL passes through the capturing fetch.
|
||||
push.attach("[email protected]", "a", "Basic w", out1 as never);
|
||||
push.attach("[email protected]", "a", cred("Basic w"), out1 as never);
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
globalThis.fetch = real;
|
||||
assert.ok(token, "the subscribe call carries the push URL with the token");
|
||||
assert.equal(await push.receive(token!, { "@type": "PushVerification", verificationCode: "v" }), 200);
|
||||
const entry = push.attach("[email protected]", "a", "Basic w", out1 as never);
|
||||
const entry = push.attach("[email protected]", "a", cred("Basic w"), out1 as never);
|
||||
assert.ok(entry, "verified: the tab is served by fan-out");
|
||||
push.attach("[email protected]", "a", "Basic w", out2 as never);
|
||||
push.attach("[email protected]", "a", cred("Basic w"), out2 as never);
|
||||
assert.equal(await push.receive(token!, { "@type": "StateChange", changed: { a: { Email: "s1" } } }), 200);
|
||||
assert.match(out1.written.at(-1) ?? "", /^event: state\ndata: \{"@type":"StateChange"/);
|
||||
assert.equal(out2.written.length, 1);
|
||||
@@ -119,12 +122,12 @@ test("a tab on the relay is moved to fan-out when its account verifies, and its
|
||||
if (m) token = m[1];
|
||||
return real(input, init);
|
||||
}) as typeof fetch;
|
||||
push.prepare("[email protected]", "a", "Basic m"); // sign-in starts the subscription
|
||||
push.prepare("[email protected]", "a", cred("Basic m")); // sign-in starts the subscription
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
globalThis.fetch = real;
|
||||
assert.ok(token);
|
||||
const out = fakeOut(); let dropped = 0;
|
||||
assert.equal(push.attach("[email protected]", "a", "Basic m", out as never), null, "not yet verified: relay");
|
||||
assert.equal(push.attach("[email protected]", "a", cred("Basic m"), out as never), null, "not yet verified: relay");
|
||||
push.attachRelay("[email protected]", out as never, () => { dropped++; });
|
||||
assert.equal(push.pushStatus().tabs.relay >= 1, true);
|
||||
assert.equal(await push.receive(token!, { "@type": "PushVerification", verificationCode: "v" }), 200);
|
||||
@@ -168,7 +171,7 @@ test("a new subscription clears what this installation left behind, and only tha
|
||||
}) as typeof fetch;
|
||||
try {
|
||||
// The installation's prefix, learned the way the server makes it: from its first create.
|
||||
push.prepare("[email protected]", "a", "Basic p");
|
||||
push.prepare("[email protected]", "a", cred("Basic p"));
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
const firstCreate = calls.find(([n, a]) => n === "PushSubscription/set" && a.create);
|
||||
const deviceId = String(firstCreate?.[1].deviceClientId ?? "");
|
||||
@@ -176,7 +179,7 @@ test("a new subscription clears what this installation left behind, and only tha
|
||||
ownPrefix = deviceId.slice(0, deviceId.lastIndexOf("-") + 1);
|
||||
|
||||
calls.length = 0;
|
||||
push.prepare("[email protected]", "a", "Basic r");
|
||||
push.prepare("[email protected]", "a", cred("Basic r"));
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
const destroyed = calls.filter(([n, a]) => n === "PushSubscription/set" && a.destroy).flatMap(([, a]) => a.destroy as string[]);
|
||||
assert.deepEqual(destroyed, ["mine-before"], "only this installation's leftover goes");
|
||||
|
||||
+18
-8
@@ -39,7 +39,7 @@ interface AccountPush {
|
||||
accountId: string;
|
||||
base: string;
|
||||
token: string; // what Stalwart puts in the URL
|
||||
authorization: string; // one live session's credential, for set/verify/renew
|
||||
credential: PushCredential; // one live session's credential, for set/verify/renew
|
||||
subscriptionId: string | null;
|
||||
state: "pending" | "verified" | "failed";
|
||||
since: number;
|
||||
@@ -49,6 +49,15 @@ interface AccountPush {
|
||||
relays: Map<ServerResponse, () => void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* How push authenticates its own calls. A password session's is fixed; an
|
||||
* OAuth session's renews its access token itself, since a subscription lives
|
||||
* for days and an access token for an hour. See pushCredential() in app.ts.
|
||||
*/
|
||||
export interface PushCredential {
|
||||
get(): Promise<string>;
|
||||
}
|
||||
|
||||
const byKey = new Map<string, AccountPush>();
|
||||
const byToken = new Map<string, AccountPush>();
|
||||
let sweeper: NodeJS.Timeout | null = null;
|
||||
@@ -60,10 +69,11 @@ export function pushEnabled(): boolean {
|
||||
function keyFor(base: string, username: string) { return `${base} ${username}`; }
|
||||
|
||||
async function jmap(entry: AccountPush, calls: unknown[]) {
|
||||
const upstream = await getUpstreamSession(entry.key, entry.authorization, entry.base);
|
||||
const authorization = await entry.credential.get();
|
||||
const upstream = await getUpstreamSession(entry.key, authorization, entry.base);
|
||||
const res = await fetch(absoluteUpstream(upstream.apiUrl, upstream.baseUrl), {
|
||||
method: "POST",
|
||||
headers: { authorization: entry.authorization, "content-type": "application/json", accept: "application/json" },
|
||||
headers: { authorization, "content-type": "application/json", accept: "application/json" },
|
||||
body: JSON.stringify({ using: USING, methodCalls: calls }),
|
||||
signal: AbortSignal.timeout(config.upstreamTimeout),
|
||||
});
|
||||
@@ -159,14 +169,14 @@ async function unsubscribe(entry: AccountPush) {
|
||||
* by the time the browser opens its stream the verification is usually
|
||||
* already in flight, and called again by attach() as a safety net.
|
||||
*/
|
||||
export function prepare(username: string, accountId: string, authorization: string): AccountPush | null {
|
||||
export function prepare(username: string, accountId: string, credential: PushCredential): AccountPush | null {
|
||||
if (!pushEnabled()) return null;
|
||||
const base = upstreamFor(username);
|
||||
const key = keyFor(base, username);
|
||||
let entry = byKey.get(key);
|
||||
if (!entry) {
|
||||
entry = { key, username, accountId, base, token: randomBytes(32).toString("base64url"),
|
||||
authorization, subscriptionId: null, state: "pending", since: Date.now(), expires: 0, tabs: new Set(), relays: new Map() };
|
||||
credential, subscriptionId: null, state: "pending", since: Date.now(), expires: 0, tabs: new Set(), relays: new Map() };
|
||||
byKey.set(key, entry); byToken.set(entry.token, entry);
|
||||
subscribe(entry).catch((err) => {
|
||||
entry!.state = "failed";
|
||||
@@ -174,7 +184,7 @@ export function prepare(username: string, accountId: string, authorization: stri
|
||||
});
|
||||
startSweeper();
|
||||
} else {
|
||||
entry.authorization = authorization; // keep a live credential for renewals
|
||||
entry.credential = credential; // keep a live credential for renewals
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
@@ -183,8 +193,8 @@ export function prepare(username: string, accountId: string, authorization: stri
|
||||
* Called when a tab opens. Returns the account's push entry if the tab can
|
||||
* be served by fan-out right now, or null if it must hold its own relay.
|
||||
*/
|
||||
export function attach(username: string, accountId: string, authorization: string, out: ServerResponse): AccountPush | null {
|
||||
const entry = prepare(username, accountId, authorization);
|
||||
export function attach(username: string, accountId: string, credential: PushCredential, out: ServerResponse): AccountPush | null {
|
||||
const entry = prepare(username, accountId, credential);
|
||||
if (!entry || entry.state !== "verified") return null;
|
||||
entry.tabs.add(out);
|
||||
out.on("close", () => { entry.tabs.delete(out); });
|
||||
|
||||
+39
-11
@@ -3,6 +3,7 @@ import { dirname } from "node:path";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { config } from "./config.js";
|
||||
import { deriveKey, open, randomToken, safeEqual, seal, sha256 } from "./crypto.js";
|
||||
import type { TokenSet } from "./oauth.js";
|
||||
|
||||
export interface StoredSession {
|
||||
id: string;
|
||||
@@ -10,7 +11,7 @@ export interface StoredSession {
|
||||
secretHash: string;
|
||||
/** base64 random salt for key derivation */
|
||||
salt: string;
|
||||
/** sealed JSON {username, password} */
|
||||
/** sealed JSON: `{u, p}` for a password, `{u, t}` for OAuth tokens (see oauth.ts) */
|
||||
sealedCredentials: string;
|
||||
username: string;
|
||||
/** Which account this is; see `accountKey`. Absent on sessions saved before it existed. */
|
||||
@@ -28,8 +29,10 @@ export interface LiveSession {
|
||||
username: string;
|
||||
/** See `accountKey`. */
|
||||
account: string;
|
||||
/** Basic Authorization header value for upstream calls. */
|
||||
/** Authorization header value for upstream calls: Basic, or Bearer for OAuth. */
|
||||
authorization: string;
|
||||
/** The OAuth tokens behind `authorization`, or null for a password session. */
|
||||
tokens: TokenSet | null;
|
||||
remember: boolean;
|
||||
createdAt: number;
|
||||
lastSeenAt: number;
|
||||
@@ -71,7 +74,9 @@ export interface CreateSessionParams {
|
||||
username: string;
|
||||
/** From `accountKey`; defaults to the lower-cased username. */
|
||||
account?: string;
|
||||
password: string;
|
||||
/** Exactly one of `password` and `tokens`. */
|
||||
password?: string;
|
||||
tokens?: TokenSet;
|
||||
remember: boolean;
|
||||
userAgent: string;
|
||||
ip: string;
|
||||
@@ -107,6 +112,8 @@ export interface SessionBackend {
|
||||
create(params: CreateSessionParams): { cookie: string; session: LiveSession };
|
||||
resolve(cookie: string | undefined): LiveSession | null;
|
||||
reseal(cookie: string | undefined, password: string): boolean;
|
||||
/** Store renewed OAuth tokens in place of the ones the session holds. */
|
||||
updateTokens(cookie: string | undefined, tokens: TokenSet): boolean;
|
||||
destroy(id: string): void;
|
||||
/** `account` is an `accountKey`, as carried on `LiveSession.account`. */
|
||||
destroyAllForUser(account: string, exceptId?: string): number;
|
||||
@@ -115,6 +122,15 @@ export interface SessionBackend {
|
||||
|
||||
const COOKIE_SEP = ".";
|
||||
|
||||
/** What a session seals: a password, or OAuth tokens. */
|
||||
type Sealed = { u: string; p: string } | { u: string; t: TokenSet };
|
||||
|
||||
function sealable(username: string, params: { password?: string; tokens?: TokenSet }): Sealed {
|
||||
if (params.tokens) return { u: username, t: params.tokens };
|
||||
if (params.password !== undefined) return { u: username, p: params.password };
|
||||
throw new Error("a session needs a password or tokens");
|
||||
}
|
||||
|
||||
export class SessionStore implements SessionBackend {
|
||||
private sessions = new Map<string, StoredSession>();
|
||||
private dirty = false;
|
||||
@@ -194,7 +210,7 @@ export class SessionStore implements SessionBackend {
|
||||
id,
|
||||
secretHash: sha256(secret),
|
||||
salt: salt.toString("base64"),
|
||||
sealedCredentials: seal(JSON.stringify({ u: params.username, p: params.password }), key),
|
||||
sealedCredentials: seal(JSON.stringify(sealable(params.username, params)), key),
|
||||
username: params.username,
|
||||
account: params.account ?? params.username.trim().toLowerCase(),
|
||||
createdAt: now,
|
||||
@@ -207,7 +223,7 @@ export class SessionStore implements SessionBackend {
|
||||
this.sessions.set(id, stored);
|
||||
this.scheduleSave();
|
||||
const cookie = `${id}${COOKIE_SEP}${secret}`;
|
||||
return { cookie, session: this.toLive(stored, params.username, params.password) };
|
||||
return { cookie, session: this.toLive(stored, sealable(params.username, params)) };
|
||||
}
|
||||
|
||||
/** Resolve a cookie to a live session (with decrypted upstream credentials). */
|
||||
@@ -229,9 +245,9 @@ export class SessionStore implements SessionBackend {
|
||||
const key = deriveKey(secret, config.appSecret, Buffer.from(stored.salt, "base64"));
|
||||
const json = open(stored.sealedCredentials, key);
|
||||
if (!json) return null;
|
||||
let creds: { u: string; p: string };
|
||||
let creds: Sealed;
|
||||
try {
|
||||
creds = JSON.parse(json) as { u: string; p: string };
|
||||
creds = JSON.parse(json) as Sealed;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -242,7 +258,7 @@ export class SessionStore implements SessionBackend {
|
||||
stored.expiresAt = now + ttl;
|
||||
this.scheduleSave();
|
||||
}
|
||||
return this.toLive(stored, creds.u, creds.p);
|
||||
return this.toLive(stored, creds);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -255,6 +271,14 @@ export class SessionStore implements SessionBackend {
|
||||
* secret half of it, which the server never keeps.
|
||||
*/
|
||||
reseal(cookie: string | undefined, password: string): boolean {
|
||||
return this.rewrite(cookie, (username) => ({ u: username, p: password }));
|
||||
}
|
||||
|
||||
updateTokens(cookie: string | undefined, tokens: TokenSet): boolean {
|
||||
return this.rewrite(cookie, (username) => ({ u: username, t: tokens }));
|
||||
}
|
||||
|
||||
private rewrite(cookie: string | undefined, next: (username: string) => Sealed): boolean {
|
||||
if (!cookie) return false;
|
||||
const idx = cookie.indexOf(COOKIE_SEP);
|
||||
if (idx <= 0) return false;
|
||||
@@ -264,7 +288,7 @@ export class SessionStore implements SessionBackend {
|
||||
if (!stored) return false;
|
||||
if (!safeEqual(stored.secretHash, sha256(secret))) return false;
|
||||
const key = deriveKey(secret, config.appSecret, Buffer.from(stored.salt, "base64"));
|
||||
stored.sealedCredentials = seal(JSON.stringify({ u: stored.username, p: password }), key);
|
||||
stored.sealedCredentials = seal(JSON.stringify(next(stored.username)), key);
|
||||
this.scheduleSave();
|
||||
return true;
|
||||
}
|
||||
@@ -295,12 +319,16 @@ export class SessionStore implements SessionBackend {
|
||||
return out;
|
||||
}
|
||||
|
||||
private toLive(s: StoredSession, username: string, password: string): LiveSession {
|
||||
private toLive(s: StoredSession, creds: Sealed): LiveSession {
|
||||
const username = creds.u;
|
||||
return {
|
||||
id: s.id,
|
||||
username,
|
||||
account: accountOf(s),
|
||||
authorization: `Basic ${Buffer.from(`${username}:${password}`, "utf8").toString("base64")}`,
|
||||
authorization: "t" in creds
|
||||
? `Bearer ${creds.t.access}`
|
||||
: `Basic ${Buffer.from(`${username}:${creds.p}`, "utf8").toString("base64")}`,
|
||||
tokens: "t" in creds ? creds.t : null,
|
||||
remember: s.remember,
|
||||
createdAt: s.createdAt,
|
||||
lastSeenAt: s.lastSeenAt,
|
||||
|
||||
Reference in New Issue
Block a user