diff --git a/INBUXA.md b/INBUXA.md
new file mode 100644
index 0000000..a26925f
--- /dev/null
+++ b/INBUXA.md
@@ -0,0 +1,40 @@
+# ihasmail-inbuxa
+
+This is ihasmail for INBUXA's mail server. Public ihasmail stays
+Stalwart-facing; everything specific to INBUXA lives here until one product
+can serve both. The contract between the two is `docs/spec/contract.md` in
+the inbuxa-server repository.
+
+Public ihasmail is the remote `ihasmail`, fetch-only. Merge its `main` in to
+keep up. Nothing here is pushed there.
+
+## What's different
+
+- **Sign-in happens on the mail server's own page** (contract C-8, C-10).
+ ihasmail sends the browser there and gets OAuth tokens back, so it never
+ handles a password to sign someone in. Two-factor codes are asked for on
+ that page. Sessions hold sealed tokens and renew them before they expire. A
+ password change revokes the tokens, so it signs the person out everywhere,
+ this session included.
+- **Tenants are offered on every server**, whatever edition it reports.
+ `SHOW_ENTERPRISE_NOTICES` still adds the notice for an upstream Stalwart.
+
+## Configuration
+
+Server sign-in is on when `OAUTH_CLIENT_SECRET` is set. Without it,
+ihasmail-inbuxa keeps public ihasmail's password form.
+
+| Variable | Meaning |
+|---|---|
+| `OAUTH_CLIENT_SECRET` | The secret of the confidential client the mail server registers for this webmail. On INBUXA, the same value as the server's `INBUXA_WEBMAIL_CLIENT_SECRET`. |
+| `OAUTH_CLIENT_ID` | The client's id. Default `ihasmail-inbuxa`, which is what INBUXA registers. |
+| `PUBLIC_URL` | Where browsers reach ihasmail, without `BASE_PATH`. Required with `OAUTH_CLIENT_SECRET`. The redirect URI is `PUBLIC_URL` + `BASE_PATH` + `/api/auth/callback`, and must match the server's `INBUXA_WEBMAIL_URL` + `/api/auth/callback` exactly. |
+
+On the INBUXA server, set `INBUXA_WEBMAIL_URL` to ihasmail's address (with
+`BASE_PATH`, if any) and `INBUXA_WEBMAIL_CLIENT_SECRET` to the shared secret.
+The server registers the client on start and allows ihasmail's origin for
+cross-origin requests.
+
+For local development, `npm run dev:mock` works as before. The mock also
+answers OAuth: start it and ihasmail with `OAUTH_CLIENT_SECRET=mock-oauth-secret`
+and a `PUBLIC_URL`, and its sign-in page approves the demo user at once.
diff --git a/server/src/app.ts b/server/src/app.ts
index 7e7e73e..97d0b94 100644
--- a/server/src/app.ts
+++ b/server/src/app.ts
@@ -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 = 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 = 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>();
+
+/**
+ * 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 {
+ 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 } {
+ 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 {
/* 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 {
// 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 {
} 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 {
} 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 {
} 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 {
// 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(c: Context): Promise {
* server.
*/
async function confirmsPassword(session: LiveSession, candidate: string): Promise {
+ 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, 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,
/**
diff --git a/server/src/config.ts b/server/src/config.ts
index 31869dc..aeebc09 100644
--- a/server/src/config.ts
+++ b/server/src/config.ts
@@ -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),
/**
diff --git a/server/src/mock/index.ts b/server/src/mock/index.ts
index 5099fec..79a6e13 100644
--- a/server/src/mock/index.ts
+++ b/server/src/mock/index.ts
@@ -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" });
diff --git a/server/src/mock/oauth.ts b/server/src/mock/oauth.ts
new file mode 100644
index 0000000..2f66115
--- /dev/null
+++ b/server/src/mock/oauth.ts
@@ -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();
+const accessTokens = new Map();
+const refreshTokens = new Map();
+
+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 {
+ 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 = { 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 {
+ 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;
diff --git a/server/src/oauth.test.ts b/server/src/oauth.test.ts
new file mode 100644
index 0000000..fd06a30
--- /dev/null
+++ b/server/src/oauth.test.ts
@@ -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 = "demo@example.com";
+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();
+
+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) },
+ });
+ 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 = "demo@example.com"): Promise {
+ 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: "demo@example.com", 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/start?username=demo@example.com");
+ 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"), "demo@example.com");
+ 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, "demo@example.com");
+ 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/start?username=demo@example.com");
+ 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-/);
+});
diff --git a/server/src/oauth.ts b/server/src/oauth.ts
new file mode 100644
index 0000000..78ef922
--- /dev/null
+++ b/server/src/oauth.ts
@@ -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();
+
+async function metadataFor(base: string): Promise {
+ 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();
+
+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 {
+ 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): Promise {
+ 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();
+}
diff --git a/server/src/push.test.ts b/server/src/push.test.ts
index e37f950..981ab68 100644
--- a/server/src/push.test.ts
+++ b/server/src/push.test.ts
@@ -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("someone@example.com", "a", "Basic x", out as never);
+ const entry = push.attach("someone@example.com", "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("fan@example.com", "a", "Basic y", first as never);
+ push.attach("fan@example.com", "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("frame@example.com", "a", "Basic z", out1 as never);
+ push.attach("frame@example.com", "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("frame2@example.com", "a", "Basic w", out1 as never);
+ push.attach("frame2@example.com", "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("frame2@example.com", "a", "Basic w", out1 as never);
+ const entry = push.attach("frame2@example.com", "a", cred("Basic w"), out1 as never);
assert.ok(entry, "verified: the tab is served by fan-out");
- push.attach("frame2@example.com", "a", "Basic w", out2 as never);
+ push.attach("frame2@example.com", "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("move@example.com", "a", "Basic m"); // sign-in starts the subscription
+ push.prepare("move@example.com", "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("move@example.com", "a", "Basic m", out as never), null, "not yet verified: relay");
+ assert.equal(push.attach("move@example.com", "a", cred("Basic m"), out as never), null, "not yet verified: relay");
push.attachRelay("move@example.com", 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("probe-prefix@example.com", "a", "Basic p");
+ push.prepare("probe-prefix@example.com", "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("restart@example.com", "a", "Basic r");
+ push.prepare("restart@example.com", "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");
diff --git a/server/src/push.ts b/server/src/push.ts
index a6caeca..6b79345 100644
--- a/server/src/push.ts
+++ b/server/src/push.ts
@@ -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 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;
+}
+
const byKey = new Map();
const byToken = new Map();
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); });
diff --git a/server/src/sessions.ts b/server/src/sessions.ts
index 74d1120..fd14474 100644
--- a/server/src/sessions.ts
+++ b/server/src/sessions.ts
@@ -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();
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,
diff --git a/web/src/jmap/types.ts b/web/src/jmap/types.ts
index a210103..0e8cdcc 100644
--- a/web/src/jmap/types.ts
+++ b/web/src/jmap/types.ts
@@ -31,6 +31,8 @@ export interface JmapSession {
maxUploadBytes: number;
sessionId: string;
loginName: string;
+ /** "oauth" when signed in on the mail server's page (ihasmail-inbuxa); absent from older servers. */
+ signIn?: "oauth" | "password";
remember: boolean;
/** Locale configured for the account in Stalwart, if the server exposes it. */
userLocale?: string | null;
diff --git a/web/src/locales/de.ts b/web/src/locales/de.ts
index 7137239..f97ee63 100644
--- a/web/src/locales/de.ts
+++ b/web/src/locales/de.ts
@@ -1242,6 +1242,17 @@ export const catalog: Catalog = {
"Could not save filters: {error}": "Filter konnten nicht gespeichert werden: {error}",
"Could not send the receipt: {error}": "Die Lesebestätigung konnte nicht gesendet werden: {error}",
"Could not sign in.": "Anmeldung fehlgeschlagen.",
+ "You'll enter your password on your mail server's sign-in page.": "Ihr Passwort geben Sie auf der Anmeldeseite Ihres Mailservers ein.",
+ "This sign-in didn't start in this browser. Try again.": "Diese Anmeldung wurde nicht in diesem Browser begonnen. Bitte versuchen Sie es erneut.",
+ "The sign-in took too long. Try again.": "Die Anmeldung hat zu lange gedauert. Bitte versuchen Sie es erneut.",
+ "Sign-in was cancelled.": "Die Anmeldung wurde abgebrochen.",
+ "The mail server didn't accept the sign-in. Try again.": "Der Mailserver hat die Anmeldung nicht angenommen. Bitte versuchen Sie es erneut.",
+ "That account is on a different mail server than the address you entered. Sign in with that address.": "Dieses Konto liegt auf einem anderen Mailserver als die eingegebene Adresse. Melden Sie sich mit dieser Adresse an.",
+ "This mail server isn't supported.": "Dieser Mailserver wird nicht unterstützt.",
+ "Couldn't reach the mail server. Try again in a moment.": "Der Mailserver ist nicht erreichbar. Bitte versuchen Sie es gleich noch einmal.",
+ "Your password was changed. Sign in with the new one.": "Ihr Passwort wurde geändert. Melden Sie sich mit dem neuen an.",
+ "Your sign-in ended. Sign in again.": "Ihre Anmeldung ist abgelaufen. Bitte melden Sie sich erneut an.",
+ "Changing your password signs you out everywhere, here included, and you sign in again with the new one. Any app passwords keep working.": "Wenn Sie Ihr Passwort ändern, werden Sie überall abgemeldet, auch hier, und melden sich mit dem neuen wieder an. App-Passwörter funktionieren weiterhin.",
"Could not store image: {error}": "Bild konnte nicht gespeichert werden: {error}",
"Could not update labels: {error}": "Labels konnten nicht aktualisiert werden: {error}",
"Could not update the Scheduled folder: {error}": "Der Ordner „Geplant“ konnte nicht aktualisiert werden: {error}",
diff --git a/web/src/locales/es.ts b/web/src/locales/es.ts
index 344377f..ba9d7ee 100644
--- a/web/src/locales/es.ts
+++ b/web/src/locales/es.ts
@@ -1215,6 +1215,17 @@ export const catalog: Catalog = {
"Could not save filters: {error}": "No se pudieron guardar los filtros: {error}",
"Could not send the receipt: {error}": "No se pudo enviar la confirmación de lectura: {error}",
"Could not sign in.": "No se pudo iniciar sesión.",
+ "You'll enter your password on your mail server's sign-in page.": "Introducirá su contraseña en la página de inicio de sesión de su servidor de correo.",
+ "This sign-in didn't start in this browser. Try again.": "Este inicio de sesión no empezó en este navegador. Inténtelo de nuevo.",
+ "The sign-in took too long. Try again.": "El inicio de sesión tardó demasiado. Inténtelo de nuevo.",
+ "Sign-in was cancelled.": "Se canceló el inicio de sesión.",
+ "The mail server didn't accept the sign-in. Try again.": "El servidor de correo no aceptó el inicio de sesión. Inténtelo de nuevo.",
+ "That account is on a different mail server than the address you entered. Sign in with that address.": "Esa cuenta está en un servidor de correo distinto del de la dirección que introdujo. Inicie sesión con esa dirección.",
+ "This mail server isn't supported.": "Este servidor de correo no es compatible.",
+ "Couldn't reach the mail server. Try again in a moment.": "No se pudo conectar con el servidor de correo. Inténtelo de nuevo en un momento.",
+ "Your password was changed. Sign in with the new one.": "Se cambió su contraseña. Inicie sesión con la nueva.",
+ "Your sign-in ended. Sign in again.": "Su sesión terminó. Inicie sesión de nuevo.",
+ "Changing your password signs you out everywhere, here included, and you sign in again with the new one. Any app passwords keep working.": "Cambiar la contraseña cierra todas sus sesiones, también esta, y vuelve a iniciar sesión con la nueva. Las contraseñas de aplicación siguen funcionando.",
"Could not store image: {error}": "No se pudo guardar la imagen: {error}",
"Could not update labels: {error}": "No se pudieron actualizar las etiquetas: {error}",
"Could not update the Scheduled folder: {error}": "No se pudo actualizar la carpeta Programados: {error}",
diff --git a/web/src/locales/fr.ts b/web/src/locales/fr.ts
index 5af24b0..0d5124c 100644
--- a/web/src/locales/fr.ts
+++ b/web/src/locales/fr.ts
@@ -1220,6 +1220,17 @@ export const catalog: Catalog = {
"Could not save filters: {error}": "Impossible d’enregistrer les filtres : {error}",
"Could not send the receipt: {error}": "Impossible d’envoyer l’accusé de lecture : {error}",
"Could not sign in.": "Connexion impossible.",
+ "You'll enter your password on your mail server's sign-in page.": "Vous saisirez votre mot de passe sur la page de connexion de votre serveur de messagerie.",
+ "This sign-in didn't start in this browser. Try again.": "Cette connexion n’a pas commencé dans ce navigateur. Réessayez.",
+ "The sign-in took too long. Try again.": "La connexion a pris trop de temps. Réessayez.",
+ "Sign-in was cancelled.": "La connexion a été annulée.",
+ "The mail server didn't accept the sign-in. Try again.": "Le serveur de messagerie n’a pas accepté la connexion. Réessayez.",
+ "That account is on a different mail server than the address you entered. Sign in with that address.": "Ce compte se trouve sur un autre serveur de messagerie que l’adresse saisie. Connectez-vous avec cette adresse.",
+ "This mail server isn't supported.": "Ce serveur de messagerie n’est pas pris en charge.",
+ "Couldn't reach the mail server. Try again in a moment.": "Impossible de joindre le serveur de messagerie. Réessayez dans un instant.",
+ "Your password was changed. Sign in with the new one.": "Votre mot de passe a été changé. Connectez-vous avec le nouveau.",
+ "Your sign-in ended. Sign in again.": "Votre connexion a pris fin. Connectez-vous à nouveau.",
+ "Changing your password signs you out everywhere, here included, and you sign in again with the new one. Any app passwords keep working.": "Changer votre mot de passe vous déconnecte partout, ici compris, et vous vous reconnectez avec le nouveau. Les mots de passe d'application continuent de fonctionner.",
"Could not store image: {error}": "Impossible d’enregistrer l’image : {error}",
"Could not update labels: {error}": "Impossible de mettre à jour les libellés : {error}",
"Could not update the Scheduled folder: {error}": "Impossible de mettre à jour le dossier Programmés : {error}",
diff --git a/web/src/locales/ja.ts b/web/src/locales/ja.ts
index 193d3a2..8d144aa 100644
--- a/web/src/locales/ja.ts
+++ b/web/src/locales/ja.ts
@@ -1223,6 +1223,17 @@ export const catalog: Catalog = {
"Could not save filters: {error}": "フィルターを保存できませんでした: {error}",
"Could not send the receipt: {error}": "開封確認を送信できませんでした: {error}",
"Could not sign in.": "サインインできませんでした。",
+ "You'll enter your password on your mail server's sign-in page.": "パスワードはメールサーバーのサインインページで入力します。",
+ "This sign-in didn't start in this browser. Try again.": "このサインインはこのブラウザーで開始されたものではありません。もう一度お試しください。",
+ "The sign-in took too long. Try again.": "サインインに時間がかかりすぎました。もう一度お試しください。",
+ "Sign-in was cancelled.": "サインインはキャンセルされました。",
+ "The mail server didn't accept the sign-in. Try again.": "メールサーバーがサインインを受け付けませんでした。もう一度お試しください。",
+ "That account is on a different mail server than the address you entered. Sign in with that address.": "そのアカウントは、入力したアドレスとは別のメールサーバーにあります。そのアドレスでサインインしてください。",
+ "This mail server isn't supported.": "このメールサーバーには対応していません。",
+ "Couldn't reach the mail server. Try again in a moment.": "メールサーバーに接続できませんでした。しばらくしてからもう一度お試しください。",
+ "Your password was changed. Sign in with the new one.": "パスワードが変更されました。新しいパスワードでサインインしてください。",
+ "Your sign-in ended. Sign in again.": "サインインが終了しました。もう一度サインインしてください。",
+ "Changing your password signs you out everywhere, here included, and you sign in again with the new one. Any app passwords keep working.": "パスワードを変更すると、この画面を含むすべての場所からサインアウトされ、新しいパスワードで再度サインインします。アプリパスワードはそのまま使えます。",
"Could not store image: {error}": "画像を保存できませんでした: {error}",
"Could not update labels: {error}": "ラベルを更新できませんでした: {error}",
"Could not update the Scheduled folder: {error}": "「送信予約」フォルダーを更新できませんでした: {error}",
diff --git a/web/src/locales/nl.ts b/web/src/locales/nl.ts
index 4463809..914f81f 100644
--- a/web/src/locales/nl.ts
+++ b/web/src/locales/nl.ts
@@ -1213,6 +1213,17 @@ export const catalog: Catalog = {
"Could not save filters: {error}": "Filters opslaan mislukt: {error}",
"Could not send the receipt: {error}": "De leesbevestiging kon niet worden verzonden: {error}",
"Could not sign in.": "Aanmelden mislukt.",
+ "You'll enter your password on your mail server's sign-in page.": "U voert uw wachtwoord in op de aanmeldpagina van uw mailserver.",
+ "This sign-in didn't start in this browser. Try again.": "Deze aanmelding is niet in deze browser begonnen. Probeer het opnieuw.",
+ "The sign-in took too long. Try again.": "Het aanmelden duurde te lang. Probeer het opnieuw.",
+ "Sign-in was cancelled.": "Het aanmelden is geannuleerd.",
+ "The mail server didn't accept the sign-in. Try again.": "De mailserver heeft de aanmelding niet geaccepteerd. Probeer het opnieuw.",
+ "That account is on a different mail server than the address you entered. Sign in with that address.": "Dat account staat op een andere mailserver dan het adres dat u invoerde. Meld u aan met dat adres.",
+ "This mail server isn't supported.": "Deze mailserver wordt niet ondersteund.",
+ "Couldn't reach the mail server. Try again in a moment.": "Kan de mailserver niet bereiken. Probeer het zo meteen opnieuw.",
+ "Your password was changed. Sign in with the new one.": "Uw wachtwoord is gewijzigd. Meld u aan met het nieuwe.",
+ "Your sign-in ended. Sign in again.": "Uw aanmelding is beëindigd. Meld u opnieuw aan.",
+ "Changing your password signs you out everywhere, here included, and you sign in again with the new one. Any app passwords keep working.": "Als u uw wachtwoord wijzigt, wordt u overal afgemeld, ook hier, en meldt u zich opnieuw aan met het nieuwe. App-wachtwoorden blijven werken.",
"Could not store image: {error}": "De afbeelding kon niet worden opgeslagen: {error}",
"Could not update labels: {error}": "Labels bijwerken mislukt: {error}",
"Could not update the Scheduled folder: {error}": "De map Gepland kon niet worden bijgewerkt: {error}",
diff --git a/web/src/locales/pt-BR.ts b/web/src/locales/pt-BR.ts
index 28786b5..460144c 100644
--- a/web/src/locales/pt-BR.ts
+++ b/web/src/locales/pt-BR.ts
@@ -1218,6 +1218,17 @@ export const catalog: Catalog = {
"Could not save filters: {error}": "Não foi possível salvar os filtros: {error}",
"Could not send the receipt: {error}": "Não foi possível enviar a confirmação de leitura: {error}",
"Could not sign in.": "Não foi possível entrar.",
+ "You'll enter your password on your mail server's sign-in page.": "Você vai digitar sua senha na página de entrada do seu servidor de e-mail.",
+ "This sign-in didn't start in this browser. Try again.": "Esta entrada não começou neste navegador. Tente de novo.",
+ "The sign-in took too long. Try again.": "A entrada demorou demais. Tente de novo.",
+ "Sign-in was cancelled.": "A entrada foi cancelada.",
+ "The mail server didn't accept the sign-in. Try again.": "O servidor de e-mail não aceitou a entrada. Tente de novo.",
+ "That account is on a different mail server than the address you entered. Sign in with that address.": "Essa conta está em um servidor de e-mail diferente do endereço que você digitou. Entre com esse endereço.",
+ "This mail server isn't supported.": "Este servidor de e-mail não é compatível.",
+ "Couldn't reach the mail server. Try again in a moment.": "Não foi possível acessar o servidor de e-mail. Tente de novo em instantes.",
+ "Your password was changed. Sign in with the new one.": "Sua senha foi alterada. Entre com a nova.",
+ "Your sign-in ended. Sign in again.": "Sua sessão terminou. Entre novamente.",
+ "Changing your password signs you out everywhere, here included, and you sign in again with the new one. Any app passwords keep working.": "Mudar sua senha encerra todas as suas sessões, inclusive esta, e você entra de novo com a nova senha. As senhas de aplicativo continuam funcionando.",
"Could not store image: {error}": "Não foi possível armazenar a imagem: {error}",
"Could not update labels: {error}": "Não foi possível atualizar os marcadores: {error}",
"Could not update the Scheduled folder: {error}": "Não foi possível atualizar a pasta Agendados: {error}",
diff --git a/web/src/locales/ru.ts b/web/src/locales/ru.ts
index 386f5d6..fec6981 100644
--- a/web/src/locales/ru.ts
+++ b/web/src/locales/ru.ts
@@ -1217,6 +1217,17 @@ export const catalog: Catalog = {
"Could not save filters: {error}": "Не удалось сохранить фильтры: {error}",
"Could not send the receipt: {error}": "Не удалось отправить уведомление о прочтении: {error}",
"Could not sign in.": "Не удалось войти.",
+ "You'll enter your password on your mail server's sign-in page.": "Пароль вводится на странице входа вашего почтового сервера.",
+ "This sign-in didn't start in this browser. Try again.": "Этот вход начат не в этом браузере. Попробуйте ещё раз.",
+ "The sign-in took too long. Try again.": "Вход занял слишком много времени. Попробуйте ещё раз.",
+ "Sign-in was cancelled.": "Вход отменён.",
+ "The mail server didn't accept the sign-in. Try again.": "Почтовый сервер не принял вход. Попробуйте ещё раз.",
+ "That account is on a different mail server than the address you entered. Sign in with that address.": "Эта учётная запись находится на другом почтовом сервере, чем введённый адрес. Войдите с этим адресом.",
+ "This mail server isn't supported.": "Этот почтовый сервер не поддерживается.",
+ "Couldn't reach the mail server. Try again in a moment.": "Не удалось связаться с почтовым сервером. Попробуйте ещё раз чуть позже.",
+ "Your password was changed. Sign in with the new one.": "Пароль изменён. Войдите с новым паролем.",
+ "Your sign-in ended. Sign in again.": "Сеанс завершён. Войдите снова.",
+ "Changing your password signs you out everywhere, here included, and you sign in again with the new one. Any app passwords keep working.": "Смена пароля завершает все сеансы, включая этот, после чего нужно войти с новым паролем. Пароли приложений продолжают работать.",
"Could not store image: {error}": "Не удалось сохранить изображение: {error}",
"Could not update labels: {error}": "Не удалось обновить метки: {error}",
"Could not update the Scheduled folder: {error}": "Не удалось обновить папку «Отложенные»: {error}",
diff --git a/web/src/locales/uk.ts b/web/src/locales/uk.ts
index 2d202ce..ab20e56 100644
--- a/web/src/locales/uk.ts
+++ b/web/src/locales/uk.ts
@@ -1211,6 +1211,17 @@ export const catalog: Catalog = {
"Could not save filters: {error}": "Не вдалося зберегти фільтри: {error}",
"Could not send the receipt: {error}": "Не вдалося надіслати сповіщення про прочитання: {error}",
"Could not sign in.": "Не вдалося увійти.",
+ "You'll enter your password on your mail server's sign-in page.": "Пароль вводиться на сторінці входу вашого поштового сервера.",
+ "This sign-in didn't start in this browser. Try again.": "Цей вхід розпочато не в цьому браузері. Спробуйте ще раз.",
+ "The sign-in took too long. Try again.": "Вхід тривав занадто довго. Спробуйте ще раз.",
+ "Sign-in was cancelled.": "Вхід скасовано.",
+ "The mail server didn't accept the sign-in. Try again.": "Поштовий сервер не прийняв вхід. Спробуйте ще раз.",
+ "That account is on a different mail server than the address you entered. Sign in with that address.": "Цей обліковий запис на іншому поштовому сервері, ніж введена адреса. Увійдіть із цією адресою.",
+ "This mail server isn't supported.": "Цей поштовий сервер не підтримується.",
+ "Couldn't reach the mail server. Try again in a moment.": "Не вдалося зв’язатися з поштовим сервером. Спробуйте ще раз трохи згодом.",
+ "Your password was changed. Sign in with the new one.": "Пароль змінено. Увійдіть із новим паролем.",
+ "Your sign-in ended. Sign in again.": "Сеанс завершено. Увійдіть знову.",
+ "Changing your password signs you out everywhere, here included, and you sign in again with the new one. Any app passwords keep working.": "Зміна пароля завершує всі сеанси, зокрема цей, після чого треба увійти з новим паролем. Паролі програм продовжують працювати.",
"Could not store image: {error}": "Не вдалося зберегти зображення: {error}",
"Could not update labels: {error}": "Не вдалося оновити мітки: {error}",
"Could not update the Scheduled folder: {error}": "Не вдалося оновити теку «Заплановані»: {error}",
diff --git a/web/src/locales/zh-Hans.ts b/web/src/locales/zh-Hans.ts
index 425eac0..0ca7345 100644
--- a/web/src/locales/zh-Hans.ts
+++ b/web/src/locales/zh-Hans.ts
@@ -1222,6 +1222,17 @@ export const catalog: Catalog = {
"Could not save filters: {error}": "无法保存过滤器:{error}",
"Could not send the receipt: {error}": "无法发送已读回执:{error}",
"Could not sign in.": "无法登录。",
+ "You'll enter your password on your mail server's sign-in page.": "您将在邮件服务器的登录页面上输入密码。",
+ "This sign-in didn't start in this browser. Try again.": "此次登录不是在这个浏览器中发起的。请重试。",
+ "The sign-in took too long. Try again.": "登录耗时过长。请重试。",
+ "Sign-in was cancelled.": "登录已取消。",
+ "The mail server didn't accept the sign-in. Try again.": "邮件服务器未接受此次登录。请重试。",
+ "That account is on a different mail server than the address you entered. Sign in with that address.": "该账户所在的邮件服务器与您输入的地址不同。请使用该地址登录。",
+ "This mail server isn't supported.": "不支持此邮件服务器。",
+ "Couldn't reach the mail server. Try again in a moment.": "无法连接邮件服务器。请稍后重试。",
+ "Your password was changed. Sign in with the new one.": "您的密码已更改。请使用新密码登录。",
+ "Your sign-in ended. Sign in again.": "您的登录已结束。请重新登录。",
+ "Changing your password signs you out everywhere, here included, and you sign in again with the new one. Any app passwords keep working.": "更改密码会让您在所有地方退出登录(包括这里),然后需用新密码重新登录。已有的应用专用密码仍可继续使用。",
"Could not store image: {error}": "无法保存图片:{error}",
"Could not update labels: {error}": "无法更新标签:{error}",
"Could not update the Scheduled folder: {error}": "无法更新「定时发送」文件夹:{error}",
diff --git a/web/src/views/Login.tsx b/web/src/views/Login.tsx
index 2bb64a0..cb130f8 100644
--- a/web/src/views/Login.tsx
+++ b/web/src/views/Login.tsx
@@ -27,6 +27,12 @@ export function LoginPage() {
* sign-in form with no name on it would be worse than a wrong one.
*/
const [appName, setAppName] = useState(DEFAULT_APP_NAME);
+ /*
+ * How this installation signs people in: on the mail server's own page
+ * ("oauth"), or with the password form. Unknown until the config arrives,
+ * and the password form if it never does.
+ */
+ const [signIn, setSignIn] = useState<"oauth" | "password" | null>(null);
useEffect(() => {
let live = true;
fetch(withBase("/api/config"))
@@ -35,8 +41,10 @@ export function LoginPage() {
if (!live || !c) return;
if (c.sourceUrl) setSourceUrl(c.sourceUrl as string);
if (typeof c.appName === "string" && c.appName.trim()) setAppName(c.appName.trim());
+ setSignIn(c.signIn === "oauth" ? "oauth" : "password");
})
- .catch(() => { /* the default stands */ });
+ .catch(() => { /* the default stands */ })
+ .finally(() => { if (live) setSignIn((m) => m ?? "password"); });
return () => { live = false; };
}, []);
const [username, setUsername] = useState(() => localStorage.getItem("ihasmail:lastUser") ?? "");
@@ -44,10 +52,19 @@ export function LoginPage() {
const [showPw, setShowPw] = useState(false);
const [trustDevice, setTrustDevice] = useState(false);
const [busy, setBusy] = useState(false);
- const [error, setError] = useState(null);
+ const [error, setError] = useState(() => takeSignInNotice());
const submit = async (e: FormEvent) => {
e.preventDefault();
+ if (signIn === "oauth") {
+ // Off to the mail server's page, which asks for the password there.
+ if (!username.trim()) return;
+ setBusy(true);
+ if (trustDevice) localStorage.setItem("ihasmail:lastUser", username.trim());
+ const params = new URLSearchParams({ username: username.trim(), ...(trustDevice ? { remember: "1" } : {}) });
+ window.location.assign(withBase(`/api/auth/oauth/start?${params}`));
+ return;
+ }
if (!username || !password) return;
setBusy(true);
setError(null);
@@ -87,6 +104,9 @@ export function LoginPage() {
setUsername(e.target.value)} autoFocus={!username} required />
+ {signIn === "oauth" ? (
+
{t("You'll enter your password on your mail server's sign-in page.")}