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.")}

+ ) : (
@@ -96,6 +116,7 @@ export function LoginPage() {
+ )}