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:
1 parent
c118184975
commit
8857bdac30
24 files changed
+1058
-74
No files matched your search
+39
-11
@@ -3,6 +3,7 @@ import { dirname } from "node:path";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { config } from "./config.js";
|
||||
import { deriveKey, open, randomToken, safeEqual, seal, sha256 } from "./crypto.js";
|
||||
import type { TokenSet } from "./oauth.js";
|
||||
|
||||
export interface StoredSession {
|
||||
id: string;
|
||||
@@ -10,7 +11,7 @@ export interface StoredSession {
|
||||
secretHash: string;
|
||||
/** base64 random salt for key derivation */
|
||||
salt: string;
|
||||
/** sealed JSON {username, password} */
|
||||
/** sealed JSON: `{u, p}` for a password, `{u, t}` for OAuth tokens (see oauth.ts) */
|
||||
sealedCredentials: string;
|
||||
username: string;
|
||||
/** Which account this is; see `accountKey`. Absent on sessions saved before it existed. */
|
||||
@@ -28,8 +29,10 @@ export interface LiveSession {
|
||||
username: string;
|
||||
/** See `accountKey`. */
|
||||
account: string;
|
||||
/** Basic Authorization header value for upstream calls. */
|
||||
/** Authorization header value for upstream calls: Basic, or Bearer for OAuth. */
|
||||
authorization: string;
|
||||
/** The OAuth tokens behind `authorization`, or null for a password session. */
|
||||
tokens: TokenSet | null;
|
||||
remember: boolean;
|
||||
createdAt: number;
|
||||
lastSeenAt: number;
|
||||
@@ -71,7 +74,9 @@ export interface CreateSessionParams {
|
||||
username: string;
|
||||
/** From `accountKey`; defaults to the lower-cased username. */
|
||||
account?: string;
|
||||
password: string;
|
||||
/** Exactly one of `password` and `tokens`. */
|
||||
password?: string;
|
||||
tokens?: TokenSet;
|
||||
remember: boolean;
|
||||
userAgent: string;
|
||||
ip: string;
|
||||
@@ -107,6 +112,8 @@ export interface SessionBackend {
|
||||
create(params: CreateSessionParams): { cookie: string; session: LiveSession };
|
||||
resolve(cookie: string | undefined): LiveSession | null;
|
||||
reseal(cookie: string | undefined, password: string): boolean;
|
||||
/** Store renewed OAuth tokens in place of the ones the session holds. */
|
||||
updateTokens(cookie: string | undefined, tokens: TokenSet): boolean;
|
||||
destroy(id: string): void;
|
||||
/** `account` is an `accountKey`, as carried on `LiveSession.account`. */
|
||||
destroyAllForUser(account: string, exceptId?: string): number;
|
||||
@@ -115,6 +122,15 @@ export interface SessionBackend {
|
||||
|
||||
const COOKIE_SEP = ".";
|
||||
|
||||
/** What a session seals: a password, or OAuth tokens. */
|
||||
type Sealed = { u: string; p: string } | { u: string; t: TokenSet };
|
||||
|
||||
function sealable(username: string, params: { password?: string; tokens?: TokenSet }): Sealed {
|
||||
if (params.tokens) return { u: username, t: params.tokens };
|
||||
if (params.password !== undefined) return { u: username, p: params.password };
|
||||
throw new Error("a session needs a password or tokens");
|
||||
}
|
||||
|
||||
export class SessionStore implements SessionBackend {
|
||||
private sessions = new Map<string, StoredSession>();
|
||||
private dirty = false;
|
||||
@@ -194,7 +210,7 @@ export class SessionStore implements SessionBackend {
|
||||
id,
|
||||
secretHash: sha256(secret),
|
||||
salt: salt.toString("base64"),
|
||||
sealedCredentials: seal(JSON.stringify({ u: params.username, p: params.password }), key),
|
||||
sealedCredentials: seal(JSON.stringify(sealable(params.username, params)), key),
|
||||
username: params.username,
|
||||
account: params.account ?? params.username.trim().toLowerCase(),
|
||||
createdAt: now,
|
||||
@@ -207,7 +223,7 @@ export class SessionStore implements SessionBackend {
|
||||
this.sessions.set(id, stored);
|
||||
this.scheduleSave();
|
||||
const cookie = `${id}${COOKIE_SEP}${secret}`;
|
||||
return { cookie, session: this.toLive(stored, params.username, params.password) };
|
||||
return { cookie, session: this.toLive(stored, sealable(params.username, params)) };
|
||||
}
|
||||
|
||||
/** Resolve a cookie to a live session (with decrypted upstream credentials). */
|
||||
@@ -229,9 +245,9 @@ export class SessionStore implements SessionBackend {
|
||||
const key = deriveKey(secret, config.appSecret, Buffer.from(stored.salt, "base64"));
|
||||
const json = open(stored.sealedCredentials, key);
|
||||
if (!json) return null;
|
||||
let creds: { u: string; p: string };
|
||||
let creds: Sealed;
|
||||
try {
|
||||
creds = JSON.parse(json) as { u: string; p: string };
|
||||
creds = JSON.parse(json) as Sealed;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -242,7 +258,7 @@ export class SessionStore implements SessionBackend {
|
||||
stored.expiresAt = now + ttl;
|
||||
this.scheduleSave();
|
||||
}
|
||||
return this.toLive(stored, creds.u, creds.p);
|
||||
return this.toLive(stored, creds);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -255,6 +271,14 @@ export class SessionStore implements SessionBackend {
|
||||
* secret half of it, which the server never keeps.
|
||||
*/
|
||||
reseal(cookie: string | undefined, password: string): boolean {
|
||||
return this.rewrite(cookie, (username) => ({ u: username, p: password }));
|
||||
}
|
||||
|
||||
updateTokens(cookie: string | undefined, tokens: TokenSet): boolean {
|
||||
return this.rewrite(cookie, (username) => ({ u: username, t: tokens }));
|
||||
}
|
||||
|
||||
private rewrite(cookie: string | undefined, next: (username: string) => Sealed): boolean {
|
||||
if (!cookie) return false;
|
||||
const idx = cookie.indexOf(COOKIE_SEP);
|
||||
if (idx <= 0) return false;
|
||||
@@ -264,7 +288,7 @@ export class SessionStore implements SessionBackend {
|
||||
if (!stored) return false;
|
||||
if (!safeEqual(stored.secretHash, sha256(secret))) return false;
|
||||
const key = deriveKey(secret, config.appSecret, Buffer.from(stored.salt, "base64"));
|
||||
stored.sealedCredentials = seal(JSON.stringify({ u: stored.username, p: password }), key);
|
||||
stored.sealedCredentials = seal(JSON.stringify(next(stored.username)), key);
|
||||
this.scheduleSave();
|
||||
return true;
|
||||
}
|
||||
@@ -295,12 +319,16 @@ export class SessionStore implements SessionBackend {
|
||||
return out;
|
||||
}
|
||||
|
||||
private toLive(s: StoredSession, username: string, password: string): LiveSession {
|
||||
private toLive(s: StoredSession, creds: Sealed): LiveSession {
|
||||
const username = creds.u;
|
||||
return {
|
||||
id: s.id,
|
||||
username,
|
||||
account: accountOf(s),
|
||||
authorization: `Basic ${Buffer.from(`${username}:${password}`, "utf8").toString("base64")}`,
|
||||
authorization: "t" in creds
|
||||
? `Bearer ${creds.t.access}`
|
||||
: `Basic ${Buffer.from(`${username}:${creds.p}`, "utf8").toString("base64")}`,
|
||||
tokens: "t" in creds ? creds.t : null,
|
||||
remember: s.remember,
|
||||
createdAt: s.createdAt,
|
||||
lastSeenAt: s.lastSeenAt,
|
||||
|
||||
Reference in new issue
Block a user