mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
feat: refuse to restore or renew sessions older than the admin-set maximum length
This commit is contained in:
@@ -23,6 +23,7 @@ import { revokeWopiTokens } from '@/lib/wopi/revocation';
|
||||
import { clearPairReauthInStore } from '@/lib/auth/pair-reauth';
|
||||
import { loginClientAddress } from '@/lib/auth/client-address';
|
||||
import { LOGIN_BUDGET_RETRY_AFTER_SECONDS, reserveLoginAttempt } from '@/lib/auth/verify-budget';
|
||||
import { isPastMaxAge, maxSessionHoursFor, SESSION_TOO_OLD } from '@/lib/auth/session-age';
|
||||
|
||||
function sessionCookieOptions() {
|
||||
return {
|
||||
@@ -73,6 +74,42 @@ function getSlot(request: NextRequest): number {
|
||||
return slot;
|
||||
}
|
||||
|
||||
/**
|
||||
* The organisation's maximum session length for a remembered session
|
||||
* (lib/auth/session-age.ts). A cookie from before sign-in times were
|
||||
* recorded counts as signed in now and gets the time written into it.
|
||||
* Returns the sign-in time, or null when the session is too old: its
|
||||
* cookies are cleared then.
|
||||
*/
|
||||
async function checkSessionAge(
|
||||
cookieStore: Awaited<ReturnType<typeof cookies>>,
|
||||
slot: number,
|
||||
credentials: SessionCredentials,
|
||||
): Promise<{ signedInAt: number } | { maxHours: number }> {
|
||||
const cookieName = sessionCookieName(slot);
|
||||
if (credentials.signedInAt === undefined) {
|
||||
const signedInAt = Date.now();
|
||||
const secret = credentials.token !== undefined ? { token: credentials.token } : credentials.password;
|
||||
cookieStore.set(cookieName, encryptSession(credentials.serverUrl, credentials.username, secret, signedInAt), sessionCookieOptions());
|
||||
return { signedInAt };
|
||||
}
|
||||
// An impersonation login is `target%master`; the policy is the target's.
|
||||
const maxHours = await maxSessionHoursFor(credentials.username.split('%', 1)[0]);
|
||||
if (isPastMaxAge(credentials.signedInAt, maxHours)) {
|
||||
cookieStore.delete(cookieName);
|
||||
clearStalwartAuthContextInStore(cookieStore, slot);
|
||||
return { maxHours };
|
||||
}
|
||||
return { signedInAt: credentials.signedInAt };
|
||||
}
|
||||
|
||||
function sessionTooOld(maxHours: number) {
|
||||
return NextResponse.json(
|
||||
{ error: SESSION_TOO_OLD, maxHours },
|
||||
{ status: 401, headers: { 'Cache-Control': 'no-store, no-cache, must-revalidate' } },
|
||||
);
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
// CSRF gate (GHSA-qvr9-m8cq-7wvg): cookies written here are SameSite=Lax,
|
||||
// so a cross-site top-level POST would otherwise reach this handler.
|
||||
@@ -162,7 +199,9 @@ export async function POST(request: NextRequest) {
|
||||
throw error;
|
||||
}
|
||||
refund();
|
||||
const token = encryptSession(normalizedServerUrl, username, accessToken ? { token: accessToken } : password);
|
||||
// A remembered sign-in starts the maximum session length.
|
||||
const signedInAt = Date.now();
|
||||
const token = encryptSession(normalizedServerUrl, username, accessToken ? { token: accessToken } : password, signedInAt);
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.set(cookieName, token, sessionCookieOptions());
|
||||
setStalwartAuthContextInStore(cookieStore, slot, {
|
||||
@@ -170,6 +209,7 @@ export async function POST(request: NextRequest) {
|
||||
username,
|
||||
authHeader,
|
||||
...(accountName !== username ? { accountName } : {}),
|
||||
signedInAt,
|
||||
});
|
||||
|
||||
void recordLogin(username, normalizedServerUrl);
|
||||
@@ -208,10 +248,14 @@ export async function GET(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'Invalid session' }, { status: 401 });
|
||||
}
|
||||
|
||||
const age = await checkSessionAge(cookieStore, slot, credentials);
|
||||
if ('maxHours' in age) return sessionTooOld(age.maxHours);
|
||||
|
||||
setStalwartAuthContextInStore(cookieStore, slot, {
|
||||
serverUrl: credentials.serverUrl,
|
||||
username: credentials.username,
|
||||
authHeader: sessionAuthHeader(credentials),
|
||||
signedInAt: age.signedInAt,
|
||||
});
|
||||
|
||||
// Only return non-sensitive fields. Use PUT to retrieve full credentials.
|
||||
@@ -258,13 +302,19 @@ export async function PUT(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'Invalid session' }, { status: 401 });
|
||||
}
|
||||
|
||||
const age = await checkSessionAge(cookieStore, slot, credentials);
|
||||
if ('maxHours' in age) return sessionTooOld(age.maxHours);
|
||||
|
||||
setStalwartAuthContextInStore(cookieStore, slot, {
|
||||
serverUrl: credentials.serverUrl,
|
||||
username: credentials.username,
|
||||
authHeader: sessionAuthHeader(credentials),
|
||||
signedInAt: age.signedInAt,
|
||||
});
|
||||
|
||||
return NextResponse.json(credentials, {
|
||||
// The credentials as they were stored, without the bookkeeping.
|
||||
const { signedInAt: _signedInAt, ...restored } = credentials;
|
||||
return NextResponse.json(restored, {
|
||||
headers: { 'Cache-Control': 'no-store, no-cache, must-revalidate' },
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
@@ -14,6 +14,7 @@ import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
||||
import { storeIdToken } from '@/lib/oauth/end-session';
|
||||
import { rejectCrossOriginRequest } from '@/lib/security/same-origin';
|
||||
import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils';
|
||||
import { writeSignInStamp } from '@/lib/auth/session-age';
|
||||
|
||||
const SSO_PENDING_COOKIE = 'sso_pending';
|
||||
const SSO_PENDING_MAX_AGE_MS = 5 * 60 * 1000; // 5 minutes
|
||||
@@ -115,6 +116,8 @@ export async function POST(request: NextRequest) {
|
||||
if (tokens.refresh_token) {
|
||||
const cookieName = refreshTokenCookieName(slot);
|
||||
cookieStore.set(cookieName, tokens.refresh_token, getCookieOptions());
|
||||
// A sign-in: the maximum session length counts from here (lib/auth/session-age.ts).
|
||||
writeSignInStamp(cookieStore, slot, { signedInAt: Date.now() });
|
||||
}
|
||||
const serverCookieName = refreshTokenServerCookieName(slot);
|
||||
if (pendingServerId) {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { JmapAuthVerificationError, resolveJmapIdentity } from '@/lib/auth/verify-jmap-auth';
|
||||
import { setStalwartAuthContext } from '@/lib/stalwart/auth-context';
|
||||
import { cookies } from 'next/headers';
|
||||
import { readStalwartAuthContextFromStore, setStalwartAuthContext } from '@/lib/stalwart/auth-context';
|
||||
import { readSignInStamp, writeSignInStamp } from '@/lib/auth/session-age';
|
||||
import { configManager } from '@/lib/admin/config-manager';
|
||||
import { isPublicHttpUrl } from '@/lib/security/url-guard';
|
||||
import { recordLogin } from '@/lib/telemetry/login-tracker';
|
||||
@@ -101,11 +103,24 @@ export async function POST(request: NextRequest) {
|
||||
// Just signed in: an older refusal of this credential no longer applies.
|
||||
recordStoredCredentialStatus(authHeader, 200);
|
||||
|
||||
// The context is written again on every renewed token and restore, so
|
||||
// the sign-in time is carried over: from this slot's context for the
|
||||
// same account, else from the token sign-in's stamp, else it is now.
|
||||
const cookieStore = await cookies();
|
||||
const previous = readStalwartAuthContextFromStore(cookieStore, slot);
|
||||
const stamp = readSignInStamp(cookieStore, slot);
|
||||
const ownStamp = stamp && (!stamp.account || stamp.account === accountName) ? stamp : null;
|
||||
const samePrevious = previous && previous.serverUrl === normalizedServerUrl && previous.username === username ? previous : null;
|
||||
const signedInAt = samePrevious?.signedInAt ?? ownStamp?.signedInAt ?? Date.now();
|
||||
// The token routes know no account; the first verified one is the stamp's.
|
||||
if (ownStamp && !ownStamp.account) writeSignInStamp(cookieStore, slot, { ...ownStamp, account: accountName });
|
||||
|
||||
await setStalwartAuthContext(slot, {
|
||||
serverUrl: normalizedServerUrl,
|
||||
username,
|
||||
authHeader,
|
||||
...(accountName !== username ? { accountName } : {}),
|
||||
signedInAt,
|
||||
});
|
||||
|
||||
void recordLogin(username, normalizedServerUrl);
|
||||
|
||||
@@ -30,6 +30,15 @@ import {
|
||||
} from '@/lib/oauth/end-session';
|
||||
import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils';
|
||||
import { rejectCrossOriginRequest } from '@/lib/security/same-origin';
|
||||
import {
|
||||
clearSignInStamp,
|
||||
isPastMaxAge,
|
||||
maxSessionHoursFor,
|
||||
readSignInStamp,
|
||||
SESSION_TOO_OLD,
|
||||
writeSignInStamp,
|
||||
} from '@/lib/auth/session-age';
|
||||
import { clearStalwartAuthContextInStore, readStalwartAuthContextFromStore } from '@/lib/stalwart/auth-context';
|
||||
|
||||
function parseSlot(raw: string | null): number | null {
|
||||
if (raw === null) return null;
|
||||
@@ -152,6 +161,8 @@ export async function POST(request: NextRequest) {
|
||||
if (tokens.refresh_token) {
|
||||
const cookieName = refreshTokenCookieName(slot);
|
||||
cookieStore.set(cookieName, tokens.refresh_token, getCookieOptions());
|
||||
// A sign-in: the maximum session length counts from here.
|
||||
writeSignInStamp(cookieStore, slot, { signedInAt: Date.now() });
|
||||
}
|
||||
const cachedAccessToken = cacheAccessToken(cookieStore, slot, tokens.access_token, tokens.expires_in || 3600, [tokens.refresh_token, serverId]);
|
||||
storeIdToken(cookieStore, slot, tokens.id_token, request.nextUrl.basePath, [tokens.refresh_token, cachedAccessToken, serverId]);
|
||||
@@ -189,6 +200,22 @@ export async function PUT(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'No refresh token' }, { status: 401 });
|
||||
}
|
||||
|
||||
// The organisation's maximum session length (lib/auth/session-age.ts).
|
||||
// A refresh token from before sign-in times were recorded counts as
|
||||
// signed in now.
|
||||
const stamp = readSignInStamp(cookieStore, slot);
|
||||
if (!stamp) {
|
||||
writeSignInStamp(cookieStore, slot, { signedInAt: Date.now() });
|
||||
} else {
|
||||
const context = readStalwartAuthContextFromStore(cookieStore, slot);
|
||||
const maxHours = await maxSessionHoursFor(stamp.account ?? context?.accountName ?? context?.username ?? null);
|
||||
if (isPastMaxAge(stamp.signedInAt, maxHours)) {
|
||||
await signOutSlot(cookieStore, slot, request.nextUrl.basePath, false);
|
||||
clearStalwartAuthContextInStore(cookieStore, slot);
|
||||
return NextResponse.json({ error: SESSION_TOO_OLD, maxHours }, { status: 401 });
|
||||
}
|
||||
}
|
||||
|
||||
// A session restore calls this to get its token back, not because the
|
||||
// current one expired. Serving the cached token avoids spending a refresh
|
||||
// the IdP may legitimately reject: Rauthy stamps refresh tokens with
|
||||
@@ -307,6 +334,7 @@ async function signOutSlot(
|
||||
for (const name of [cookieName, refreshTokenServerCookieName(slot), accessTokenCookieName(slot)]) {
|
||||
if (cookieStore.get(name)) cookieStore.delete(name);
|
||||
}
|
||||
clearSignInStamp(cookieStore, slot);
|
||||
if (idToken !== undefined) clearIdToken(cookieStore, slot, basePath);
|
||||
return endSessionUrl;
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import { rejectCrossOriginRequest } from '@/lib/security/same-origin';
|
||||
import { stalwartPasswordLogin } from '@/lib/auth/stalwart-password-login';
|
||||
import { loginClientAddress } from '@/lib/auth/client-address';
|
||||
import { LOGIN_BUDGET_RETRY_AFTER_SECONDS, reserveLoginAttempt } from '@/lib/auth/verify-budget';
|
||||
import { writeSignInStamp } from '@/lib/auth/session-age';
|
||||
|
||||
/**
|
||||
* Exchange a password + (optional) TOTP code for OAuth tokens.
|
||||
@@ -213,6 +214,8 @@ async function storeAndRespond(
|
||||
// Without "Remember me" the sign-in ends with the browser session.
|
||||
const { maxAge: _maxAge, ...sessionOptions } = getCookieOptions();
|
||||
cookieStore.set(cookieName, tokens.refresh_token, rememberMe ? getCookieOptions() : sessionOptions);
|
||||
// A sign-in: the maximum session length counts from here (lib/auth/session-age.ts).
|
||||
writeSignInStamp(cookieStore, slot, { signedInAt: Date.now() });
|
||||
}
|
||||
const serverCookieName = refreshTokenServerCookieName(slot);
|
||||
if (serverId) {
|
||||
|
||||
@@ -31,6 +31,7 @@ describe('encryptSession / decryptSession', () => {
|
||||
serverUrl: 'https://mail.example.com',
|
||||
username: 'alice',
|
||||
password: 's3cret',
|
||||
signedInAt: expect.any(Number),
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -48,6 +48,7 @@ vi.mock('@/lib/stalwart/auth-context', () => ({
|
||||
setStalwartAuthContext: vi.fn(),
|
||||
setStalwartAuthContextInStore: vi.fn(),
|
||||
clearStalwartAuthContextInStore: vi.fn(),
|
||||
readStalwartAuthContextFromStore: () => null,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/telemetry/login-tracker', () => ({ recordLogin: vi.fn() }));
|
||||
|
||||
@@ -56,6 +56,7 @@ vi.mock('@/lib/stalwart/auth-context', () => ({
|
||||
setStalwartAuthContext: (...args: unknown[]) => setStalwartAuthContext(...args),
|
||||
setStalwartAuthContextInStore: (...args: unknown[]) => setStalwartAuthContextInStore(...args),
|
||||
clearStalwartAuthContextInStore: vi.fn(),
|
||||
readStalwartAuthContextFromStore: () => null,
|
||||
}));
|
||||
|
||||
const recordLogin = vi.fn();
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
// @vitest-environment node
|
||||
import { createCipheriv, createHash, randomBytes } from 'node:crypto';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { NextRequest } from 'next/server';
|
||||
import type { SettingsPolicy } from '@/lib/admin/types';
|
||||
|
||||
/**
|
||||
* The organisation's maximum session length on the server: the routes that
|
||||
* restore a remembered sign-in or renew a provider token refuse a session
|
||||
* older than `maxHours` of the account's domain scopes, and a cookie from
|
||||
* before sign-in times were recorded counts as signed in when first seen.
|
||||
*/
|
||||
|
||||
const SECRET = 's'.repeat(64);
|
||||
vi.mock('@/lib/auth/session-secret', () => ({ getSessionSecret: () => SECRET, hasSessionSecret: () => true }));
|
||||
vi.mock('@/lib/logger', () => ({ logger: { warn: () => {}, error: () => {}, info: () => {}, debug: () => {} } }));
|
||||
vi.mock('@/lib/telemetry/login-tracker', () => ({ recordLogin: () => {} }));
|
||||
vi.mock('@/lib/oauth/cookie-config', () => ({
|
||||
getCookieOptions: () => ({ httpOnly: true, secure: true, sameSite: 'lax', path: '/', maxAge: 2592000 }),
|
||||
}));
|
||||
|
||||
const jar = new Map<string, string>();
|
||||
vi.mock('next/headers', () => ({
|
||||
cookies: async () => ({
|
||||
get: (name: string) => (jar.has(name) ? { name, value: jar.get(name)! } : undefined),
|
||||
set: (name: string, value: string) => { jar.set(name, value); },
|
||||
delete: (arg: string | { name: string }) => { jar.delete(typeof arg === 'string' ? arg : arg.name); },
|
||||
getAll: () => [...jar].map(([name, value]) => ({ name, value })),
|
||||
}),
|
||||
}));
|
||||
|
||||
let policy: Partial<SettingsPolicy>;
|
||||
vi.mock('@/lib/admin/config-manager', () => ({
|
||||
configManager: {
|
||||
ensureLoaded: async () => {},
|
||||
get: (key: string, fallback: unknown) => (key === 'jmapServerUrl' ? 'https://mail.example.org' : fallback),
|
||||
getPolicy: () => policy,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/auth/verify-jmap-auth', () => ({
|
||||
JmapAuthVerificationError: class extends Error { status = 401; },
|
||||
resolveJmapIdentity: async (serverUrl: string, _auth: string, username: string) => ({ serverUrl, accountName: username }),
|
||||
}));
|
||||
|
||||
const tokenFetch = vi.fn();
|
||||
vi.mock('@/lib/oauth/token-exchange', () => ({
|
||||
exchangeCodeForTokens: async () => ({ access_token: 'first-access', refresh_token: 'refresh-1', expires_in: 300 }),
|
||||
buildOAuthParams: (params: Record<string, string>) => new URLSearchParams(params),
|
||||
getMetadata: async () => ({ revocation_endpoint: 'https://id.example.org/revoke' }),
|
||||
getRequiredConfig: () => ({ clientId: 'webmail' }),
|
||||
getTokenEndpoint: async () => 'https://id.example.org/token',
|
||||
defaultClientOptions: async () => ({ fallbackClientId: 'bulwark-webmail' }),
|
||||
DEFAULT_CLIENT_ID: 'bulwark-webmail',
|
||||
}));
|
||||
|
||||
const { encryptSession, decryptSession, decryptPayload } = await import('@/lib/auth/crypto');
|
||||
const { readSignInStamp, writeSignInStamp } = await import('@/lib/auth/session-age');
|
||||
const sessionRoute = await import('@/app/api/auth/session/route');
|
||||
const tokenRoute = await import('@/app/api/auth/token/route');
|
||||
const contextRoute = await import('@/app/api/auth/stalwart-context/route');
|
||||
|
||||
const HOUR = 60 * 60 * 1000;
|
||||
const NOW = new Date('2026-10-28T12:00:00Z').getTime();
|
||||
const SERVER = 'https://mail.example.org';
|
||||
const SAME_ORIGIN = { 'sec-fetch-site': 'same-origin', 'sec-fetch-mode': 'cors', 'sec-fetch-dest': 'empty' };
|
||||
|
||||
/** Eight hours for example.org, nothing for everyone else. */
|
||||
function policyWithDomainMax(hours: number): Partial<SettingsPolicy> {
|
||||
return {
|
||||
features: {} as never, restrictions: {}, defaults: {},
|
||||
scopes: [{ id: 'example', label: 'example.org', match: { domains: ['example.org'] }, policy: { organisation: { session: { maxHours: hours } } } }],
|
||||
};
|
||||
}
|
||||
|
||||
/** A remembered-session cookie as Bulwark wrote it before sign-in times were recorded. */
|
||||
function legacySessionCookie(username: string, password: string): string {
|
||||
const key = createHash('sha256').update(SECRET).digest();
|
||||
const iv = randomBytes(12);
|
||||
const cipher = createCipheriv('aes-256-gcm', key, iv);
|
||||
const encrypted = Buffer.concat([cipher.update(JSON.stringify({ v: 1, serverUrl: SERVER, username, password }), 'utf8'), cipher.final()]);
|
||||
return Buffer.concat([iv, cipher.getAuthTag(), encrypted]).toString('base64');
|
||||
}
|
||||
|
||||
const restore = () => sessionRoute.PUT(new NextRequest('https://webmail.example/api/auth/session', { method: 'PUT', headers: SAME_ORIGIN }));
|
||||
const peek = () => sessionRoute.GET(new NextRequest('https://webmail.example/api/auth/session'));
|
||||
const refresh = () => tokenRoute.PUT(new NextRequest('https://webmail.example/api/auth/token?slot=0', { method: 'PUT', headers: SAME_ORIGIN }));
|
||||
const context = () => decryptPayload(jar.get('jmap_stalwart_ctx')!, 'session-context');
|
||||
|
||||
beforeEach(() => {
|
||||
jar.clear();
|
||||
vi.useFakeTimers({ toFake: ['Date'] });
|
||||
vi.setSystemTime(NOW);
|
||||
policy = policyWithDomainMax(8);
|
||||
tokenFetch.mockReset();
|
||||
tokenFetch.mockImplementation(async (url: string) => {
|
||||
if (url.endsWith('/token')) return Response.json({ access_token: 'new-access', expires_in: 300 });
|
||||
return new Response(null, { status: 200 });
|
||||
});
|
||||
vi.stubGlobal('fetch', tokenFetch);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('remembered sessions (/api/auth/session)', () => {
|
||||
it('records the sign-in time when the session is remembered', async () => {
|
||||
const res = await sessionRoute.POST(new NextRequest('https://webmail.example/api/auth/session?slot=0', {
|
||||
method: 'POST',
|
||||
headers: { ...SAME_ORIGIN, 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ serverUrl: SERVER, username: 'org-alice@example.org', password: 'pw' }),
|
||||
}));
|
||||
expect(res.status).toBe(200);
|
||||
expect(decryptSession(jar.get('jmap_session')!)?.signedInAt).toBe(NOW);
|
||||
expect(context()?.signedInAt).toBe(NOW);
|
||||
});
|
||||
|
||||
it('restores a session younger than the maximum, without the sign-in time in the answer', async () => {
|
||||
jar.set('jmap_session', encryptSession(SERVER, 'org-alice@example.org', 'pw', NOW - 7 * HOUR));
|
||||
const res = await restore();
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.json()).toEqual({ serverUrl: SERVER, username: 'org-alice@example.org', password: 'pw' });
|
||||
expect(context()?.signedInAt).toBe(NOW - 7 * HOUR);
|
||||
});
|
||||
|
||||
it('refuses a session older than its domain allows and clears its cookies', async () => {
|
||||
jar.set('jmap_session', encryptSession(SERVER, 'org-alice@example.org', 'pw', NOW - 8 * HOUR));
|
||||
jar.set('jmap_stalwart_ctx', 'stale');
|
||||
|
||||
const res = await restore();
|
||||
expect(res.status).toBe(401);
|
||||
expect(await res.json()).toEqual({ error: 'session_expired', maxHours: 8 });
|
||||
expect(jar.has('jmap_session')).toBe(false);
|
||||
expect(jar.has('jmap_stalwart_ctx')).toBe(false);
|
||||
|
||||
jar.set('jmap_session', encryptSession(SERVER, 'org-alice@example.org', 'pw', NOW - 9 * HOUR));
|
||||
expect((await peek()).status).toBe(401);
|
||||
});
|
||||
|
||||
it('leaves an account of another domain alone', async () => {
|
||||
jar.set('jmap_session', encryptSession(SERVER, 'org-dave@org-two.test', 'pw', NOW - 100 * HOUR));
|
||||
expect((await restore()).status).toBe(200);
|
||||
});
|
||||
|
||||
it('applies the maximum of Everyone to every domain', async () => {
|
||||
policy = { ...policyWithDomainMax(8), organisation: { session: { idleMinutes: 0, maxHours: 24, warnSeconds: 60 } } as never };
|
||||
jar.set('jmap_session', encryptSession(SERVER, 'org-dave@org-two.test', 'pw', NOW - 25 * HOUR));
|
||||
expect((await restore()).status).toBe(401);
|
||||
});
|
||||
|
||||
it('counts a cookie without a sign-in time as signed in when first seen', async () => {
|
||||
jar.set('jmap_session', legacySessionCookie('org-alice@example.org', 'pw'));
|
||||
|
||||
const first = await restore();
|
||||
expect(first.status).toBe(200);
|
||||
expect(await first.json()).toEqual({ serverUrl: SERVER, username: 'org-alice@example.org', password: 'pw' });
|
||||
expect(decryptSession(jar.get('jmap_session')!)?.signedInAt).toBe(NOW);
|
||||
|
||||
vi.setSystemTime(NOW + 7 * HOUR);
|
||||
expect((await restore()).status).toBe(200);
|
||||
vi.setSystemTime(NOW + 8 * HOUR);
|
||||
expect((await restore()).status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('provider tokens (/api/auth/token)', () => {
|
||||
it('records the sign-in time when a provider sign-in stores its refresh token', async () => {
|
||||
// A leftover stamp from an earlier sign-in in this slot.
|
||||
const store = (await (await import('next/headers')).cookies()) as never;
|
||||
writeSignInStamp(store, 0, { signedInAt: NOW - 20 * HOUR, account: 'org-bob@example.org' });
|
||||
|
||||
const res = await tokenRoute.POST(new NextRequest('https://webmail.example/api/auth/token?slot=0', {
|
||||
method: 'POST',
|
||||
headers: { ...SAME_ORIGIN, 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ code: 'c', code_verifier: 'v', redirect_uri: 'https://webmail.example/auth/callback', slot: 0 }),
|
||||
}));
|
||||
expect(res.status).toBe(200);
|
||||
expect(readSignInStamp(store, 0)).toEqual({ signedInAt: NOW });
|
||||
});
|
||||
|
||||
it('refuses to renew a token sign-in older than the maximum and revokes it', async () => {
|
||||
jar.set('jmap_rt', 'refresh-1');
|
||||
const store = (await (await import('next/headers')).cookies()) as never;
|
||||
writeSignInStamp(store, 0, { signedInAt: NOW - 8 * HOUR, account: 'org-alice@example.org' });
|
||||
|
||||
const res = await refresh();
|
||||
expect(res.status).toBe(401);
|
||||
expect(await res.json()).toEqual({ error: 'session_expired', maxHours: 8 });
|
||||
expect(jar.has('jmap_rt')).toBe(false);
|
||||
expect(jar.has('jmap_signed_in')).toBe(false);
|
||||
expect(tokenFetch).toHaveBeenCalledWith('https://id.example.org/revoke', expect.anything());
|
||||
expect(tokenFetch).not.toHaveBeenCalledWith('https://id.example.org/token', expect.anything());
|
||||
});
|
||||
|
||||
it('renews a token sign-in younger than the maximum', async () => {
|
||||
jar.set('jmap_rt', 'refresh-1');
|
||||
const store = (await (await import('next/headers')).cookies()) as never;
|
||||
writeSignInStamp(store, 0, { signedInAt: NOW - 7 * HOUR, account: 'org-alice@example.org' });
|
||||
|
||||
const res = await refresh();
|
||||
expect(res.status).toBe(200);
|
||||
expect((await res.json()).access_token).toBe('new-access');
|
||||
});
|
||||
|
||||
it('counts a refresh token without a sign-in time as signed in when first seen', async () => {
|
||||
jar.set('jmap_rt', 'refresh-1');
|
||||
|
||||
expect((await refresh()).status).toBe(200);
|
||||
const store = (await (await import('next/headers')).cookies()) as never;
|
||||
expect(readSignInStamp(store, 0)).toEqual({ signedInAt: NOW });
|
||||
});
|
||||
});
|
||||
|
||||
describe('the auth context (/api/auth/stalwart-context)', () => {
|
||||
const post = () => contextRoute.POST(new NextRequest('https://webmail.example/api/auth/stalwart-context', {
|
||||
method: 'POST',
|
||||
headers: { ...SAME_ORIGIN, 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ serverUrl: SERVER, username: 'org-alice@example.org', authHeader: 'Bearer new', slot: 0 }),
|
||||
}));
|
||||
|
||||
it('keeps the sign-in time when a renewed token is written again', async () => {
|
||||
jar.set('jmap_session', encryptSession(SERVER, 'org-alice@example.org', 'pw', NOW - 3 * HOUR));
|
||||
await restore();
|
||||
vi.setSystemTime(NOW + HOUR);
|
||||
|
||||
expect((await post()).status).toBe(200);
|
||||
expect(context()?.signedInAt).toBe(NOW - 3 * HOUR);
|
||||
});
|
||||
|
||||
it("takes a token sign-in's time from its stamp and names the account on it", async () => {
|
||||
const store = (await (await import('next/headers')).cookies()) as never;
|
||||
writeSignInStamp(store, 0, { signedInAt: NOW - 2 * HOUR });
|
||||
|
||||
expect((await post()).status).toBe(200);
|
||||
expect(context()?.signedInAt).toBe(NOW - 2 * HOUR);
|
||||
expect(readSignInStamp(store, 0)).toEqual({ signedInAt: NOW - 2 * HOUR, account: 'org-alice@example.org' });
|
||||
});
|
||||
});
|
||||
+21
-7
@@ -23,10 +23,13 @@ function getKey(): Buffer {
|
||||
/**
|
||||
* A remembered sign-in: the account's password (Basic auth), or the access
|
||||
* token the user signed in with (Bearer auth, e.g. a Fastmail API token).
|
||||
* `signedInAt`: when the user signed in (ms), for the organisation's maximum
|
||||
* session length (lib/auth/session-age.ts); absent in cookies written
|
||||
* before it was recorded.
|
||||
*/
|
||||
export type SessionCredentials =
|
||||
| { serverUrl: string; username: string; password: string; token?: undefined }
|
||||
| { serverUrl: string; username: string; token: string; password?: undefined };
|
||||
| { serverUrl: string; username: string; password: string; token?: undefined; signedInAt?: number }
|
||||
| { serverUrl: string; username: string; token: string; password?: undefined; signedInAt?: number };
|
||||
|
||||
/** The Authorization header remembered credentials sign in with. */
|
||||
export function sessionAuthHeader(credentials: SessionCredentials): string {
|
||||
@@ -34,13 +37,18 @@ export function sessionAuthHeader(credentials: SessionCredentials): string {
|
||||
return `Basic ${Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64')}`;
|
||||
}
|
||||
|
||||
export function encryptSession(serverUrl: string, username: string, secret: string | { token: string }): string {
|
||||
export function encryptSession(
|
||||
serverUrl: string,
|
||||
username: string,
|
||||
secret: string | { token: string },
|
||||
signedInAt: number = Date.now(),
|
||||
): string {
|
||||
const key = getKey();
|
||||
const iv = randomBytes(IV_LENGTH);
|
||||
const cipher = createCipheriv(ALGORITHM, key, iv);
|
||||
|
||||
const credential = typeof secret === 'string' ? { password: secret } : { token: secret.token };
|
||||
const payload = JSON.stringify({ v: 1, serverUrl, username, ...credential });
|
||||
const payload = JSON.stringify({ v: 1, serverUrl, username, ...credential, signedInAt });
|
||||
const encrypted = Buffer.concat([cipher.update(payload, 'utf8'), cipher.final()]);
|
||||
const tag = cipher.getAuthTag();
|
||||
|
||||
@@ -64,11 +72,14 @@ export function decryptSession(token: string): SessionCredentials | null {
|
||||
const parsed = JSON.parse(decrypted.toString('utf8'));
|
||||
|
||||
if (parsed.v !== 1 || !parsed.serverUrl || !parsed.username) return null;
|
||||
const signedInAt = typeof parsed.signedInAt === 'number' && Number.isFinite(parsed.signedInAt) && parsed.signedInAt > 0
|
||||
? { signedInAt: parsed.signedInAt as number }
|
||||
: {};
|
||||
if (typeof parsed.password === 'string' && parsed.password) {
|
||||
return { serverUrl: parsed.serverUrl, username: parsed.username, password: parsed.password };
|
||||
return { serverUrl: parsed.serverUrl, username: parsed.username, password: parsed.password, ...signedInAt };
|
||||
}
|
||||
if (typeof parsed.token === 'string' && parsed.token) {
|
||||
return { serverUrl: parsed.serverUrl, username: parsed.username, token: parsed.token };
|
||||
return { serverUrl: parsed.serverUrl, username: parsed.username, token: parsed.token, ...signedInAt };
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
@@ -104,7 +115,9 @@ export type PayloadPurpose =
|
||||
/** The app password an impersonation handoff minted, kept for revocation. */
|
||||
| 'impersonation-grant'
|
||||
/** A user's plugin credentials (lib/plugin-secrets/vault.ts). */
|
||||
| 'plugin-secrets';
|
||||
| 'plugin-secrets'
|
||||
/** When a token sign-in happened, for the maximum session length (lib/auth/session-age.ts). */
|
||||
| 'session-age';
|
||||
|
||||
/**
|
||||
* Whether a blob minted before purposes were bound may be re-admitted for
|
||||
@@ -125,6 +138,7 @@ const LEGACY_PAYLOAD_MARKERS: Record<PayloadPurpose, (payload: Record<string, un
|
||||
'wopi-token': (p) => p.t === 'wopi',
|
||||
'impersonation-grant': () => false,
|
||||
'plugin-secrets': () => false,
|
||||
'session-age': () => false,
|
||||
};
|
||||
|
||||
export function encryptPayload(payload: Record<string, unknown>, purpose: PayloadPurpose): string {
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* The organisation's maximum session length (policy.json
|
||||
* `organisation.session.maxHours`) on the server side.
|
||||
*
|
||||
* JMAP goes from the browser straight to Stalwart, so the client's timer
|
||||
* signs users out on time (components/session/session-timeout-guard.tsx).
|
||||
* The server's part is to stop a session older than the maximum from coming
|
||||
* back: the routes that restore a remembered sign-in (/api/auth/session) or
|
||||
* renew a provider token (/api/auth/token) refuse it and clear its cookies.
|
||||
*
|
||||
* The sign-in time travels in the remembered-session cookie (`signedInAt`
|
||||
* in lib/auth/crypto.ts), in the auth-context cookie, and for token
|
||||
* sign-ins in a small cookie of its own written when the refresh token is
|
||||
* (the auth-context cookie ends with the browser, the refresh token lasts
|
||||
* 30 days). A cookie written before this existed has no sign-in time: it
|
||||
* counts as signed in when the server first sees it after the upgrade,
|
||||
* which then writes the time into it.
|
||||
*
|
||||
* The server knows an account's address, not its groups, so only Everyone
|
||||
* and the domain scopes count here; a group scope's value is enforced by the
|
||||
* client alone.
|
||||
*/
|
||||
|
||||
import type { cookies } from 'next/headers';
|
||||
import { configManager } from '@/lib/admin/config-manager';
|
||||
import { resolvePolicy } from '@/lib/admin/policy-scopes';
|
||||
import { decryptPayload, encryptPayload } from '@/lib/auth/crypto';
|
||||
import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
||||
import { logger } from '@/lib/logger';
|
||||
|
||||
type CookieStore = Awaited<ReturnType<typeof cookies>>;
|
||||
|
||||
const HOUR_MS = 60 * 60 * 1000;
|
||||
|
||||
/** What a route answers when it refuses a session that is too old. */
|
||||
export const SESSION_TOO_OLD = 'session_expired';
|
||||
|
||||
/** The maximum session length (hours, 0 = none) for an account's address. */
|
||||
export async function maxSessionHoursFor(account: string | null | undefined): Promise<number> {
|
||||
try {
|
||||
await configManager.ensureLoaded?.();
|
||||
const email = account?.trim().toLowerCase() ?? '';
|
||||
const policy = resolvePolicy(configManager.getPolicy(), email ? { email, groups: [] } : null).policy;
|
||||
const hours = policy.organisation?.session?.maxHours;
|
||||
return typeof hours === 'number' && hours > 0 ? hours : 0;
|
||||
} catch (error) {
|
||||
logger.warn('Could not read the maximum session length', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether a session signed in at `signedInAt` has outlived `maxHours`. */
|
||||
export function isPastMaxAge(signedInAt: number | undefined, maxHours: number, now = Date.now()): boolean {
|
||||
if (!maxHours || typeof signedInAt !== 'number') return false;
|
||||
return now - signedInAt >= maxHours * HOUR_MS;
|
||||
}
|
||||
|
||||
export function validSignedInAt(value: unknown): number | undefined {
|
||||
return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The sign-in time of a token sign-in, kept as long as its refresh token.
|
||||
* `account` is filled in once the auth context names the account.
|
||||
*/
|
||||
export interface SignInStamp {
|
||||
signedInAt: number;
|
||||
account?: string;
|
||||
}
|
||||
|
||||
export function signInStampCookieName(slot: number): string {
|
||||
return slot === 0 ? 'jmap_signed_in' : `jmap_signed_in_${slot}`;
|
||||
}
|
||||
|
||||
export function readSignInStamp(cookieStore: CookieStore, slot: number): SignInStamp | null {
|
||||
const value = cookieStore.get(signInStampCookieName(slot))?.value;
|
||||
if (!value) return null;
|
||||
try {
|
||||
const payload = decryptPayload(value, 'session-age');
|
||||
const signedInAt = validSignedInAt(payload?.signedInAt);
|
||||
if (!signedInAt) return null;
|
||||
return typeof payload?.account === 'string' ? { signedInAt, account: payload.account } : { signedInAt };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Without a session secret nothing can be sealed; the client's timer still applies. */
|
||||
export function writeSignInStamp(cookieStore: CookieStore, slot: number, stamp: SignInStamp): void {
|
||||
try {
|
||||
const payload: Record<string, unknown> = { signedInAt: stamp.signedInAt };
|
||||
if (stamp.account) payload.account = stamp.account;
|
||||
cookieStore.set(signInStampCookieName(slot), encryptPayload(payload, 'session-age'), getCookieOptions());
|
||||
} catch (error) {
|
||||
logger.warn('Could not record the sign-in time', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||
}
|
||||
}
|
||||
|
||||
export function clearSignInStamp(cookieStore: CookieStore, slot: number): void {
|
||||
const name = signInStampCookieName(slot);
|
||||
if (cookieStore.get(name)) cookieStore.delete(name);
|
||||
}
|
||||
@@ -14,6 +14,12 @@ export interface StalwartAuthContext {
|
||||
* data such as synced settings is keyed on this.
|
||||
*/
|
||||
accountName?: string;
|
||||
/**
|
||||
* When the user signed in (ms), for the organisation's maximum session
|
||||
* length (lib/auth/session-age.ts). Carried over when the context is
|
||||
* written again for the same account (a renewed token, a restore).
|
||||
*/
|
||||
signedInAt?: number;
|
||||
}
|
||||
|
||||
type CookieStore = Awaited<ReturnType<typeof cookies>>;
|
||||
@@ -39,7 +45,8 @@ function isValidContext(payload: unknown): payload is StalwartAuthContext {
|
||||
return typeof candidate.serverUrl === 'string'
|
||||
&& typeof candidate.username === 'string'
|
||||
&& typeof candidate.authHeader === 'string'
|
||||
&& (candidate.accountName === undefined || typeof candidate.accountName === 'string');
|
||||
&& (candidate.accountName === undefined || typeof candidate.accountName === 'string')
|
||||
&& (candidate.signedInAt === undefined || typeof candidate.signedInAt === 'number');
|
||||
}
|
||||
|
||||
function getSessionCookieOptions() {
|
||||
@@ -59,8 +66,14 @@ export function readStalwartAuthContextFromStore(
|
||||
|
||||
// Hand back exactly the declared shape: the envelope's own bookkeeping
|
||||
// stays in the envelope rather than riding along into the credentials.
|
||||
const { serverUrl, username, authHeader, accountName } = payload;
|
||||
return accountName ? { serverUrl, username, authHeader, accountName } : { serverUrl, username, authHeader };
|
||||
const { serverUrl, username, authHeader, accountName, signedInAt } = payload;
|
||||
return {
|
||||
serverUrl,
|
||||
username,
|
||||
authHeader,
|
||||
...(accountName ? { accountName } : {}),
|
||||
...(typeof signedInAt === 'number' ? { signedInAt } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function setStalwartAuthContextInStore(
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { beforeEach, expect, it, vi } from 'vitest';
|
||||
import { createConfig } from '@/lib/__tests__/fixtures/config';
|
||||
|
||||
// The server refuses to restore a session older than the organisation's
|
||||
// maximum session length (lib/auth/session-age.ts). The login page then says
|
||||
// why the user has to sign in again.
|
||||
vi.mock('@/lib/jmap/client', () => {
|
||||
class JMAPClient {
|
||||
constructor(public serverUrl: string, public username: string, public password: string) {}
|
||||
static withBearer(serverUrl: string, _token: string, username: string) { return new JMAPClient(serverUrl, username, ''); }
|
||||
async connect() {}
|
||||
getAuthHeader() { return 'Basic x'; }
|
||||
onConnectionChange() {}
|
||||
onRateLimit() {}
|
||||
getRateLimitRemainingMs() { return 0; }
|
||||
disconnect() {}
|
||||
}
|
||||
class RateLimitError extends Error {}
|
||||
class RequestTimeoutError extends Error {}
|
||||
return { JMAPClient, RateLimitError, RequestTimeoutError };
|
||||
});
|
||||
|
||||
import * as nav from '@/lib/browser-navigation';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useAccountStore } from '@/stores/account-store';
|
||||
import { consumeSessionEnd } from '@/lib/session-policy/sign-out-reason';
|
||||
|
||||
const account = (authMode: 'basic' | 'oauth') => ({
|
||||
id: 'alice@example.org@mail.example.org', label: 'Alice', serverUrl: 'https://mail.example.org', username: 'alice@example.org', authMode,
|
||||
rememberMe: true, providerSession: authMode === 'oauth', cookieSlot: 0, displayName: 'Alice', email: 'alice@example.org', isConnected: false,
|
||||
hasError: false, lastLoginAt: 1, avatarColor: '#000000', isDefault: true,
|
||||
});
|
||||
|
||||
const tooOld = () => new Response(JSON.stringify({ error: 'session_expired', maxHours: 8 }), { status: 401 });
|
||||
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
vi.stubGlobal('fetch', vi.fn(async (input: unknown, init?: { method?: string }) => {
|
||||
const url = String(input);
|
||||
if (url.includes('/api/config')) return new Response(JSON.stringify(createConfig()), { status: 200 });
|
||||
if (url.includes('/api/auth/session') && init?.method === 'PUT') return tooOld();
|
||||
if (url.includes('/api/auth/token') && init?.method === 'PUT') return tooOld();
|
||||
return new Response('{}', { status: 200 });
|
||||
}));
|
||||
vi.spyOn(nav, 'replaceWindowLocation').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
it('says on the login page that a remembered session reached its maximum length', async () => {
|
||||
useAccountStore.setState({ accounts: [account('basic')] as never, activeAccountId: account('basic').id, defaultAccountId: account('basic').id });
|
||||
|
||||
await useAuthStore.getState().checkAuth();
|
||||
await vi.waitFor(() => expect(useAccountStore.getState().accounts).toHaveLength(0));
|
||||
|
||||
expect(consumeSessionEnd()).toEqual({ kind: 'max', hours: 8 });
|
||||
});
|
||||
|
||||
it('says so for a provider sign-in whose renewal the server refused', async () => {
|
||||
useAccountStore.setState({ accounts: [account('oauth')] as never, activeAccountId: account('oauth').id, defaultAccountId: account('oauth').id });
|
||||
|
||||
await useAuthStore.getState().checkAuth();
|
||||
await vi.waitFor(() => expect(useAccountStore.getState().accounts).toHaveLength(0));
|
||||
|
||||
expect(consumeSessionEnd()).toEqual({ kind: 'max', hours: 8 });
|
||||
});
|
||||
+23
-2
@@ -24,6 +24,7 @@ import { useSearchHistoryStore } from './search-history-store';
|
||||
import { useCalendarNotificationStore } from './calendar-notification-store';
|
||||
import { usePolicyStore } from './policy-store';
|
||||
import { policySubjectForClient } from '@/lib/admin/policy-subject';
|
||||
import { rememberSessionEnd } from '@/lib/session-policy/sign-out-reason';
|
||||
import type { Identity } from '@/lib/jmap/types';
|
||||
import { authHooks } from '@/lib/plugin-hooks';
|
||||
import { IS_LITE, IS_LITE_STALWART } from '@/lib/lite';
|
||||
@@ -536,12 +537,31 @@ async function persistSession(slot: number, session: RememberedSession): Promise
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A restore or renewal the server refused because the session reached the
|
||||
* organisation's maximum length (lib/auth/session-age.ts): the login page
|
||||
* says so.
|
||||
*/
|
||||
async function noteSessionTooOld(res: Response): Promise<Response> {
|
||||
if (res.status !== 401 || typeof res.clone !== 'function') return res;
|
||||
try {
|
||||
const data = await res.clone().json() as { error?: unknown; maxHours?: unknown };
|
||||
if (data?.error === 'session_expired' && typeof data.maxHours === 'number') {
|
||||
rememberSessionEnd({ kind: 'max', hours: data.maxHours });
|
||||
}
|
||||
} catch {
|
||||
/* not JSON */
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
/** PUT /api/auth/token?slot=N - renew (or, unless `force`, reuse) the slot's access token. */
|
||||
async function fetchSlotAccessToken(slot: number, opts: { force?: boolean } = {}): Promise<Response> {
|
||||
// Renewing would write the tokens of a slot being signed out back.
|
||||
if (closingSlots.has(slot)) return jsonResponse({ error: 'signed_out' }, 401);
|
||||
if (!IS_LITE) {
|
||||
return trackSessionWrite(slot, (signal) => apiFetch(`/api/auth/token?slot=${slot}${opts.force ? '&force=true' : ''}`, { method: 'PUT', signal }));
|
||||
return trackSessionWrite(slot, (signal) => apiFetch(`/api/auth/token?slot=${slot}${opts.force ? '&force=true' : ''}`, { method: 'PUT', signal }))
|
||||
.then(noteSessionTooOld);
|
||||
}
|
||||
// Like the route's cookie cache: a restore resumes with the token it had,
|
||||
// since a renewal before the refresh token's `nbf` is refused (#552).
|
||||
@@ -563,7 +583,8 @@ async function fetchSlotAccessToken(slot: number, opts: { force?: boolean } = {}
|
||||
/** PUT /api/auth/session[?slot=N] - the slot's remembered password or access token. */
|
||||
async function fetchSlotSession(slot?: number): Promise<Response> {
|
||||
if (!IS_LITE) {
|
||||
return apiFetch(slot === undefined ? '/api/auth/session' : `/api/auth/session?slot=${slot}`, { method: 'PUT' });
|
||||
return apiFetch(slot === undefined ? '/api/auth/session' : `/api/auth/session?slot=${slot}`, { method: 'PUT' })
|
||||
.then(noteSessionTooOld);
|
||||
}
|
||||
const stored = readLiteBasicSession(slot ?? 0);
|
||||
return stored ? jsonResponse(stored) : jsonResponse({ error: 'no_session' }, 401);
|
||||
|
||||
Reference in New Issue
Block a user