Sign in on the mail server's own page (OAuth with PKCE), sessions hold tokens; tenants on every edition

Contract C-8 and C-10: with OAUTH_CLIENT_SECRET set, sign-in goes through the
server's page and the session keeps sealed tokens, renewed before they expire,
instead of a password. Push keeps a credential that renews itself. A password
change signs the session out, since the server revokes its tokens. The mock
answers OAuth for tests and development. Eleven new strings, in all nine
catalogues.
This commit is contained in:
2026-09-18 15:30:37 -07:00
parent c118184975
commit 8857bdac30
24 changed files with 1058 additions and 74 deletions
+40
View File
@@ -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.
+192 -3
View File
@@ -41,6 +41,7 @@ import {
revokeAppPassword,
} from "./account.js";
import { imageProxyHandler } from "./imageproxy.js";
import { SignInError, finish as finishSignIn, needsRefresh, oauthEnabled, refreshTokens, start as startSignIn, type TokenSet } from "./oauth.js";
import { icsProxyHandler } from "./icsproxy.js";
import { staticHandler } from "./static.js";
@@ -199,6 +200,9 @@ function compressResponses(basePath: string): MiddlewareHandler {
}
const csrfGuard: MiddlewareHandler = async (c, next) => {
// The mail server's sign-in page sends the browser back here, so this one
// arrives cross-site by design. Its state, bound to a cookie, stands in.
if (c.req.method === "GET" && c.req.path.endsWith("/api/auth/callback")) return next();
const site = c.req.header("sec-fetch-site");
if (site && site !== "same-origin" && site !== "none") {
return c.json({ error: "cross_site_request" }, 403);
@@ -229,7 +233,20 @@ const smallBodies: MiddlewareHandler = (c, next) => (LARGE_BODY_ROUTE.test(c.req
const requireSession: MiddlewareHandler<Env> = async (c, next) => {
const cookie = getCookie(c, config.cookieName);
const session = sessions.resolve(cookie);
let session = sessions.resolve(cookie);
if (session?.tokens && needsRefresh(session.tokens)) {
try {
session = await refreshSession(cookie!, session);
} catch (err) {
// Couldn't ask the server. The token may still have a few minutes; if
// not, the call itself will say so.
console.warn("[ihasmail] token refresh failed:", (err as Error).message);
}
if (!session) {
deleteCookie(c, config.cookieName, { path: cookiePath });
return c.json({ error: "unauthenticated" }, 401);
}
}
if (!session) {
return c.json({ error: "unauthenticated" }, 401);
}
@@ -237,6 +254,55 @@ const requireSession: MiddlewareHandler<Env> = async (c, next) => {
await next();
};
/*
* One refresh per session at a time: a page opening does several requests at
* once, and each would otherwise renew the same token.
*/
const refreshing = new Map<string, Promise<LiveSession | null>>();
/**
* Renew an OAuth session's access token and keep the new one. Null when the
* server refused the refresh token (a password change revokes it), which
* ends the session.
*/
function refreshSession(cookie: string, session: LiveSession): Promise<LiveSession | null> {
let inFlight = refreshing.get(session.id);
if (!inFlight) {
inFlight = (async () => {
const renewed = await refreshTokens(upstreamFor(session.username), session.tokens!);
if (!renewed) {
sessions.destroy(session.id);
forgetUpstreamSession(session.id);
return null;
}
sessions.updateTokens(cookie, renewed);
return sessions.resolve(cookie);
})().finally(() => refreshing.delete(session.id));
refreshing.set(session.id, inFlight);
}
return inFlight;
}
/**
* What push keeps to renew an account's subscription long after the session
* that started it. A password is good until it changes; OAuth tokens get a
* copy that renews itself, since push outlives any one access token.
*/
export function pushCredential(session: LiveSession): { get(): Promise<string> } {
if (!session.tokens) {
const authorization = session.authorization;
return { get: async () => authorization };
}
let tokens: TokenSet = session.tokens;
const base = upstreamFor(session.username);
return {
async get() {
if (needsRefresh(tokens)) tokens = (await refreshTokens(base, tokens)) ?? tokens;
return `Bearer ${tokens.access}`;
},
};
}
/**
* Scope the session cookie to the mount, not the whole host.
*
@@ -316,11 +382,85 @@ export function createApp(basePath = config.basePath): Hono<Env> {
/* Sent before sign-in like the rest of this: it says what the
installation has decided, not anything about who is asking. */
settingsPolicy: config.settingsPolicy,
/* "oauth": sign in on the mail server's own page (see oauth.ts). */
signIn: oauthEnabled() ? "oauth" : "password",
}),
);
// ---------- Auth ----------
/*
* Sign-in through the mail server's page. `start` sends the browser there;
* `callback` is where the server sends it back. See oauth.ts.
*/
const OAUTH_STATE_COOKIE = `${config.cookieName}_signin`;
api.get("/auth/oauth/start", async (c) => {
if (!oauthEnabled()) return c.json({ error: "not_found" }, 404);
const rateIp = rateLimitKey(clientIp(c));
if (!loginFloodLimiter.check(rateIp)) {
c.header("Retry-After", String(loginFloodLimiter.retryAfterSeconds(rateIp)));
return c.redirect(`${basePath}/?signin_error=rate_limited`, 302);
}
const username = (c.req.query("username") ?? "").trim().slice(0, 320);
try {
const { location, state } = await startSignIn({ username, base: upstreamFor(username), remember: c.req.query("remember") === "1" });
setCookie(c, OAUTH_STATE_COOKIE, state, { httpOnly: true, sameSite: "Lax", secure: isSecureRequest(c), path: `${basePath}/api/auth`, maxAge: 600 });
return c.redirect(location, 302);
} catch (err) {
console.warn("[ihasmail] could not start sign-in:", (err as Error).message);
return c.redirect(`${basePath}/?signin_error=unavailable`, 302);
}
});
api.get("/auth/callback", async (c) => {
if (!oauthEnabled()) return c.json({ error: "not_found" }, 404);
const boundState = getCookie(c, OAUTH_STATE_COOKIE);
deleteCookie(c, OAUTH_STATE_COOKIE, { path: `${basePath}/api/auth` });
const fail = (code: string) => c.redirect(`${basePath}/?signin_error=${code}`, 302);
const rateIp = rateLimitKey(clientIp(c));
if (!loginFloodLimiter.check(rateIp)) return fail("rate_limited");
const state = c.req.query("state") ?? "";
const code = c.req.query("code") ?? "";
// The server's page sends `error` when the person cancels or is refused.
if (!code || c.req.query("error")) return fail("cancelled");
let result;
try {
result = await finishSignIn({ state, boundState, code });
} catch (err) {
if (err instanceof SignInError) return fail(err.code);
console.warn("[ihasmail] sign-in exchange failed:", (err as Error).message);
return fail("unavailable");
}
const authorization = `Bearer ${result.tokens.access}`;
try {
const upstream = await fetchUpstreamSession(authorization, result.base);
if (!hasStalwartRegistry(upstream)) return fail("unsupported_server");
const username = upstream.username || result.username;
// Every later call finds the account's server from its name. If the
// server signed in an account that routes elsewhere, calls would go to
// the wrong server, so refuse it.
if (upstreamFor(username) !== result.base) return fail("wrong_account");
const { cookie, session } = sessions.create({
username,
account: accountKey(result.base, username),
tokens: result.tokens,
remember: result.remember,
userAgent: c.req.header("user-agent") ?? "",
ip: clientIp(c),
});
setSessionCookie(c, cookie, session.remember);
const mailAccount = upstream.primaryAccounts?.["urn:ietf:params:jmap:mail"];
if (mailAccount) pushPrepare(session.username, mailAccount, pushCredential(session));
return c.redirect(`${basePath}/`, 302);
} catch (err) {
console.warn("[ihasmail] sign-in failed after the exchange:", (err as Error).message);
return fail("unavailable");
}
});
api.post("/auth/login", async (c) => {
// With sign-in on the mail server's page, this form never sees a password.
if (oauthEnabled()) return c.json({ error: "oauth_required", message: "Sign in on the mail server's page." }, 403);
const ip = clientIp(c);
// What the limits count under: the address, or its /64 for IPv6.
const rateIp = rateLimitKey(ip);
@@ -396,7 +536,7 @@ export function createApp(basePath = config.basePath): Hono<Env> {
// Start the account's push subscription now, so it is usually verified
// by the time the browser opens its stream. See push.ts.
const mailAccount = upstream.primaryAccounts?.["urn:ietf:params:jmap:mail"];
if (mailAccount) pushPrepare(session.username, mailAccount, session.authorization);
if (mailAccount) pushPrepare(session.username, mailAccount, pushCredential(session));
const info = await getAccountInfo(session.id, session.authorization, upstream);
return c.json(localizeSession(upstream, sessionExtras(session, info)));
} catch (err) {
@@ -535,6 +675,14 @@ export function createApp(basePath = config.basePath): Hono<Env> {
} catch (err) {
return accountFailure(c, err);
}
if (session.tokens) {
// The server revokes every token when the password changes, this
// session's included, so there is nothing to keep: sign in again.
forgetUpstreamSession(session.id);
const revoked = sessions.destroyAllForUser(session.account);
deleteCookie(c, config.cookieName, { path: cookiePath });
return c.json({ ok: true, revokedSessions: revoked - 1, signedOut: true });
}
// The old password is now dead: re-seal this session with the new one and
// drop the others, whose sealed copies would fail on their next call.
const otpCode = body.otpCode?.trim();
@@ -625,6 +773,16 @@ export function createApp(basePath = config.basePath): Hono<Env> {
} catch (err) {
return accountFailure(c, err);
}
if (session.tokens) {
// Signed in on the server's page, where two-factor is asked for, so
// nothing here needs moving onto an app password.
try {
await enableOtp(ctx, { url: body.url, code, current: body.current });
} catch (err) {
return accountFailure(c, err);
}
return c.json({ ok: true, ...(await afterCredentialChange(c, session)) });
}
let app: { id: string; secret: string } | null = null;
try {
app = await createAppPassword(ctx, { description: appPasswordName(c) });
@@ -663,6 +821,7 @@ export function createApp(basePath = config.basePath): Hono<Env> {
} catch (err) {
return accountFailure(c, err);
}
if (session.tokens) return c.json({ ok: true, ...(await afterCredentialChange(c, session)) });
// This session may be running on the app password minted when 2FA went on;
// the plain password works again now, so put it back.
sessions.reseal(getCookie(c, config.cookieName), body.current);
@@ -880,7 +1039,7 @@ export function createApp(basePath = config.basePath): Hono<Env> {
// relay, and is moved to fan-out the moment the account verifies.
const accountId = upstream.primaryAccounts?.["urn:ietf:params:jmap:mail"];
const out = (c.env as { outgoing: import("node:http").ServerResponse }).outgoing;
if (accountId && pushAttach(session.username, accountId, session.authorization, out)) {
if (accountId && pushAttach(session.username, accountId, pushCredential(session), out)) {
out.writeHead(200, SSE_HEADERS);
out.flushHeaders();
out.write(": subscribed\n\n");
@@ -957,6 +1116,16 @@ async function readJson<T>(c: Context): Promise<T | null> {
* server.
*/
async function confirmsPassword(session: LiveSession, candidate: string): Promise<boolean> {
if (session.tokens) {
// Holding no password, the only judge is the server.
try {
const authorization = `Basic ${Buffer.from(`${session.username}:${candidate}`, "utf8").toString("base64")}`;
await fetchUpstreamSession(authorization, upstreamFor(session.username));
return true;
} catch {
return false;
}
}
const decoded = Buffer.from(session.authorization.replace(/^Basic /, ""), "base64").toString("utf8");
const held = decoded.slice(decoded.indexOf(":") + 1);
if (safeEqual(held, candidate)) return true;
@@ -974,6 +1143,24 @@ async function confirmsPassword(session: LiveSession, candidate: string): Promis
}
}
/**
* After a two-factor change on a token session: whether the server still
* honors this session's token. If it revoked it, end the session here too,
* so the web app can send the person to sign in again.
*/
async function afterCredentialChange(c: Context<Env>, session: LiveSession): Promise<{ signedOut: boolean }> {
forgetUpstreamSession(session.id);
try {
await fetchUpstreamSession(session.authorization, upstreamFor(session.username));
return { signedOut: false };
} catch (err) {
if (!(err instanceof UpstreamError && err.status === 401)) return { signedOut: false };
sessions.destroyAllForUser(session.account);
deleteCookie(c, config.cookieName, { path: cookiePath });
return { signedOut: true };
}
}
/**
* Direction overrides and isolates, which can make `Invoice_\u202Efdp.exe`
* read as a PDF in the downloads list. A filename has no use for them.
@@ -999,6 +1186,8 @@ function sessionExtras(session: LiveSession, info: AccountInfo = { locale: null,
sessionId: session.id,
loginName: session.username,
remember: session.remember,
/** "oauth": signed in on the mail server's page, holding tokens, not a password. */
signIn: session.tokens ? "oauth" : "password",
/** Locale configured for the account in Stalwart's directory, if readable. */
userLocale: info.locale,
/**
+18
View File
@@ -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),
/**
+3
View File
@@ -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" });
+131
View File
@@ -0,0 +1,131 @@
/**
* The mock's OAuth side, enough to sign in the way INBUXA's server does:
* metadata, a sign-in page, and a token endpoint for one confidential client.
*
* The sign-in page approves the demo user at once: there is no form, since
* what's being exercised is ihasmail's side of the flow. Tokens are tied to
* the password they were issued under, so a password change revokes them,
* as it does on the real server.
*/
import { createHash, randomBytes } from "node:crypto";
import type { IncomingMessage, ServerResponse } from "node:http";
import { PORT, USER, account } from "./config.js";
export const OAUTH_CLIENT_ID = process.env.MOCK_OAUTH_CLIENT_ID ?? "ihasmail-inbuxa";
export const OAUTH_CLIENT_SECRET = process.env.MOCK_OAUTH_CLIENT_SECRET ?? "mock-oauth-secret";
/** Seconds an access token lasts. */
export let accessTokenTtl = Number(process.env.MOCK_OAUTH_TOKEN_TTL ?? 3600);
interface Grant { password: string }
const codes = new Map<string, { challenge: string; redirectUri: string; issuedAt: number }>();
const accessTokens = new Map<string, Grant & { expiresAt: number }>();
const refreshTokens = new Map<string, Grant>();
const base = () => `http://127.0.0.1:${PORT}`;
/** For tests: how long new access tokens last, and a way to end every token. */
export const oauthMock = {
setAccessTokenTtl(seconds: number) { accessTokenTtl = seconds; },
expireAccessTokens() { for (const t of accessTokens.values()) t.expiresAt = 0; },
reset() { codes.clear(); accessTokens.clear(); refreshTokens.clear(); accessTokenTtl = 3600; },
};
/** A bearer token the mock issued, still valid under the current password. */
export function checkBearer(header: string): boolean {
if (!header.startsWith("Bearer ")) return false;
const t = accessTokens.get(header.slice(7));
return Boolean(t && t.expiresAt > Date.now() && t.password === account.password);
}
function json(res: ServerResponse, status: number, body: unknown) {
res.writeHead(status, { "content-type": "application/json", "cache-control": "no-store" });
res.end(JSON.stringify(body));
}
function readForm(req: IncomingMessage): Promise<URLSearchParams> {
return new Promise((resolve) => {
const chunks: Buffer[] = [];
req.on("data", (c) => chunks.push(c));
req.on("end", () => resolve(new URLSearchParams(Buffer.concat(chunks).toString())));
});
}
function issue(res: ServerResponse, refresh: string | null) {
const access = `mock-at-${randomBytes(16).toString("hex")}`;
accessTokens.set(access, { password: account.password, expiresAt: Date.now() + accessTokenTtl * 1000 });
const body: Record<string, unknown> = { access_token: access, token_type: "bearer", expires_in: accessTokenTtl };
if (!refresh) {
const fresh = `mock-rt-${randomBytes(16).toString("hex")}`;
refreshTokens.set(fresh, { password: account.password });
body.refresh_token = fresh;
}
return json(res, 200, body);
}
/** Handles the OAuth routes; false for anything else. */
export async function handleOAuth(req: IncomingMessage, res: ServerResponse, url: URL): Promise<boolean> {
if (url.pathname === "/.well-known/oauth-authorization-server" && req.method === "GET") {
json(res, 200, {
issuer: base(),
authorization_endpoint: `${base()}/login`,
token_endpoint: `${base()}/auth/token`,
grant_types_supported: ["authorization_code", "refresh_token"],
response_types_supported: ["code"],
scopes_supported: ["openid", "offline_access"],
token_endpoint_auth_methods_supported: ["client_secret_post"],
code_challenge_methods_supported: ["S256"],
});
return true;
}
if (url.pathname === "/login" && req.method === "GET") {
const q = url.searchParams;
const redirectUri = q.get("redirect_uri") ?? "";
if (q.get("client_id") !== OAUTH_CLIENT_ID || q.get("response_type") !== "code" || !redirectUri || q.get("code_challenge_method") !== "S256") {
json(res, 400, { error: "invalid_request" });
return true;
}
const code = randomBytes(16).toString("hex");
codes.set(code, { challenge: q.get("code_challenge") ?? "", redirectUri, issuedAt: Date.now() });
const back = new URL(redirectUri);
back.searchParams.set("code", code);
back.searchParams.set("state", q.get("state") ?? "");
res.writeHead(302, { location: back.toString() });
res.end();
return true;
}
if (url.pathname === "/auth/token" && req.method === "POST") {
const form = await readForm(req);
if (form.get("client_id") !== OAUTH_CLIENT_ID || form.get("client_secret") !== OAUTH_CLIENT_SECRET) {
json(res, 400, { error: "invalid_client" });
return true;
}
if (form.get("grant_type") === "authorization_code") {
const code = codes.get(form.get("code") ?? "");
codes.delete(form.get("code") ?? "");
const verifier = form.get("code_verifier") ?? "";
const challenge = createHash("sha256").update(verifier).digest("base64url");
if (!code || code.challenge !== challenge || code.redirectUri !== form.get("redirect_uri") || Date.now() - code.issuedAt > 600_000) {
json(res, 400, { error: "invalid_grant" });
return true;
}
issue(res, null);
return true;
}
if (form.get("grant_type") === "refresh_token") {
const refresh = form.get("refresh_token") ?? "";
const grant = refreshTokens.get(refresh);
if (!grant || grant.password !== account.password) {
json(res, 400, { error: "invalid_grant" });
return true;
}
issue(res, refresh);
return true;
}
json(res, 400, { error: "unsupported_grant_type" });
return true;
}
return false;
}
/** The username the mock signs in, for tests. */
export const OAUTH_USER = USER;
+205
View File
@@ -0,0 +1,205 @@
import { test, before, after, beforeEach } from "node:test";
import assert from "node:assert/strict";
/**
* Signing in on the mail server's own page, end to end against the mock's
* OAuth side: the redirect out, the callback, the session holding tokens
* instead of a password, token renewal, and what ends a session.
*/
const PORT = 18811;
process.env.MOCK_PORT = String(PORT);
process.env.MOCK_USER = "[email protected]";
process.env.MOCK_PASS = "demo-password";
process.env.STALWART_URL = `http://127.0.0.1:${PORT}`;
process.env.APP_SECRET = "test-secret-for-oauth";
process.env.OAUTH_CLIENT_SECRET = "mock-oauth-secret";
process.env.PUBLIC_URL = "https://webmail.example.test";
const mock = await import("./mock/index.js");
const { oauthMock } = await import("./mock/oauth.js");
const { createApp, pushCredential, sessions } = await import("./app.js");
const { resetOAuthState } = await import("./oauth.js");
const app = createApp();
const CALLBACK = "https://webmail.example.test/api/auth/callback";
/** A cookie jar, since sign-in sets two cookies on different paths. */
let jar = new Map<string, string>();
function keepCookies(res: Response) {
for (const header of res.headers.getSetCookie()) {
const [pair, ...attrs] = header.split(";");
const [name, value] = [pair!.slice(0, pair!.indexOf("=")), pair!.slice(pair!.indexOf("=") + 1)];
const expired = attrs.some((a) => /max-age=0\b/i.test(a.trim()) || /expires=thu, 01 jan 1970/i.test(a.trim()));
if (expired || value === "") jar.delete(name);
else jar.set(name, value);
}
}
async function call(path: string, init: RequestInit = {}) {
const cookie = [...jar].map(([k, v]) => `${k}=${v}`).join("; ");
const res = await app.request(path, {
...init,
headers: { "content-type": "application/json", "x-requested-with": "ihasmail", ...(cookie ? { cookie } : {}), ...(init.headers as Record<string, string>) },
});
keepCookies(res);
return res;
}
async function jsonOf(res: Response) {
const text = await res.text();
return text ? JSON.parse(text) : null;
}
/** Leave for the server's page and come back: returns the callback URL. */
async function goToServerAndBack(username = "[email protected]"): Promise<URL> {
const start = await call(`/api/auth/oauth/start?username=${encodeURIComponent(username)}&remember=1`);
assert.equal(start.status, 302);
const signInPage = new URL(start.headers.get("location")!);
const approved = await fetch(signInPage, { redirect: "manual" });
assert.equal(approved.status, 302, "the mock's page approves the demo user");
return new URL(approved.headers.get("location")!);
}
async function signIn() {
const back = await goToServerAndBack();
const res = await call(`/api/auth/callback${back.search}`);
assert.equal(res.status, 302);
assert.equal(res.headers.get("location"), "/");
assert.ok(jar.get("ihm_session"), "a session cookie was set");
}
beforeEach(() => {
jar = new Map();
oauthMock.reset();
resetOAuthState();
});
before(() => {});
after(() => {
(mock as { server?: { close(): void } }).server?.close();
});
test("the configuration tells the web app to use the server's page", async () => {
const body = await jsonOf(await call("/api/config"));
assert.equal(body.signIn, "oauth");
});
test("the password form is refused: ihasmail never sees a password", async () => {
const res = await call("/api/auth/login", { method: "POST", body: JSON.stringify({ username: "[email protected]", password: "demo-password" }) });
assert.equal(res.status, 403);
assert.equal((await jsonOf(res)).error, "oauth_required");
});
test("start sends the browser to the server's page with PKCE and a bound state", async () => {
const res = await call("/api/auth/oauth/[email protected]");
assert.equal(res.status, 302);
const to = new URL(res.headers.get("location")!);
assert.equal(`${to.origin}${to.pathname}`, `http://127.0.0.1:${PORT}/login`);
assert.equal(to.searchParams.get("client_id"), "ihasmail-inbuxa");
assert.equal(to.searchParams.get("redirect_uri"), CALLBACK);
assert.equal(to.searchParams.get("response_type"), "code");
assert.equal(to.searchParams.get("code_challenge_method"), "S256");
assert.match(to.searchParams.get("code_challenge") ?? "", /^[\w-]{43}$/);
assert.equal(to.searchParams.get("login_hint"), "[email protected]");
assert.equal(to.searchParams.get("scope"), "openid offline_access");
assert.equal(jar.get("ihm_session_signin"), to.searchParams.get("state"), "the state is bound to this browser");
});
test("a full sign-in holds tokens, and the session works", async () => {
await signIn();
const res = await call("/api/auth/session");
assert.equal(res.status, 200);
const body = await jsonOf(res);
assert.equal(body.ihasmail.loginName, "[email protected]");
assert.equal(jar.get("ihm_session_signin"), undefined, "the state cookie is cleared");
});
test("the callback comes back cross-site, and is still accepted", async () => {
const back = await goToServerAndBack();
const res = await call(`/api/auth/callback${back.search}`, { headers: { "sec-fetch-site": "cross-site" } });
assert.equal(res.headers.get("location"), "/");
});
test("a callback from a sign-in this browser didn't start is refused", async () => {
const back = await goToServerAndBack();
jar.delete("ihm_session_signin");
const res = await call(`/api/auth/callback${back.search}`);
assert.equal(res.headers.get("location"), "/?signin_error=state_mismatch");
assert.equal(jar.get("ihm_session"), undefined);
});
test("a state is good for one attempt", async () => {
const back = await goToServerAndBack();
const state = jar.get("ihm_session_signin")!;
await call(`/api/auth/callback${back.search}`);
jar = new Map([["ihm_session_signin", state]]);
const again = await call(`/api/auth/callback${back.search}`);
assert.equal(again.headers.get("location"), "/?signin_error=state_mismatch");
});
test("cancelling on the server's page comes back as an error, not a session", async () => {
await call("/api/auth/oauth/[email protected]");
const state = jar.get("ihm_session_signin")!;
const res = await call(`/api/auth/callback?error=access_denied&state=${state}`);
assert.equal(res.headers.get("location"), "/?signin_error=cancelled");
assert.equal(jar.get("ihm_session"), undefined);
});
test("a code the server won't exchange is refused", async () => {
const back = await goToServerAndBack();
back.searchParams.set("code", "not-a-code");
const res = await call(`/api/auth/callback${back.search}`);
assert.equal(res.headers.get("location"), "/?signin_error=exchange_failed");
});
test("an access token about to expire is renewed without the person noticing", async () => {
oauthMock.setAccessTokenTtl(60); // inside the renewal margin from the start
await signIn();
oauthMock.expireAccessTokens(); // the one the session holds is now dead upstream
const res = await call("/api/auth/session?refresh=1");
assert.equal(res.status, 200, "renewed before the call went upstream");
});
test("renewal refused by the server ends the session", async () => {
await signIn();
const cookie = jar.get("ihm_session")!;
const session = sessions.resolve(cookie)!;
// Pretend the token is about to expire, then make the server refuse to renew it.
sessions.updateTokens(cookie, { ...session.tokens!, expiresAt: Date.now() + 1000, refresh: "revoked" });
const res = await call("/api/auth/session");
assert.equal(res.status, 401);
assert.equal(jar.get("ihm_session"), undefined, "and the cookie is cleared");
});
test("a password change signs the session out, since the server revokes its tokens", async () => {
await signIn();
const res = await call("/api/account/password", { method: "POST", body: JSON.stringify({ current: "demo-password", next: "new-password-123" }) });
assert.equal(res.status, 200);
assert.equal((await jsonOf(res)).signedOut, true);
assert.equal((await call("/api/auth/session")).status, 401);
// Put it back for the tests after this one.
const { account } = await import("./mock/config.js");
account.password = "demo-password";
});
test("creating an app password checks the typed password with the server", async () => {
await signIn();
const wrong = await call("/api/account/app-passwords", { method: "POST", body: JSON.stringify({ description: "Phone", current: "nope" }) });
assert.equal(wrong.status, 403);
const right = await call("/api/account/app-passwords", { method: "POST", body: JSON.stringify({ description: "Phone", current: "demo-password" }) });
assert.equal(right.status, 200);
});
test("push keeps a credential that renews itself", async () => {
oauthMock.setAccessTokenTtl(60);
await signIn();
const session = sessions.resolve(jar.get("ihm_session"))!;
const credential = pushCredential(session);
const first = await credential.get();
oauthMock.expireAccessTokens();
const second = await credential.get();
assert.notEqual(second, first, "a fresh access token");
assert.match(second, /^Bearer mock-at-/);
});
+197
View File
@@ -0,0 +1,197 @@
/**
* Signing in through the mail server's own page (OAuth 2.0 authorization code
* with PKCE), so ihasmail never handles a password to sign someone in.
*
* The flow, with ihasmail as a confidential client registered on the server:
*
* 1. `start()` picks the account's server from the username, reads the
* server's OAuth metadata, and sends the browser to its sign-in page with
* a PKCE challenge and a one-time `state`. The state is bound to the
* browser by a short-lived cookie, so a callback carrying somebody else's
* code can't sign this browser into their account.
* 2. The person signs in there, two-factor included, and the server sends the
* browser back to `/api/auth/callback` with a code.
* 3. `finish()` checks the state, exchanges the code (with the PKCE verifier
* and this client's secret) for an access and a refresh token, and the
* session keeps those, sealed, instead of a password.
*
* Access tokens last an hour; `refreshTokens()` renews them before they run
* out. A password change on the server revokes both tokens, which ends every
* session holding them -- the safe result, and the one the web app is told
* about.
*
* Nothing here is taken from another client's implementation; the shapes are
* RFC 6749, RFC 7636 and RFC 8414.
*/
import { createHash } from "node:crypto";
import { config } from "./config.js";
import { randomToken } from "./crypto.js";
import { UpstreamError, absoluteUpstream } from "./upstream.js";
export interface TokenSet {
access: string;
refresh: string | null;
/** When the access token expires, in ms since the epoch. */
expiresAt: number;
}
interface Metadata {
authorizationEndpoint: string;
tokenEndpoint: string;
scopes: string[];
}
/** Renew an access token this long before it expires. */
export const REFRESH_MARGIN_MS = 5 * 60_000;
/** How long a sign-in may take between leaving and coming back. */
const PENDING_TTL_MS = 10 * 60_000;
const METADATA_TTL_MS = 60 * 60_000;
const MAX_PENDING = 10_000;
export function oauthEnabled(): boolean {
return Boolean(config.oauthClientSecret);
}
/** The one redirect URI registered for this client on the server. */
export function redirectUri(): string {
return `${config.publicUrl}${config.basePath}/api/auth/callback`;
}
const metadataCache = new Map<string, { metadata: Metadata; fetchedAt: number }>();
async function metadataFor(base: string): Promise<Metadata> {
const cached = metadataCache.get(base);
if (cached && Date.now() - cached.fetchedAt < METADATA_TTL_MS) return cached.metadata;
const res = await fetch(`${base}/.well-known/oauth-authorization-server`, {
headers: { accept: "application/json" },
signal: AbortSignal.timeout(config.upstreamTimeout),
});
if (!res.ok) throw new UpstreamError(`OAuth metadata request failed (${res.status})`, 502);
const doc = (await res.json()) as { authorization_endpoint?: string; token_endpoint?: string; scopes_supported?: string[] };
if (!doc.authorization_endpoint || !doc.token_endpoint) {
throw new UpstreamError("The mail server's OAuth metadata has no authorization or token endpoint", 502);
}
const metadata = {
// Where the *browser* goes, so the server's public address, as advertised.
authorizationEndpoint: new URL(doc.authorization_endpoint, base).toString(),
// Where this process goes, so the configured route, like every other call.
tokenEndpoint: absoluteUpstream(doc.token_endpoint, base),
scopes: doc.scopes_supported ?? [],
};
metadataCache.set(base, { metadata, fetchedAt: Date.now() });
return metadata;
}
interface Pending {
verifier: string;
base: string;
username: string;
remember: boolean;
createdAt: number;
}
const pending = new Map<string, Pending>();
function sweepPending(now = Date.now()) {
for (const [state, p] of pending) if (now - p.createdAt > PENDING_TTL_MS) pending.delete(state);
}
function challengeOf(verifier: string): string {
return createHash("sha256").update(verifier).digest("base64url");
}
/**
* Begin a sign-in. Returns where to send the browser, and the state to bind
* to it in a cookie.
*/
export async function start(params: { username: string; base: string; remember: boolean }): Promise<{ location: string; state: string }> {
const metadata = await metadataFor(params.base);
sweepPending();
if (pending.size >= MAX_PENDING) throw new UpstreamError("Too many sign-ins in progress", 503);
const state = randomToken(24);
const verifier = randomToken(48);
pending.set(state, { verifier, base: params.base, username: params.username, remember: params.remember, createdAt: Date.now() });
const scope = ["openid", "offline_access"].filter((s) => metadata.scopes.length === 0 || metadata.scopes.includes(s)).join(" ");
const url = new URL(metadata.authorizationEndpoint);
url.searchParams.set("response_type", "code");
url.searchParams.set("client_id", config.oauthClientId);
url.searchParams.set("redirect_uri", redirectUri());
if (scope) url.searchParams.set("scope", scope);
url.searchParams.set("state", state);
url.searchParams.set("code_challenge", challengeOf(verifier));
url.searchParams.set("code_challenge_method", "S256");
if (params.username) url.searchParams.set("login_hint", params.username);
return { location: url.toString(), state };
}
export class SignInError extends Error {
constructor(readonly code: "state_mismatch" | "expired" | "denied" | "exchange_failed", message: string) {
super(message);
}
}
/**
* Finish a sign-in: `state` as it came back in the URL, `boundState` as the
* browser's cookie holds it. Each state is good for one attempt.
*/
export async function finish(params: { state: string; boundState: string | undefined; code: string }): Promise<{ tokens: TokenSet; base: string; username: string; remember: boolean }> {
const p = pending.get(params.state);
if (!p || !params.boundState || params.boundState !== params.state) {
throw new SignInError("state_mismatch", "This sign-in didn't start in this browser. Try again.");
}
pending.delete(params.state);
if (Date.now() - p.createdAt > PENDING_TTL_MS) throw new SignInError("expired", "The sign-in took too long. Try again.");
const metadata = await metadataFor(p.base);
const tokens = await tokenRequest(metadata.tokenEndpoint, {
grant_type: "authorization_code",
code: params.code,
code_verifier: p.verifier,
redirect_uri: redirectUri(),
});
if (!tokens) throw new SignInError("exchange_failed", "The mail server didn't accept the sign-in. Try again.");
return { tokens, base: p.base, username: p.username, remember: p.remember };
}
/**
* Renew an access token. Null when the server refuses the refresh token --
* revoked by a password change, expired, or the client's secret changed --
* which ends the session. Throws when the server couldn't be asked.
*/
export async function refreshTokens(base: string, tokens: TokenSet): Promise<TokenSet | null> {
if (!tokens.refresh) return null;
const metadata = await metadataFor(base);
const renewed = await tokenRequest(metadata.tokenEndpoint, { grant_type: "refresh_token", refresh_token: tokens.refresh });
// The server hands out a new refresh token only when the old one is close
// to expiring; otherwise the old one stays good.
return renewed && { ...renewed, refresh: renewed.refresh ?? tokens.refresh };
}
async function tokenRequest(endpoint: string, fields: Record<string, string>): Promise<TokenSet | null> {
const res = await fetch(endpoint, {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" },
body: new URLSearchParams({ ...fields, client_id: config.oauthClientId, client_secret: config.oauthClientSecret }),
signal: AbortSignal.timeout(config.upstreamTimeout),
});
if (res.status === 400 || res.status === 401) return null;
if (!res.ok) throw new UpstreamError(`Token request failed (${res.status})`, 502);
const body = (await res.json()) as { access_token?: string; refresh_token?: string; expires_in?: number; token_type?: string };
if (!body.access_token || (body.token_type && body.token_type.toLowerCase() !== "bearer")) {
throw new UpstreamError("The mail server returned no usable access token", 502);
}
return {
access: body.access_token,
refresh: body.refresh_token ?? null,
expiresAt: Date.now() + (body.expires_in ?? 3600) * 1000,
};
}
export function needsRefresh(tokens: TokenSet, now = Date.now()): boolean {
return tokens.expiresAt - now < REFRESH_MARGIN_MS;
}
/** For tests. */
export function resetOAuthState(): void {
pending.clear();
metadataCache.clear();
}
+13 -10
View File
@@ -5,6 +5,9 @@ process.env.STALWART_URL = "http://127.0.0.1:1";
process.env.PUSH_URL = "https://ihasmail.example";
const push = await import("./push.js");
/** A fixed credential, as a password session hands push. */
const cred = (authorization: string) => ({ get: async () => authorization });
// Nothing in this file may reach the network. Background subscribe() calls
// outlive the test that started them, so the stub stays in place for the
// whole file rather than per test; the per-test stubs below layer on top.
@@ -46,7 +49,7 @@ test("a tab opened before verification gets no fan-out, and a subscription is st
const restore = stubUpstream();
try {
const out = fakeOut();
const entry = push.attach("[email protected]", "a", "Basic x", out as never);
const entry = push.attach("[email protected]", "a", cred("Basic x"), out as never);
assert.equal(entry, null, "not verified yet, so the tab must keep its own relay");
await new Promise((r) => setTimeout(r, 30));
const st = push.pushStatus();
@@ -59,7 +62,7 @@ test("verification then fan-out: one POST reaches every open tab for the account
try {
// First contact starts the subscription; wait for the stubbed create to land.
const first = fakeOut();
push.attach("[email protected]", "a", "Basic y", first as never);
push.attach("[email protected]", "a", cred("Basic y"), first as never);
await new Promise((r) => setTimeout(r, 30));
// Find the token Stalwart would have been given, the way Stalwart learns it: from the subscribe call.
// We cannot read it back through the public API, so verify via the status transition instead:
@@ -75,7 +78,7 @@ test("a StateChange is written to attached tabs as an SSE frame, and closed tabs
const restore = stubUpstream();
try {
const out1 = fakeOut(), out2 = fakeOut();
push.attach("[email protected]", "a", "Basic z", out1 as never);
push.attach("[email protected]", "a", cred("Basic z"), out1 as never);
await new Promise((r) => setTimeout(r, 30));
// Verify by handing the module its own token: pushStatus does not expose it, so read it from the
// subscribe request the stub saw. Simplest faithful route: capture the URL Stalwart would POST to.
@@ -88,14 +91,14 @@ test("a StateChange is written to attached tabs as an SSE frame, and closed tabs
return real(input, init);
}) as typeof fetch;
// Force a renewal-style subscribe so the URL passes through the capturing fetch.
push.attach("[email protected]", "a", "Basic w", out1 as never);
push.attach("[email protected]", "a", cred("Basic w"), out1 as never);
await new Promise((r) => setTimeout(r, 30));
globalThis.fetch = real;
assert.ok(token, "the subscribe call carries the push URL with the token");
assert.equal(await push.receive(token!, { "@type": "PushVerification", verificationCode: "v" }), 200);
const entry = push.attach("[email protected]", "a", "Basic w", out1 as never);
const entry = push.attach("[email protected]", "a", cred("Basic w"), out1 as never);
assert.ok(entry, "verified: the tab is served by fan-out");
push.attach("[email protected]", "a", "Basic w", out2 as never);
push.attach("[email protected]", "a", cred("Basic w"), out2 as never);
assert.equal(await push.receive(token!, { "@type": "StateChange", changed: { a: { Email: "s1" } } }), 200);
assert.match(out1.written.at(-1) ?? "", /^event: state\ndata: \{"@type":"StateChange"/);
assert.equal(out2.written.length, 1);
@@ -119,12 +122,12 @@ test("a tab on the relay is moved to fan-out when its account verifies, and its
if (m) token = m[1];
return real(input, init);
}) as typeof fetch;
push.prepare("[email protected]", "a", "Basic m"); // sign-in starts the subscription
push.prepare("[email protected]", "a", cred("Basic m")); // sign-in starts the subscription
await new Promise((r) => setTimeout(r, 30));
globalThis.fetch = real;
assert.ok(token);
const out = fakeOut(); let dropped = 0;
assert.equal(push.attach("[email protected]", "a", "Basic m", out as never), null, "not yet verified: relay");
assert.equal(push.attach("[email protected]", "a", cred("Basic m"), out as never), null, "not yet verified: relay");
push.attachRelay("[email protected]", out as never, () => { dropped++; });
assert.equal(push.pushStatus().tabs.relay >= 1, true);
assert.equal(await push.receive(token!, { "@type": "PushVerification", verificationCode: "v" }), 200);
@@ -168,7 +171,7 @@ test("a new subscription clears what this installation left behind, and only tha
}) as typeof fetch;
try {
// The installation's prefix, learned the way the server makes it: from its first create.
push.prepare("[email protected]", "a", "Basic p");
push.prepare("[email protected]", "a", cred("Basic p"));
await new Promise((r) => setTimeout(r, 30));
const firstCreate = calls.find(([n, a]) => n === "PushSubscription/set" && a.create);
const deviceId = String(firstCreate?.[1].deviceClientId ?? "");
@@ -176,7 +179,7 @@ test("a new subscription clears what this installation left behind, and only tha
ownPrefix = deviceId.slice(0, deviceId.lastIndexOf("-") + 1);
calls.length = 0;
push.prepare("[email protected]", "a", "Basic r");
push.prepare("[email protected]", "a", cred("Basic r"));
await new Promise((r) => setTimeout(r, 30));
const destroyed = calls.filter(([n, a]) => n === "PushSubscription/set" && a.destroy).flatMap(([, a]) => a.destroy as string[]);
assert.deepEqual(destroyed, ["mine-before"], "only this installation's leftover goes");
+18 -8
View File
@@ -39,7 +39,7 @@ interface AccountPush {
accountId: string;
base: string;
token: string; // what Stalwart puts in the URL
authorization: string; // one live session's credential, for set/verify/renew
credential: PushCredential; // one live session's credential, for set/verify/renew
subscriptionId: string | null;
state: "pending" | "verified" | "failed";
since: number;
@@ -49,6 +49,15 @@ interface AccountPush {
relays: Map<ServerResponse, () => void>;
}
/**
* How push authenticates its own calls. A password session's is fixed; an
* OAuth session's renews its access token itself, since a subscription lives
* for days and an access token for an hour. See pushCredential() in app.ts.
*/
export interface PushCredential {
get(): Promise<string>;
}
const byKey = new Map<string, AccountPush>();
const byToken = new Map<string, AccountPush>();
let sweeper: NodeJS.Timeout | null = null;
@@ -60,10 +69,11 @@ export function pushEnabled(): boolean {
function keyFor(base: string, username: string) { return `${base} ${username}`; }
async function jmap(entry: AccountPush, calls: unknown[]) {
const upstream = await getUpstreamSession(entry.key, entry.authorization, entry.base);
const authorization = await entry.credential.get();
const upstream = await getUpstreamSession(entry.key, authorization, entry.base);
const res = await fetch(absoluteUpstream(upstream.apiUrl, upstream.baseUrl), {
method: "POST",
headers: { authorization: entry.authorization, "content-type": "application/json", accept: "application/json" },
headers: { authorization, "content-type": "application/json", accept: "application/json" },
body: JSON.stringify({ using: USING, methodCalls: calls }),
signal: AbortSignal.timeout(config.upstreamTimeout),
});
@@ -159,14 +169,14 @@ async function unsubscribe(entry: AccountPush) {
* by the time the browser opens its stream the verification is usually
* already in flight, and called again by attach() as a safety net.
*/
export function prepare(username: string, accountId: string, authorization: string): AccountPush | null {
export function prepare(username: string, accountId: string, credential: PushCredential): AccountPush | null {
if (!pushEnabled()) return null;
const base = upstreamFor(username);
const key = keyFor(base, username);
let entry = byKey.get(key);
if (!entry) {
entry = { key, username, accountId, base, token: randomBytes(32).toString("base64url"),
authorization, subscriptionId: null, state: "pending", since: Date.now(), expires: 0, tabs: new Set(), relays: new Map() };
credential, subscriptionId: null, state: "pending", since: Date.now(), expires: 0, tabs: new Set(), relays: new Map() };
byKey.set(key, entry); byToken.set(entry.token, entry);
subscribe(entry).catch((err) => {
entry!.state = "failed";
@@ -174,7 +184,7 @@ export function prepare(username: string, accountId: string, authorization: stri
});
startSweeper();
} else {
entry.authorization = authorization; // keep a live credential for renewals
entry.credential = credential; // keep a live credential for renewals
}
return entry;
}
@@ -183,8 +193,8 @@ export function prepare(username: string, accountId: string, authorization: stri
* Called when a tab opens. Returns the account's push entry if the tab can
* be served by fan-out right now, or null if it must hold its own relay.
*/
export function attach(username: string, accountId: string, authorization: string, out: ServerResponse): AccountPush | null {
const entry = prepare(username, accountId, authorization);
export function attach(username: string, accountId: string, credential: PushCredential, out: ServerResponse): AccountPush | null {
const entry = prepare(username, accountId, credential);
if (!entry || entry.state !== "verified") return null;
entry.tabs.add(out);
out.on("close", () => { entry.tabs.delete(out); });
+39 -11
View File
@@ -3,6 +3,7 @@ import { dirname } from "node:path";
import { randomBytes } from "node:crypto";
import { config } from "./config.js";
import { deriveKey, open, randomToken, safeEqual, seal, sha256 } from "./crypto.js";
import type { TokenSet } from "./oauth.js";
export interface StoredSession {
id: string;
@@ -10,7 +11,7 @@ export interface StoredSession {
secretHash: string;
/** base64 random salt for key derivation */
salt: string;
/** sealed JSON {username, password} */
/** sealed JSON: `{u, p}` for a password, `{u, t}` for OAuth tokens (see oauth.ts) */
sealedCredentials: string;
username: string;
/** Which account this is; see `accountKey`. Absent on sessions saved before it existed. */
@@ -28,8 +29,10 @@ export interface LiveSession {
username: string;
/** See `accountKey`. */
account: string;
/** Basic Authorization header value for upstream calls. */
/** Authorization header value for upstream calls: Basic, or Bearer for OAuth. */
authorization: string;
/** The OAuth tokens behind `authorization`, or null for a password session. */
tokens: TokenSet | null;
remember: boolean;
createdAt: number;
lastSeenAt: number;
@@ -71,7 +74,9 @@ export interface CreateSessionParams {
username: string;
/** From `accountKey`; defaults to the lower-cased username. */
account?: string;
password: string;
/** Exactly one of `password` and `tokens`. */
password?: string;
tokens?: TokenSet;
remember: boolean;
userAgent: string;
ip: string;
@@ -107,6 +112,8 @@ export interface SessionBackend {
create(params: CreateSessionParams): { cookie: string; session: LiveSession };
resolve(cookie: string | undefined): LiveSession | null;
reseal(cookie: string | undefined, password: string): boolean;
/** Store renewed OAuth tokens in place of the ones the session holds. */
updateTokens(cookie: string | undefined, tokens: TokenSet): boolean;
destroy(id: string): void;
/** `account` is an `accountKey`, as carried on `LiveSession.account`. */
destroyAllForUser(account: string, exceptId?: string): number;
@@ -115,6 +122,15 @@ export interface SessionBackend {
const COOKIE_SEP = ".";
/** What a session seals: a password, or OAuth tokens. */
type Sealed = { u: string; p: string } | { u: string; t: TokenSet };
function sealable(username: string, params: { password?: string; tokens?: TokenSet }): Sealed {
if (params.tokens) return { u: username, t: params.tokens };
if (params.password !== undefined) return { u: username, p: params.password };
throw new Error("a session needs a password or tokens");
}
export class SessionStore implements SessionBackend {
private sessions = new Map<string, StoredSession>();
private dirty = false;
@@ -194,7 +210,7 @@ export class SessionStore implements SessionBackend {
id,
secretHash: sha256(secret),
salt: salt.toString("base64"),
sealedCredentials: seal(JSON.stringify({ u: params.username, p: params.password }), key),
sealedCredentials: seal(JSON.stringify(sealable(params.username, params)), key),
username: params.username,
account: params.account ?? params.username.trim().toLowerCase(),
createdAt: now,
@@ -207,7 +223,7 @@ export class SessionStore implements SessionBackend {
this.sessions.set(id, stored);
this.scheduleSave();
const cookie = `${id}${COOKIE_SEP}${secret}`;
return { cookie, session: this.toLive(stored, params.username, params.password) };
return { cookie, session: this.toLive(stored, sealable(params.username, params)) };
}
/** Resolve a cookie to a live session (with decrypted upstream credentials). */
@@ -229,9 +245,9 @@ export class SessionStore implements SessionBackend {
const key = deriveKey(secret, config.appSecret, Buffer.from(stored.salt, "base64"));
const json = open(stored.sealedCredentials, key);
if (!json) return null;
let creds: { u: string; p: string };
let creds: Sealed;
try {
creds = JSON.parse(json) as { u: string; p: string };
creds = JSON.parse(json) as Sealed;
} catch {
return null;
}
@@ -242,7 +258,7 @@ export class SessionStore implements SessionBackend {
stored.expiresAt = now + ttl;
this.scheduleSave();
}
return this.toLive(stored, creds.u, creds.p);
return this.toLive(stored, creds);
}
/**
@@ -255,6 +271,14 @@ export class SessionStore implements SessionBackend {
* secret half of it, which the server never keeps.
*/
reseal(cookie: string | undefined, password: string): boolean {
return this.rewrite(cookie, (username) => ({ u: username, p: password }));
}
updateTokens(cookie: string | undefined, tokens: TokenSet): boolean {
return this.rewrite(cookie, (username) => ({ u: username, t: tokens }));
}
private rewrite(cookie: string | undefined, next: (username: string) => Sealed): boolean {
if (!cookie) return false;
const idx = cookie.indexOf(COOKIE_SEP);
if (idx <= 0) return false;
@@ -264,7 +288,7 @@ export class SessionStore implements SessionBackend {
if (!stored) return false;
if (!safeEqual(stored.secretHash, sha256(secret))) return false;
const key = deriveKey(secret, config.appSecret, Buffer.from(stored.salt, "base64"));
stored.sealedCredentials = seal(JSON.stringify({ u: stored.username, p: password }), key);
stored.sealedCredentials = seal(JSON.stringify(next(stored.username)), key);
this.scheduleSave();
return true;
}
@@ -295,12 +319,16 @@ export class SessionStore implements SessionBackend {
return out;
}
private toLive(s: StoredSession, username: string, password: string): LiveSession {
private toLive(s: StoredSession, creds: Sealed): LiveSession {
const username = creds.u;
return {
id: s.id,
username,
account: accountOf(s),
authorization: `Basic ${Buffer.from(`${username}:${password}`, "utf8").toString("base64")}`,
authorization: "t" in creds
? `Bearer ${creds.t.access}`
: `Basic ${Buffer.from(`${username}:${creds.p}`, "utf8").toString("base64")}`,
tokens: "t" in creds ? creds.t : null,
remember: s.remember,
createdAt: s.createdAt,
lastSeenAt: s.lastSeenAt,
+2
View File
@@ -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;
+11
View File
@@ -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}",
+11
View File
@@ -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}",
+11
View File
@@ -1220,6 +1220,17 @@ export const catalog: Catalog = {
"Could not save filters: {error}": "Impossible denregistrer les filtres : {error}",
"Could not send the receipt: {error}": "Impossible denvoyer laccusé 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 na 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 na 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 ladresse saisie. Connectez-vous avec cette adresse.",
"This mail server isn't supported.": "Ce serveur de messagerie nest 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 denregistrer limage : {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}",
+11
View File
@@ -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}",
+11
View File
@@ -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}",
+11
View File
@@ -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}",
+11
View File
@@ -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}",
+11
View File
@@ -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}",
+11
View File
@@ -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}",
+62 -3
View File
@@ -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<string | null>(null);
const [error, setError] = useState<string | null>(() => 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() {
<label htmlFor="u">{t("Email or username")}</label>
<input id="u" className="input" type="text" autoComplete="username" autoCapitalize="none" autoCorrect="off" spellCheck={false} value={username} onChange={(e) => setUsername(e.target.value)} autoFocus={!username} required />
</div>
{signIn === "oauth" ? (
<p className="hint" style={{ marginBottom: 12 }}>{t("You'll enter your password on your mail server's sign-in page.")}</p>
) : (
<div className="field">
<label htmlFor="p">{t("Password")}</label>
<div className="pw-wrap">
@@ -96,6 +116,7 @@ export function LoginPage() {
</button>
</div>
</div>
)}
<label className="check" style={{ marginBottom: 4 }}>
<input type="checkbox" checked={trustDevice} onChange={(e) => setTrustDevice(e.target.checked)} />
<span>{t("This is my own device")}</span>
@@ -105,7 +126,7 @@ export function LoginPage() {
? "Stay signed in, and keep settings and recent addresses on this computer."
: "Signed out after 5 minutes of inactivity, and nothing is kept on this computer. Leave this unticked on a shared or public one."}
</p>
<button className="btn btn-primary btn-lg btn-block" type="submit" disabled={busy}>
<button className="btn btn-primary btn-lg btn-block" type="submit" disabled={busy || signIn === null}>
{busy ? <span className="spinner" style={{ borderTopColor: "#fff" }} /> : <LogIn size={18} />}
{busy ? "Signing in…" : "Sign in"}
</button>
@@ -130,3 +151,41 @@ export function LoginPage() {
</div>
);
}
/*
* Why the sign-in page is showing, when something sent it here: an error the
* mail server's page came back with (`?signin_error=`), or a notice left by a
* change that signed this session out. Read once, then removed, so a reload
* doesn't repeat it.
*/
const SIGNIN_ERROR_LABELS: Record<string, string> = {
state_mismatch: "This sign-in didn't start in this browser. Try again.",
expired: "The sign-in took too long. Try again.",
cancelled: "Sign-in was cancelled.",
exchange_failed: "The mail server didn't accept the sign-in. Try again.",
wrong_account: "That account is on a different mail server than the address you entered. Sign in with that address.",
unsupported_server: "This mail server isn't supported.",
unavailable: "Couldn't reach the mail server. Try again in a moment.",
rate_limited: "Too many attempts. Please wait a few minutes and try again.",
password_changed: "Your password was changed. Sign in with the new one.",
signed_out: "Your sign-in ended. Sign in again.",
};
export const SIGNIN_NOTICE_KEY = "ihasmail:signinNotice";
function takeSignInNotice(): string | null {
let code: string | null = null;
try {
code = sessionStorage.getItem(SIGNIN_NOTICE_KEY);
sessionStorage.removeItem(SIGNIN_NOTICE_KEY);
} catch { /* storage may be unavailable */ }
const url = new URL(window.location.href);
const fromUrl = url.searchParams.get("signin_error");
if (fromUrl) {
code = fromUrl;
url.searchParams.delete("signin_error");
window.history.replaceState(null, "", url.toString());
}
if (!code) return null;
return t(SIGNIN_ERROR_LABELS[code] ?? "Could not sign in.");
}
+5 -21
View File
@@ -18,30 +18,14 @@ const PAGE_SIZE = 50;
* Tenants: separate organizations on one server, each with its own people,
* domains and limits.
*
* The section is offered to whoever may read tenants. On a server that does not
* report Enterprise -- or reports no edition -- the page is only a notice that
* tenants are an Enterprise feature: tenants there hold nobody to anything
* beyond an ordinary user's permissions, so there is nothing worth creating or
* listing. On Enterprise the notice is left out, unless the installation asks
* for it (SHOW_ENTERPRISE_NOTICES), as the public demo does so as not to
* suggest tenants come without the license.
* The section is offered to whoever may read tenants. INBUXA ships tenants to
* everybody, whatever edition the server reports, so there is no edition
* check here (public ihasmail shows only a notice unless the server reports
* Enterprise). SHOW_ENTERPRISE_NOTICES still adds the notice, for talking to
* upstream Stalwart.
*/
export function TenantsAdmin({ selectedId }: { selectedId?: string }) {
const edition = useSession((s) => s.session?.ihasmail?.server?.edition ?? null);
const notices = useSession((s) => s.session?.ihasmail?.server?.enterpriseNotices === true);
if (edition !== "enterprise") {
return (
<div>
<div className="admin-head">
<div className="grow">
<h1>{t("Tenants")}</h1>
<p className="lead">{t("Separate organizations on one server, each with its own people, domains and limits.")}</p>
</div>
</div>
<EnterpriseNotice warn />
</div>
);
}
return <EnterpriseTenants selectedId={selectedId} notice={notices} />;
}
@@ -21,7 +21,7 @@ const PERMS = ["sysTenantGet", "sysTenantQuery", "sysTenantCreate"];
const signIn = (edition: string | null, enterpriseNotices = false) =>
useSession.setState({ session: { capabilities: {}, accounts: {}, primaryAccounts: {}, username: "[email protected]", ihasmail: { permissions: PERMS, server: { edition, enterpriseNotices } } } as unknown as JmapSession });
/** Tenants are managed on Enterprise only; anywhere else the page is the notice and nothing more. */
/** INBUXA: tenants are managed on every server, whatever edition it reports. */
describe("the Tenants page", () => {
let host: HTMLDivElement;
let root: Root;
@@ -43,25 +43,17 @@ describe("the Tenants page", () => {
host.remove();
});
for (const edition of ["community", "oss", null]) {
it(`shows only the notice on ${edition ?? "a server that reports no edition"}`, async () => {
for (const edition of ["community", "oss", null, "enterprise"]) {
it(`lists and offers tenants on ${edition ?? "a server that reports no edition"}, with no Enterprise notice`, async () => {
signIn(edition);
await render();
expect(host.querySelector(".admin-notice.warn")?.textContent).toContain("Tenants are a Stalwart Enterprise feature");
expect(host.textContent).not.toContain("New tenant");
expect(host.querySelector('input[type="search"]')).toBeNull();
expect(host.querySelector(".admin-table")).toBeNull();
expect(api.queryTenants).not.toHaveBeenCalled();
});
}
it("lists and offers tenants on Enterprise, and does not say they are Enterprise", async () => {
signIn("enterprise");
await render();
expect(host.querySelector(".admin-notice")).toBeNull();
expect(host.textContent).toContain("New tenant");
expect(host.querySelector(".admin-table")?.textContent).toContain("Acme Corp");
expect(api.queryTenants).toHaveBeenCalled();
});
}
});
describe("the Tenants page where the installation asks for Enterprise notices", () => {
+27 -3
View File
@@ -6,6 +6,7 @@ import { formatFullDate } from "@/lib/format";
import { toast } from "@/ui/toast";
import { confirmDialog, Dialog } from "@/ui/dialog";
import { plural, t, tNode } from "@/lib/i18n";
import { SIGNIN_NOTICE_KEY } from "@/views/Login";
interface SessionRow {
id: string;
@@ -109,7 +110,18 @@ export function SecuritySettings() {
/* ------------------------------------------------------------------ */
/**
* A change that ended this session on the server (with sign-in on the mail
* server's page, a new password revokes every token): sign out here too, and
* leave the sign-in page a line saying why.
*/
async function signedOutBy(notice: string) {
try { sessionStorage.setItem(SIGNIN_NOTICE_KEY, notice); } catch { /* storage may be unavailable */ }
await useSession.getState().logout();
}
function PasswordForm({ otpEnabled, onChanged }: { otpEnabled: boolean; onChanged: () => void }) {
const tokenSession = useSession((s) => s.session?.ihasmail?.signIn === "oauth");
const [current, setCurrent] = useState("");
const [next, setNext] = useState("");
const [confirm, setConfirm] = useState("");
@@ -124,11 +136,15 @@ function PasswordForm({ otpEnabled, onChanged }: { otpEnabled: boolean; onChange
}
setBusy(true);
try {
const res = await apiFetch<{ revokedSessions: number }>("/api/account/password", {
const res = await apiFetch<{ revokedSessions: number; signedOut?: boolean }>("/api/account/password", {
method: "POST",
body: JSON.stringify({ current, next, otpCode: code || undefined }),
});
setCurrent(""); setNext(""); setConfirm(""); setCode("");
if (res.signedOut) {
await signedOutBy("password_changed");
return;
}
toast.success(res.revokedSessions ? `Password changed. ${res.revokedSessions} other session(s) signed out.` : "Password changed");
onChanged();
} catch (err) {
@@ -140,7 +156,11 @@ function PasswordForm({ otpEnabled, onChanged }: { otpEnabled: boolean; onChange
return (
<form onSubmit={submit}>
<p className="hint" style={{ marginBottom: 12 }}>{t("Changing your password signs out your other webmail sessions. Any app passwords keep working.")}</p>
<p className="hint" style={{ marginBottom: 12 }}>
{tokenSession
? t("Changing your password signs you out everywhere, here included, and you sign in again with the new one. Any app passwords keep working.")
: t("Changing your password signs out your other webmail sessions. Any app passwords keep working.")}
</p>
<div className="field" style={{ maxWidth: 380 }}>
<label htmlFor="pw-current">{t("Current password")}</label>
<input id="pw-current" type="password" autoComplete="current-password" value={current} onChange={(e) => setCurrent(e.target.value)} required />
@@ -185,7 +205,11 @@ function TwoFactorOff({ reload }: { reload: () => Promise<void> }) {
const disable = async () => {
setBusy(true);
try {
await apiFetch("/api/account/2fa/disable", { method: "POST", body: JSON.stringify({ current: password, code }) });
const res = await apiFetch<{ signedOut?: boolean }>("/api/account/2fa/disable", { method: "POST", body: JSON.stringify({ current: password, code }) });
if (res?.signedOut) {
await signedOutBy("signed_out");
return;
}
setDisabling(false);
await reload();
toast.success(t("Two-factor authentication is off"));