fix: link the mobile app from every account with a sign-in of its own

"Link Mobile App" only appeared for OAuth sessions, its step-up could
only go through the identity provider (which failed without an OAuth
client and bounced password+TOTP users to the provider's login page),
and it handed the phone the desktop's own refresh token. Revoking that
token on sign-out, or a rotating provider, signed the phone out too; a
confidential client stopped the phone at its first token expiry; and an
identity provider on another host was refused by the app.

The step-up now mints the phone's own grant:

- Password and password+TOTP accounts confirm the current password (and
  code) in the dialog. The server uses it once to get an OAuth grant
  from Stalwart's structured login for the app's own client, retries
  with the webmail's client when the server insists on a registered one,
  and falls back to an app password ("Bulwark mobile (paired <date>)")
  or, on servers that offer neither, the verified password. Failed
  attempts are limited per account so the webmail's address cannot be
  banned by guessing through it.
- SSO accounts keep the provider round trip, now with `offline_access`
  where offered; its fresh tokens become the phone's grant instead of
  being thrown away, and must belong to the account being paired.
- The account and server always come from the slot's verified session
  context, never from the request.

Grants live 5 minutes server-side, codes 2 minutes, both single use; the
store is pinned on `globalThis`. Redeem answers `invalid_code`,
`expired_code` or `used_code`, and the desktop polls a separate status
id to replace the QR with "Signed in on your phone". A phone whose
client is confidential, or whose provider is on another host, renews
through `/api/auth/pair/token` with a sealed refresh token, so current
app builds work unchanged. Signing out discards a pending grant but no
longer affects a paired phone.

The dialog shows for every account (not in Lite), with a copyable
sign-in link, a TOTP field when needed, and the app's own "Scan a
sign-in code" wording.
This commit is contained in:
Linus Rath
2026-09-27 04:16:18 +02:00
parent bb80d01ddf
commit 1446fd39dc
57 changed files with 5248 additions and 374 deletions
+139 -73
View File
@@ -1,99 +1,165 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { logger } from '@/lib/logger';
import { refreshTokenCookieName, refreshTokenServerCookieName } from '@/lib/oauth/tokens';
import { buildOAuthParams, getRequiredConfig, getTokenEndpoint } from '@/lib/oauth/token-exchange';
import { getCookieOptions } from '@/lib/oauth/cookie-config';
import { createPairing } from '@/lib/auth/pairing-store';
import { hasValidPairReauth } from '@/lib/auth/pair-reauth';
import { createPairingCode, isGrantAvailable, stashGrant } from '@/lib/auth/pairing-store';
import { pairingOwner, readPairReauthFromStore, setPairReauthInStore } from '@/lib/auth/pair-reauth';
import { mintGrantWithPassword } from '@/lib/auth/pair-grant';
import { beginPairStepUp, settlePairStepUp } from '@/lib/auth/pair-attempts';
import { hasSessionSecret } from '@/lib/auth/session-secret';
import { readStalwartAuthContextFromStore } from '@/lib/stalwart/auth-context';
import { isTrustedJmapServerUrl } from '@/lib/stalwart/server-fetch';
import { configManager } from '@/lib/admin/config-manager';
import { findServerByUrl, parseJmapServers } from '@/lib/admin/jmap-servers';
import { IMPERSONATION_GRANT_COOKIE, openImpersonationGrant } from '@/lib/impersonation/grant-cookie';
import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils';
import { rejectCrossOriginRequest } from '@/lib/security/same-origin';
// Desktop side of the cross-device QR login. The caller must be a signed-in
// webmail session (its refresh token lives in the httpOnly jmap_rt cookie). We
// refresh that token to (a) prove the session is live and (b) obtain a fresh
// access token to hand the phone, then stash the bundle under a one-time
// pairing code. The desktop renders the returned code as a QR; the phone
// redeems it at /api/auth/pair/redeem.
// Desktop side of "Link mobile app". Returns a one-time pairing code for the
// QR; the phone redeems it at /api/auth/pair/redeem.
//
// Token sharing note: the phone receives the SAME refresh token as the desktop.
// That is correct for OAuth servers (such as Stalwart in its default config)
// that do not rotate refresh tokens on use. If the server rotates refresh
// tokens, the two devices would fight over the latest token — such deployments
// should disable rotation for this client or use a token-exchange grant.
// Linking provisions a new long-lived login on another device, so it needs a
// step-up first, and the step-up itself produces what the phone receives:
// - with `password` (and `totp`): the current password is checked against
// the mail server and used once to mint the phone's own sign-in (see
// lib/auth/pair-grant.ts);
// - after a fresh IdP login (/api/auth/sso/start purpose=reauth, completed by
// /api/auth/reauth/sso/complete): that login's own tokens.
// Either way the phone never shares the desktop's session, so signing out
// here does not sign the phone out. Within the step-up window a new code can
// be shown without asking again, until one of them is redeemed.
//
// The account is the slot's verified session context: its server URL and user
// never come from the request body.
function parseSlot(raw: unknown): number {
return typeof raw === 'number' && Number.isInteger(raw) && raw >= 0 && raw < MAX_ACCOUNT_SLOTS ? raw : 0;
}
/**
* The webmail's public base (origin + mount path) the desktop shows in the
* QR. Only accepted for the origin this request came from; the phone sends
* its refresh requests there when it needs the token proxy.
*/
function resolveWebmailBase(raw: unknown, request: NextRequest): string | null {
if (typeof raw !== 'string' || !raw || raw.length > 2048) return null;
let url: URL;
try {
url = new URL(raw);
} catch {
return null;
}
if (url.protocol !== 'https:' && url.protocol !== 'http:') return null;
if (url.username || url.password || url.search || url.hash) return null;
const origin = request.headers.get('origin');
if (origin) {
if (url.origin !== origin) return null;
} else {
const host = request.headers.get('x-forwarded-host') ?? request.headers.get('host');
if (!host || url.host !== host) return null;
}
return `${url.origin}${url.pathname.replace(/\/+$/, '')}`;
}
/** The desktop's OAuth callback, for servers that insist on a registered redirect. */
function resolveRedirectUri(raw: unknown, webmailBase: string): string | undefined {
if (typeof raw !== 'string' || !raw || raw.length > 2048) return undefined;
try {
const url = new URL(raw);
if (url.origin !== new URL(webmailBase).origin || url.search || url.hash) return undefined;
return url.toString();
} catch {
return undefined;
}
}
const STEP_UP_ERRORS = {
invalid_credentials: 401,
totp_required: 401,
server_unreachable: 502,
pairing_unavailable: 502,
} as const;
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.
const crossOrigin = rejectCrossOriginRequest(request);
if (crossOrigin) return crossOrigin;
const cookieStore = await cookies();
try {
// Step-up gate: minting a pairing code grants new-device access, so it
// requires a recent fresh IdP re-authentication (see the reauth SSO flow).
// The client turns this 401 into a re-auth redirect, then retries.
if (!(await hasValidPairReauth())) {
return NextResponse.json({ error: 'reauth_required' }, { status: 401 });
// The step-up proof, the session context and the phone's sealed refresh
// token are all encrypted under SESSION_SECRET.
if (!hasSessionSecret()) {
return NextResponse.json({ error: 'session_secret_required' }, { status: 500 });
}
const body = await request.json().catch(() => ({}));
const slot =
typeof body.slot === 'number' && body.slot >= 0 && body.slot < MAX_ACCOUNT_SLOTS
? body.slot
: 0;
const cookieName = refreshTokenCookieName(slot);
const refreshToken = cookieStore.get(cookieName)?.value;
if (!refreshToken) {
return NextResponse.json({ error: 'Not signed in' }, { status: 401 });
}
const serverId = cookieStore.get(refreshTokenServerCookieName(slot))?.value || null;
const tokenEndpoint = await getTokenEndpoint(serverId);
const params = buildOAuthParams({ grant_type: 'refresh_token', refresh_token: refreshToken }, serverId);
const tokenResponse = await fetch(tokenEndpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: params.toString(),
});
if (!tokenResponse.ok) {
const errorText = await tokenResponse.text();
logger.warn('Pair create: refresh failed', { status: tokenResponse.status, error: errorText });
// Stale session — clear the dead cookie so the user is prompted to log
// back in, mirroring the token route's behaviour.
cookieStore.delete(cookieName);
cookieStore.delete(refreshTokenServerCookieName(slot));
return NextResponse.json({ error: 'Session expired' }, { status: 401 });
const slot = parseSlot(body?.slot);
const webmailBase = resolveWebmailBase(body?.webmail_base, request);
if (!webmailBase) {
return NextResponse.json({ error: 'invalid_request' }, { status: 400 });
}
const tokens = await tokenResponse.json();
if (!tokens.access_token) {
logger.error('Pair create: refresh response missing access_token');
return NextResponse.json({ error: 'Invalid token response' }, { status: 502 });
const cookieStore = await cookies();
// A support session opens the mailbox with a short-lived app password; it
// must not be able to hand out a lasting login for it.
if (slot === 0 && openImpersonationGrant(cookieStore.get(IMPERSONATION_GRANT_COOKIE)?.value)) {
return NextResponse.json({ error: 'impersonation' }, { status: 403 });
}
// If the server rotated the refresh token, persist the new one back to the
// desktop's cookie so this very session keeps working. The phone will get
// the same (new) token below.
const effectiveRefreshToken = tokens.refresh_token || refreshToken;
if (tokens.refresh_token) {
cookieStore.set(cookieName, tokens.refresh_token, getCookieOptions());
const context = readStalwartAuthContextFromStore(cookieStore, slot);
if (!context) {
return NextResponse.json({ error: 'not_signed_in' }, { status: 401 });
}
const owner = pairingOwner(slot, context.username);
let grantId: string;
const password = typeof body?.password === 'string' ? body.password : '';
if (password) {
if (!beginPairStepUp(owner)) {
return NextResponse.json({ error: 'too_many_attempts' }, { status: 429 });
}
const totp = typeof body?.totp === 'string' && body.totp.trim() ? body.totp.trim() : undefined;
const serverUrl = context.serverUrl.replace(/\/+$/, '');
await configManager.ensureLoaded();
const serverId = findServerByUrl(parseJmapServers(configManager.get<unknown>('jmapServers', [])), serverUrl)?.id ?? null;
const result = await mintGrantWithPassword({
serverUrl,
trusted: await isTrustedJmapServerUrl(serverUrl),
serverId,
// The login name the credential belongs to, when the session context
// was written under an identity address.
username: context.accountName ?? context.username,
password,
totp,
webmailRedirectUri: resolveRedirectUri(body?.redirect_uri, webmailBase),
});
if (!result.ok) {
const wrong = result.error === 'invalid_credentials' || (result.error === 'totp_required' && !!totp);
settlePairStepUp(owner, wrong ? 'wrong_credential' : 'other');
return NextResponse.json({ error: result.error }, { status: STEP_UP_ERRORS[result.error] });
}
settlePairStepUp(owner, 'success');
grantId = stashGrant(result.grant, owner);
setPairReauthInStore(cookieStore, { grantId, owner });
logger.info('Pair step-up succeeded', { flow: result.grant.flow, method: 'password' });
} else {
const proof = readPairReauthFromStore(cookieStore);
if (!proof || proof.owner !== owner || !isGrantAvailable(proof.grantId, owner)) {
return NextResponse.json({ error: 'reauth_required' }, { status: 401 });
}
grantId = proof.grantId;
}
const { clientId, serverUrl } = getRequiredConfig(serverId);
const pairing = createPairingCode(grantId, owner, webmailBase);
if (!pairing) {
return NextResponse.json({ error: 'reauth_required' }, { status: 401 });
}
const { code, expiresIn } = createPairing({
accessToken: tokens.access_token,
refreshToken: effectiveRefreshToken,
expiresIn: tokens.expires_in,
tokenEndpoint,
clientId,
serverUrl,
serverId,
});
return NextResponse.json({ pairing_code: code, server_url: serverUrl, expires_in: expiresIn });
return NextResponse.json(
{ pairing_code: pairing.code, status_id: pairing.statusId, expires_in: pairing.expiresIn },
{ headers: { 'Cache-Control': 'no-store' } },
);
} catch (error) {
logger.error('Pair create error', { error: error instanceof Error ? error.message : 'Unknown error' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
+28 -22
View File
@@ -1,41 +1,47 @@
import { NextRequest, NextResponse } from 'next/server';
import { logger } from '@/lib/logger';
import { consumePairing } from '@/lib/auth/pairing-store';
import { redeemPairingCode } from '@/lib/auth/pairing-store';
import { PAIR_TOKEN_PROXY_PATH, buildRedeemBundle } from '@/lib/auth/pair-bundle';
import { rejectCrossOriginRequest } from '@/lib/security/same-origin';
// Phone side of the cross-device QR login. The app POSTs the pairing code it
// scanned; we hand back the OAuth token bundle the desktop stashed at
// /api/auth/pair/create. The code is the only credential required — it is
// high-entropy, single-use, and expires within ~2 minutes — so this route is
// intentionally unauthenticated (the scanning device has no webmail cookies).
// Phone side of "Link mobile app". The app POSTs the code it scanned and gets
// the sign-in the desktop's step-up minted for it (see /api/auth/pair/create):
// an OAuth bundle, or for servers without OAuth login a username + (app)
// password. The code is the only credential required - it carries 256 bits,
// works once and expires within two minutes - so this route is intentionally
// unauthenticated (the scanning device has no webmail cookies).
//
// Failures say what happened, so the app can tell the user what to do:
// 400 invalid_code (never issued), 410 expired_code, 410 used_code.
const NO_STORE = { 'Cache-Control': 'no-store' };
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.
const crossOrigin = rejectCrossOriginRequest(request);
if (crossOrigin) return crossOrigin;
try {
const { pairing_code: pairingCode } = await request.json().catch(() => ({}));
if (!pairingCode || typeof pairingCode !== 'string') {
return NextResponse.json({ error: 'Missing pairing code' }, { status: 400 });
const body = await request.json().catch(() => ({}));
const pairingCode = body?.pairing_code;
if (typeof pairingCode !== 'string' || !/^[0-9a-f]{64}$/.test(pairingCode)) {
return NextResponse.json({ error: 'invalid_code' }, { status: 400, headers: NO_STORE });
}
const tokens = consumePairing(pairingCode);
if (!tokens) {
// Unknown, expired, or already redeemed — do not distinguish.
return NextResponse.json({ error: 'Invalid or expired pairing code' }, { status: 400 });
const result = redeemPairingCode(pairingCode);
if (!result.ok) {
const status = result.reason === 'invalid' ? 400 : 410;
return NextResponse.json({ error: `${result.reason}_code` }, { status, headers: NO_STORE });
}
return NextResponse.json({
flow: 'oauth',
server_url: tokens.serverUrl,
access_token: tokens.accessToken,
...(tokens.refreshToken ? { refresh_token: tokens.refreshToken } : {}),
...(typeof tokens.expiresIn === 'number' ? { expires_in: tokens.expiresIn } : {}),
token_endpoint: tokens.tokenEndpoint,
client_id: tokens.clientId,
const bundle = buildRedeemBundle(result.grant, result.webmailBase);
logger.info('Pair code redeemed', {
flow: bundle.flow,
proxied: bundle.flow === 'oauth' && bundle.token_endpoint.endsWith(PAIR_TOKEN_PROXY_PATH),
});
return NextResponse.json(bundle, { headers: NO_STORE });
} catch (error) {
logger.error('Pair redeem error', { error: error instanceof Error ? error.message : 'Unknown error' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
return NextResponse.json({ error: 'Internal server error' }, { status: 500, headers: NO_STORE });
}
}
+13
View File
@@ -0,0 +1,13 @@
import { NextRequest, NextResponse } from 'next/server';
import { getPairingStatus } from '@/lib/auth/pairing-store';
// Lets the desktop replace its QR with "Signed in on your phone" once the
// code was redeemed. Polled with the separate status id pair/create returned,
// so the code itself is never repeated in URLs or access logs. A status id
// reveals nothing but whether its own code was used.
export async function GET(request: NextRequest) {
const id = request.nextUrl.searchParams.get('id') ?? '';
const status = /^[0-9a-f]{32}$/.test(id) ? getPairingStatus(id) : 'unknown';
return NextResponse.json({ status }, { headers: { 'Cache-Control': 'no-store' } });
}
+103
View File
@@ -0,0 +1,103 @@
import { NextRequest, NextResponse } from 'next/server';
import { logger } from '@/lib/logger';
import { openPhoneRefreshToken, sealPhoneRefreshToken } from '@/lib/auth/pair-bundle';
import { getClientSecret } from '@/lib/oauth/token-exchange';
import { fetchPublicUrl } from '@/lib/security/url-guard';
import { rejectCrossOriginRequest } from '@/lib/security/same-origin';
// Token endpoint for paired phones whose grant the phone cannot renew itself:
// the client is confidential (its secret stays on this server), or the
// provider's host is one current app builds refuse to talk to. The app posts
// the standard refresh grant here; we renew at the provider that minted the
// grant, with the secret, and hand back a re-sealed refresh token.
//
// Only refresh tokens this webmail sealed for a phone are accepted (see
// lib/auth/pair-bundle.ts), so the route cannot be used to renew arbitrary
// tokens with our client secret. No cookies are involved.
const UPSTREAM_TIMEOUT_MS = 10_000;
const NO_STORE = { 'Cache-Control': 'no-store', Pragma: 'no-cache' };
function oauthError(error: string, status: number) {
return NextResponse.json({ error }, { status, headers: NO_STORE });
}
export async function POST(request: NextRequest) {
const crossOrigin = rejectCrossOriginRequest(request);
if (crossOrigin) return crossOrigin;
let params: URLSearchParams;
try {
params = new URLSearchParams(await request.text());
} catch {
return oauthError('invalid_request', 400);
}
if (params.get('grant_type') !== 'refresh_token') {
return oauthError('unsupported_grant_type', 400);
}
const sealed = openPhoneRefreshToken(params.get('refresh_token') ?? '');
if (!sealed) {
return oauthError('invalid_grant', 400);
}
const upstreamParams = new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: sealed.refreshToken,
client_id: sealed.clientId,
});
// The secret is the admin's, for the admin's servers only.
const secret = sealed.trusted ? getClientSecret(sealed.serverId) : '';
if (secret) upstreamParams.set('client_secret', secret);
let upstream: Response;
try {
const init = {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' },
body: upstreamParams.toString(),
signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS),
};
// The body carries the refresh token and the client secret: never follow
// a redirect with it. A user-chosen server goes through the
// rebinding-safe fetch (which never follows redirects either).
upstream = sealed.trusted
? await fetch(sealed.tokenEndpoint, { ...init, redirect: 'error' })
: (await fetchPublicUrl(sealed.tokenEndpoint, init)) as unknown as Response;
} catch (error) {
logger.warn('Pair token proxy: provider unreachable', {
error: error instanceof Error ? error.message : 'Unknown error',
});
return oauthError('temporarily_unavailable', 503);
}
if (!upstream.ok) {
// 400/401/403: the grant is dead (expired, revoked, password changed) and
// the app signs the account out. Anything else is an outage it waits out.
const dead = upstream.status === 400 || upstream.status === 401 || upstream.status === 403;
logger.warn('Pair token proxy: refresh rejected', { status: upstream.status });
return oauthError(dead ? 'invalid_grant' : 'temporarily_unavailable', dead ? 400 : 503);
}
let tokens: { access_token?: unknown; refresh_token?: unknown; expires_in?: unknown; token_type?: unknown };
try {
tokens = await upstream.json();
} catch {
return oauthError('temporarily_unavailable', 503);
}
if (typeof tokens.access_token !== 'string' || !tokens.access_token) {
return oauthError('temporarily_unavailable', 503);
}
const refreshToken = typeof tokens.refresh_token === 'string' && tokens.refresh_token
? tokens.refresh_token
: sealed.refreshToken;
return NextResponse.json(
{
access_token: tokens.access_token,
token_type: typeof tokens.token_type === 'string' ? tokens.token_type : 'Bearer',
...(typeof tokens.expires_in === 'number' ? { expires_in: tokens.expires_in } : {}),
refresh_token: sealPhoneRefreshToken({ ...sealed, refreshToken }),
},
{ headers: NO_STORE },
);
}
+63 -14
View File
@@ -2,17 +2,23 @@ import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { logger } from '@/lib/logger';
import { decryptPayload } from '@/lib/auth/crypto';
import { exchangeCodeForTokens } from '@/lib/oauth/token-exchange';
import { setPairReauth } from '@/lib/auth/pair-reauth';
import { exchangeCodeForTokens, getRequiredConfig, getTokenEndpoint, hasClientSecret } from '@/lib/oauth/token-exchange';
import { pairingOwner, setPairReauthInStore } from '@/lib/auth/pair-reauth';
import { stashGrant } from '@/lib/auth/pairing-store';
import { readStalwartAuthContextFromStore } from '@/lib/stalwart/auth-context';
import { isTrustedJmapServerUrl } from '@/lib/stalwart/server-fetch';
import { JmapAuthVerificationError, verifyJmapIdentity } from '@/lib/auth/verify-jmap-auth';
import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils';
import { rejectCrossOriginRequest } from '@/lib/security/same-origin';
// Completes the step-up re-authentication for device pairing. The user was sent
// to the IdP with prompt=login (see /api/auth/sso/start with purpose=reauth);
// here we verify the returned code against the pending state and exchange it to
// confirm a fresh login actually happened, then set the short-lived pairing
// re-auth proof cookie. We deliberately do NOT issue a login session or write
// any refresh-token cookies — the user is already signed in; this only proves
// recency for the pairing action.
// Completes the step-up re-authentication for device pairing. The user was
// sent to the IdP with prompt=login (see /api/auth/sso/start with
// purpose=reauth); here we verify the returned code against the pending state
// and exchange it. That fresh login becomes the paired phone's own grant: it
// is kept server-side (see pairing-store) until a code for it is redeemed, and
// the short-lived pairing proof cookie remembers which grant. We deliberately
// do NOT issue a login session or touch the desktop's refresh-token cookies -
// the user is already signed in, and the phone must not share that session.
const SSO_PENDING_COOKIE = 'sso_pending';
const SSO_PENDING_MAX_AGE_MS = 5 * 60 * 1000;
@@ -56,16 +62,59 @@ export async function POST(request: NextRequest) {
const codeVerifier = pending.code_verifier as string;
const redirectUri = pending.redirect_uri as string;
const pendingServerId = typeof pending.server_id === 'string' ? pending.server_id : null;
const slot = typeof pending.slot === 'number' && Number.isInteger(pending.slot)
&& pending.slot >= 0 && pending.slot < MAX_ACCOUNT_SLOTS ? pending.slot : 0;
if (!codeVerifier || !redirectUri) {
return NextResponse.json({ error: 'Invalid re-auth session data' }, { status: 400 });
}
// A successful exchange proves the user just authenticated at the IdP (the
// freshness is enforced by prompt=login on the authorize request). We don't
// keep the resulting tokens.
await exchangeCodeForTokens(code, codeVerifier, redirectUri, pendingServerId);
const context = readStalwartAuthContextFromStore(cookieStore, slot);
if (!context) {
return NextResponse.json({ error: 'not_signed_in' }, { status: 401 });
}
await setPairReauth();
// A successful exchange proves the user just authenticated at the IdP (the
// freshness is enforced by prompt=login on the authorize request).
const tokens = await exchangeCodeForTokens(code, codeVerifier, redirectUri, pendingServerId);
// ...but not as whom. The phone must get the account being paired, not
// whichever account the IdP prompt was answered with.
const serverUrl = context.serverUrl.replace(/\/+$/, '');
try {
await verifyJmapIdentity(serverUrl, `Bearer ${tokens.access_token}`, context.username, {
trusted: await isTrustedJmapServerUrl(serverUrl),
});
} catch (error) {
if (error instanceof JmapAuthVerificationError && (error.status === 401 || error.status === 403)) {
logger.warn('Reauth complete: fresh login is a different account');
return NextResponse.json({ error: 'account_mismatch' }, { status: 403 });
}
throw error;
}
// Without a refresh token the phone would be signed out at the first
// access-token expiry; better to say so now.
if (!tokens.refresh_token) {
logger.warn('Reauth complete: provider issued no refresh token');
return NextResponse.json({ error: 'no_refresh_token' }, { status: 502 });
}
const owner = pairingOwner(slot, context.username);
const grantId = stashGrant({
flow: 'oauth',
serverUrl,
serverId: pendingServerId,
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
expiresIn: tokens.expires_in,
issuedAt: Date.now(),
tokenEndpoint: await getTokenEndpoint(pendingServerId),
clientId: getRequiredConfig(pendingServerId).clientId,
confidential: hasClientSecret(pendingServerId),
trusted: true,
}, owner);
setPairReauthInStore(cookieStore, { grantId, owner });
logger.info('Pair step-up succeeded', { flow: 'oauth', method: 'sso' });
return NextResponse.json({ ok: true });
} catch (error) {
cookieStore.delete(SSO_PENDING_COOKIE);
+5
View File
@@ -20,6 +20,7 @@ import { readImpersonationConfig } from '@/lib/impersonation/master-config';
import { revokeImpersonationCredential } from '@/lib/impersonation/app-password';
import { IMPERSONATION_GRANT_COOKIE, openImpersonationGrant } from '@/lib/impersonation/grant-cookie';
import { revokeWopiTokens } from '@/lib/wopi/revocation';
import { clearPairReauthInStore } from '@/lib/auth/pair-reauth';
function sessionCookieOptions() {
return {
@@ -258,11 +259,15 @@ export async function DELETE(request: NextRequest) {
clearStalwartAuthContextInStore(cookieStore, i);
}
await revokeImpersonationGrant(cookieStore);
// A phone that already paired keeps its own sign-in; a QR still on
// screen stops working.
clearPairReauthInStore(cookieStore);
} else {
const slot = getSlot(request);
cookieStore.delete(sessionCookieName(slot));
clearStalwartAuthContextInStore(cookieStore, slot);
if (slot === 0) await revokeImpersonationGrant(cookieStore);
clearPairReauthInStore(cookieStore, slot);
}
return NextResponse.json({ ok: true });
+19 -5
View File
@@ -21,6 +21,19 @@ const SSO_PENDING_MAX_AGE = 300; // 5 minutes
// caller-controlled URL the attacker chose.
const MOBILE_REDIRECT_SCHEME = 'bulwarkmobile://';
/**
* A re-auth mints the grant a paired phone keeps using. Providers such as
* Keycloak tie ordinary refresh tokens to the browser's login session, so
* signing out on the desktop (RP-initiated logout) would sign the phone out
* too; an `offline_access` token outlives that session. Only asked for when
* the provider advertises it, as an unknown scope can fail the request.
*/
function reauthScopes(scopes: string, isReauth: boolean, supported: string[] | undefined): string {
if (!isReauth || !supported?.includes('offline_access')) return scopes;
const list = scopes.split(/\s+/).filter(Boolean);
return list.includes('offline_access') ? scopes : [...list, 'offline_access'].join(' ');
}
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.
@@ -66,11 +79,11 @@ export async function POST(request: NextRequest) {
// doesn't know that account's server id, so read it from the slot's
// server cookie like pair/create does. Without it, deployments that only
// configure OAuth per server (no global client/server) fail discovery.
const reauthSlot =
typeof rawSlot === 'number' && Number.isInteger(rawSlot) && rawSlot >= 0 && rawSlot < MAX_ACCOUNT_SLOTS ? rawSlot : 0;
let serverId = typeof bodyServerId === 'string' && bodyServerId ? bodyServerId : null;
if (!serverId && isReauth) {
const slot =
typeof rawSlot === 'number' && rawSlot >= 0 && rawSlot < MAX_ACCOUNT_SLOTS ? rawSlot : 0;
serverId = cookieStore.get(refreshTokenServerCookieName(slot))?.value || null;
serverId = cookieStore.get(refreshTokenServerCookieName(reauthSlot))?.value || null;
}
// Validate redirect_uri origin matches the request origin to prevent open redirects
@@ -110,7 +123,8 @@ export async function POST(request: NextRequest) {
...(serverId ? { server_id: serverId } : {}),
...(mobileRedirectUri ? { mobile_redirect_uri: mobileRedirectUri } : {}),
...(mobileState ? { mobile_state: mobileState } : {}),
...(isReauth ? { purpose: 'reauth' } : {}),
// The re-auth completes into a grant for the phone paired to this slot.
...(isReauth ? { purpose: 'reauth', slot: reauthSlot } : {}),
};
const encrypted = encryptPayload(pendingData, 'sso-pending');
@@ -129,7 +143,7 @@ export async function POST(request: NextRequest) {
authUrl.searchParams.set('response_type', 'code');
authUrl.searchParams.set('client_id', clientId);
authUrl.searchParams.set('redirect_uri', redirect_uri);
authUrl.searchParams.set('scope', getOauthScopes());
authUrl.searchParams.set('scope', reauthScopes(getOauthScopes(), isReauth, metadata.scopes_supported));
authUrl.searchParams.set('state', state);
authUrl.searchParams.set('code_challenge', codeChallenge);
authUrl.searchParams.set('code_challenge_method', 'S256');
@@ -0,0 +1,463 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { act } from 'react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { LinkDeviceSection, AccountSecuritySettings } from '../account-security-settings';
import { useAccountStore, type AccountEntry } from '@/stores/account-store';
import { useAccountSecurityStore } from '@/stores/account-security-store';
import { useAuthStore } from '@/stores/auth-store';
const mocks = vi.hoisted(() => ({
lite: { value: false },
oauthEnabled: { value: false },
apiFetch: vi.fn(),
toDataURL: vi.fn(async () => 'data:image/png;base64,QR'),
}));
// A stable `t` (keys come back as-is) that also has `t.raw` for the
// "not available" note.
vi.mock('next-intl', () => {
const t = Object.assign((key: string) => key, { raw: (key: string) => key });
return { useTranslations: () => t, useLocale: () => 'en' };
});
vi.mock('@/lib/lite', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/lite')>();
return {
...actual,
get IS_LITE() {
return mocks.lite.value;
},
};
});
vi.mock('@/lib/browser-navigation', async (importOriginal) => ({
...(await importOriginal<typeof import('@/lib/browser-navigation')>()),
apiFetch: mocks.apiFetch,
getPathPrefix: () => '',
}));
vi.mock('qrcode', () => ({ default: { toDataURL: mocks.toDataURL } }));
vi.mock('@/hooks/use-config', () => ({
useConfig: () => ({ oauthEnabled: mocks.oauthEnabled.value }),
}));
interface Reply {
status: number;
body: unknown;
}
const OK = (code = 'CODE-1', statusId = 'st-1', expiresIn = 120): Reply => ({
status: 200,
body: { pairing_code: code, status_id: statusId, expires_in: expiresIn },
});
const ERR = (status: number, error: string): Reply => ({ status, body: { error } });
let createReplies: Reply[] = [];
let pairStatus = 'pending';
function reply({ status, body }: Reply) {
return { ok: status >= 200 && status < 300, status, json: async () => body };
}
function callsTo(prefix: string) {
return mocks.apiFetch.mock.calls.filter(([url]) => String(url).startsWith(prefix));
}
const createBodies = () =>
callsTo('/api/auth/pair/create').map(([, init]) => JSON.parse((init as RequestInit).body as string));
const ssoBodies = () =>
callsTo('/api/auth/sso/start').map(([, init]) => JSON.parse((init as RequestInit).body as string));
const statusCalls = () => callsTo('/api/auth/pair/status');
function setAccount(providerSession?: boolean) {
const entry = {
id: 'me@example.com@mail.example.com',
label: 'me@example.com',
serverUrl: 'https://mail.example.com',
username: 'me@example.com',
authMode: providerSession ? 'oauth' : 'basic',
cookieSlot: 2,
rememberMe: false,
providerSession,
displayName: '',
email: 'me@example.com',
avatarColor: '#000000',
lastLoginAt: 0,
isConnected: true,
hasError: false,
isDefault: true,
} as AccountEntry;
useAccountStore.setState({ accounts: [entry], activeAccountId: entry.id });
}
const origin = () => window.location.origin;
const expectedBase = () => ({
slot: 2,
webmail_base: origin(),
redirect_uri: `${origin()}/en/auth/callback`,
});
const announcement = (container: HTMLElement) =>
container.querySelector('p.sr-only[aria-live="polite"]')?.textContent ?? '';
function clickGenerate() {
fireEvent.click(screen.getByRole('button', { name: 'link_device.generate' }));
}
async function submitPassword(password: string, totp?: string) {
fireEvent.change(await screen.findByLabelText('password.current'), { target: { value: password } });
if (totp !== undefined) {
fireEvent.change(screen.getByLabelText('totp.verification_code'), { target: { value: totp } });
}
fireEvent.click(screen.getByRole('button', { name: 'link_device.continue' }));
}
async function advance(ms: number) {
await act(async () => {
await vi.advanceTimersByTimeAsync(ms);
});
}
beforeEach(() => {
vi.clearAllMocks();
createReplies = [];
pairStatus = 'pending';
mocks.lite.value = false;
mocks.oauthEnabled.value = false;
mocks.apiFetch.mockImplementation(async (url: string) => {
if (url === '/api/auth/pair/create') {
return reply(createReplies.shift() ?? ERR(500, 'unexpected_call'));
}
if (url.startsWith('/api/auth/pair/status')) return reply({ status: 200, body: { status: pairStatus } });
// A fragment-only URL keeps jsdom from attempting a real navigation.
if (url === '/api/auth/sso/start') return reply({ status: 200, body: { authorize_url: '#idp' } });
throw new Error(`unexpected fetch ${url}`);
});
sessionStorage.clear();
useAccountSecurityStore.setState({ otpEnabled: false });
setAccount(false);
});
afterEach(() => {
vi.useRealTimers();
window.history.replaceState(null, '', '/');
});
describe('LinkDeviceSection step-up', () => {
it('offers single sign-on from the password form when the server has it', async () => {
mocks.oauthEnabled.value = true;
createReplies = [ERR(401, 'reauth_required')];
render(<LinkDeviceSection />);
clickGenerate();
fireEvent.click(await screen.findByRole('button', { name: 'link_device.use_sso' }));
await waitFor(() => expect(ssoBodies()).toHaveLength(1));
expect(ssoBodies()[0]).toMatchObject({ purpose: 'reauth', slot: 2 });
expect(sessionStorage.getItem('pair_reauth_resume')).toBe('1');
});
it('shows no single sign-on option without it', async () => {
createReplies = [ERR(401, 'reauth_required')];
render(<LinkDeviceSection />);
clickGenerate();
await screen.findByLabelText('password.current');
expect(screen.queryByRole('button', { name: 'link_device.use_sso' })).toBeNull();
});
it('asks a password account for its password when the server wants a step-up', async () => {
createReplies = [ERR(401, 'reauth_required')];
render(<LinkDeviceSection />);
clickGenerate();
const password = await screen.findByLabelText('password.current');
expect(password).toHaveAttribute('type', 'password');
expect(password).toHaveAttribute('autocomplete', 'current-password');
expect(screen.getByText('link_device.password_prompt')).toBeInTheDocument();
expect(screen.queryByLabelText('totp.verification_code')).toBeNull();
expect(createBodies()).toEqual([expectedBase()]);
expect(ssoBodies()).toEqual([]);
fireEvent.click(screen.getByRole('button', { name: 'link_device.show_password' }));
expect(password).toHaveAttribute('type', 'text');
fireEvent.click(screen.getByRole('button', { name: 'app_passwords.cancel' }));
expect(screen.queryByLabelText('password.current')).toBeNull();
expect(screen.getByRole('button', { name: 'link_device.generate' })).toBeInTheDocument();
});
it('sends the password with webmail_base and shows the QR and sign-in link', async () => {
createReplies = [ERR(401, 'reauth_required'), OK('ABC 123')];
render(<LinkDeviceSection />);
clickGenerate();
await submitPassword('hunter2');
const img = await screen.findByAltText('link_device.qr_alt');
expect(img).toHaveAttribute('src', 'data:image/png;base64,QR');
expect(createBodies()[1]).toEqual({ ...expectedBase(), password: 'hunter2' });
const link = `bulwarkmail://pair?server=${encodeURIComponent(origin())}&code=${encodeURIComponent('ABC 123')}`;
expect(mocks.toDataURL).toHaveBeenCalledWith(link, expect.anything());
expect(screen.getByLabelText('link_device.link_label')).toHaveValue(link);
expect(screen.getByText('link_device.instructions')).toBeInTheDocument();
expect(screen.getByText('link_device.copy_hint')).toBeInTheDocument();
expect(screen.getByText('link_device.expires_in')).toBeInTheDocument();
// The password form (and the password in it) is gone.
expect(screen.queryByLabelText('password.current')).toBeNull();
const writeText = vi.fn(async () => undefined);
Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true });
fireEvent.click(screen.getByRole('button', { name: 'link_device.copy_link' }));
expect(writeText).toHaveBeenCalledWith(link);
expect(await screen.findByText('link_device.copied')).toBeInTheDocument();
});
it('sends an identity-provider account back to the IdP instead of asking for a password', async () => {
setAccount(true);
createReplies = [ERR(401, 'reauth_required')];
render(<LinkDeviceSection />);
clickGenerate();
await waitFor(() => expect(ssoBodies()).toHaveLength(1));
expect(ssoBodies()[0]).toMatchObject({
purpose: 'reauth',
slot: 2,
locale: 'en',
redirect_uri: `${origin()}/en/auth/callback`,
});
expect(sessionStorage.getItem('pair_reauth_resume')).toBe('1');
expect(screen.queryByLabelText('password.current')).toBeNull();
});
it('does not bounce to the IdP again right after an IdP re-auth', async () => {
setAccount(true);
sessionStorage.setItem('pair_reauth_done', '1');
createReplies = [ERR(401, 'reauth_required')];
render(<LinkDeviceSection />);
expect(await screen.findByRole('alert')).toHaveTextContent('link_device.error');
expect(createBodies()).toEqual([expectedBase()]);
expect(ssoBodies()).toEqual([]);
expect(sessionStorage.getItem('pair_reauth_done')).toBeNull();
});
it('keeps the IdP resume when the section is remounted before the code arrives', async () => {
setAccount(true);
sessionStorage.setItem('pair_reauth_done', '1');
let release: () => void = () => {};
const held = new Promise<void>((resolve) => { release = resolve; });
createReplies = [OK('CODE-LOST'), OK('CODE-SHOWN')]; // whichever lands on the mounted section
const firstReply = mocks.apiFetch.getMockImplementation()!;
mocks.apiFetch.mockImplementationOnce(async (url: string, init?: RequestInit) => {
await held;
return firstReply(url, init);
});
// The Security tab unmounts the section while it probes the server.
const first = render(<LinkDeviceSection />);
first.unmount();
release();
expect(sessionStorage.getItem('pair_reauth_done')).toBe('1');
render(<LinkDeviceSection />);
const link = (await screen.findByLabelText('link_device.link_label')) as HTMLInputElement;
expect(link.value).toMatch(/^bulwarkmail:\/\/pair\?server=.+&code=CODE-(LOST|SHOWN)$/);
expect(sessionStorage.getItem('pair_reauth_done')).toBeNull();
expect(ssoBodies()).toEqual([]);
});
it('reveals the TOTP field on totp_required and blames password or code after a failed code', async () => {
createReplies = [
ERR(401, 'reauth_required'),
ERR(401, 'totp_required'),
ERR(401, 'totp_required'),
OK(),
];
render(<LinkDeviceSection />);
clickGenerate();
await submitPassword('hunter2');
const totp = await screen.findByLabelText('totp.verification_code');
expect(totp).toHaveAttribute('inputmode', 'numeric');
expect(totp).toHaveAttribute('autocomplete', 'one-time-code');
expect(screen.getByRole('status')).toHaveTextContent('link_device.totp_prompt');
expect(screen.getByLabelText('password.current')).toHaveValue('hunter2');
expect(createBodies()[1]).toEqual({ ...expectedBase(), password: 'hunter2' });
fireEvent.change(totp, { target: { value: '000000' } });
fireEvent.click(screen.getByRole('button', { name: 'link_device.continue' }));
expect(await screen.findByRole('alert')).toHaveTextContent('link_device.error_password_or_code');
expect(createBodies()[2]).toEqual({ ...expectedBase(), password: 'hunter2', totp: '000000' });
expect(screen.getByLabelText('totp.verification_code')).toHaveValue('');
fireEvent.change(screen.getByLabelText('totp.verification_code'), { target: { value: '123456' } });
fireEvent.click(screen.getByRole('button', { name: 'link_device.continue' }));
await screen.findByAltText('link_device.qr_alt');
expect(createBodies()[3]).toEqual({ ...expectedBase(), password: 'hunter2', totp: '123456' });
});
it('shows the TOTP field up front when two-factor auth is on', async () => {
useAccountSecurityStore.setState({ otpEnabled: true });
createReplies = [ERR(401, 'reauth_required'), OK()];
render(<LinkDeviceSection />);
clickGenerate();
fireEvent.change(await screen.findByLabelText('password.current'), { target: { value: 'hunter2' } });
const continueButton = screen.getByRole('button', { name: 'link_device.continue' });
expect(continueButton).toBeDisabled();
fireEvent.change(screen.getByLabelText('totp.verification_code'), { target: { value: '654321' } });
expect(continueButton).toBeEnabled();
fireEvent.click(continueButton);
await screen.findByAltText('link_device.qr_alt');
expect(createBodies()[1]).toEqual({ ...expectedBase(), password: 'hunter2', totp: '654321' });
});
it('says the password is wrong on invalid_credentials and clears it', async () => {
createReplies = [ERR(401, 'reauth_required'), ERR(401, 'invalid_credentials')];
render(<LinkDeviceSection />);
clickGenerate();
await submitPassword('wrong');
expect(await screen.findByRole('alert')).toHaveTextContent('link_device.error_wrong_password');
expect(screen.getByLabelText('password.current')).toHaveValue('');
});
it.each([
[401, 'not_signed_in', 'link_device.error_not_signed_in'],
[403, 'impersonation', 'link_device.error_impersonation'],
[429, 'too_many_attempts', 'link_device.error_too_many_attempts'],
[500, 'session_secret_required', 'link_device.error_session_secret'],
[502, 'server_unreachable', 'link_device.error_server_unreachable'],
[502, 'pairing_unavailable', 'link_device.error_pairing_unavailable'],
[400, 'invalid_request', 'link_device.error'],
[500, 'something_else', 'link_device.error'],
])('maps %i %s to %s', async (status, error, key) => {
createReplies = [ERR(status, error)];
render(<LinkDeviceSection />);
clickGenerate();
expect(await screen.findByRole('alert')).toHaveTextContent(new RegExp(`^${key}$`));
});
it('shows the generic error when the request fails outright', async () => {
mocks.apiFetch.mockRejectedValueOnce(new TypeError('Failed to fetch'));
render(<LinkDeviceSection />);
clickGenerate();
expect(await screen.findByRole('alert')).toHaveTextContent(/^link_device\.error$/);
});
});
describe('LinkDeviceSection status polling', () => {
beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true });
});
it('swaps the QR for a success panel once the phone redeems the code, then stops polling', async () => {
createReplies = [OK('C1', 'st-9'), ERR(401, 'reauth_required')];
const { container } = render(<LinkDeviceSection />);
clickGenerate();
await screen.findByAltText('link_device.qr_alt');
await advance(2000);
expect(statusCalls()).toHaveLength(1);
expect(statusCalls()[0][0]).toBe('/api/auth/pair/status?id=st-9');
expect(screen.getByAltText('link_device.qr_alt')).toBeInTheDocument();
pairStatus = 'redeemed';
await advance(2000);
await waitFor(() => expect(screen.getByText('link_device.success_body')).toBeInTheDocument());
expect(screen.queryByAltText('link_device.qr_alt')).toBeNull();
expect(announcement(container)).toBe('link_device.success_title');
const polled = statusCalls().length;
await advance(6000);
expect(statusCalls()).toHaveLength(polled);
// "Link another device" mints again; the used grant means a new step-up.
fireEvent.click(screen.getByRole('button', { name: 'link_device.link_another' }));
expect(await screen.findByLabelText('password.current')).toBeInTheDocument();
expect(createBodies()[1]).toEqual(expectedBase());
});
it('offers a new code when the server reports the code expired', async () => {
createReplies = [OK('C1', 'st-1'), OK('C2', 'st-2')];
const { container } = render(<LinkDeviceSection />);
clickGenerate();
await screen.findByAltText('link_device.qr_alt');
pairStatus = 'expired';
await advance(2000);
await waitFor(() => expect(announcement(container)).toBe('link_device.expired'));
expect(screen.queryByAltText('link_device.qr_alt')).toBeNull();
pairStatus = 'pending';
fireEvent.click(screen.getByRole('button', { name: 'link_device.regenerate' }));
await screen.findByAltText('link_device.qr_alt');
// Tried without a password first: the server may still hold a live grant.
expect(createBodies()[1]).toEqual(expectedBase());
await advance(2000);
expect(statusCalls().at(-1)?.[0]).toBe('/api/auth/pair/status?id=st-2');
});
it('treats an unknown status id as expired', async () => {
createReplies = [OK()];
const { container } = render(<LinkDeviceSection />);
clickGenerate();
await screen.findByAltText('link_device.qr_alt');
pairStatus = 'unknown';
await advance(2000);
await waitFor(() => expect(announcement(container)).toBe('link_device.expired'));
expect(screen.getByRole('button', { name: 'link_device.regenerate' })).toBeInTheDocument();
});
it('expires the code when the countdown runs out', async () => {
createReplies = [OK('C1', 'st-1', 3)];
const { container } = render(<LinkDeviceSection />);
clickGenerate();
await screen.findByAltText('link_device.qr_alt');
await advance(3000);
await waitFor(() => expect(announcement(container)).toBe('link_device.expired'));
expect(screen.queryByAltText('link_device.qr_alt')).toBeNull();
});
it('stops polling on unmount', async () => {
createReplies = [OK()];
const { unmount } = render(<LinkDeviceSection />);
clickGenerate();
await screen.findByAltText('link_device.qr_alt');
unmount();
await advance(6000);
expect(statusCalls()).toHaveLength(0);
});
});
describe('AccountSecuritySettings link-device visibility', () => {
beforeEach(() => {
useAuthStore.setState({ isAuthenticated: false, authMode: 'basic', client: null });
});
it('shows the linker to password accounts on servers without account management', () => {
useAccountSecurityStore.setState({ isStalwart: false, isProbing: false });
render(<AccountSecuritySettings />);
expect(screen.getByText('link_device.title')).toBeInTheDocument();
expect(screen.getByText('not_available')).toBeInTheDocument();
});
it('shows the linker to password accounts on Stalwart, without the OAuth-only email client box', () => {
useAccountSecurityStore.setState({ isStalwart: true, isProbing: false });
render(<AccountSecuritySettings />);
expect(screen.getByText('link_device.title')).toBeInTheDocument();
expect(screen.queryByText('email_client.title')).toBeNull();
});
it('hides the linker in Bulwark Lite', () => {
mocks.lite.value = true;
useAccountSecurityStore.setState({ isStalwart: false, isProbing: false });
render(<AccountSecuritySettings />);
expect(screen.queryByText('link_device.title')).toBeNull();
expect(screen.getByText('not_available')).toBeInTheDocument();
});
});
+453 -76
View File
@@ -1,11 +1,11 @@
'use client';
import { useState, useEffect, useMemo, useCallback } from 'react';
import { useState, useEffect, useMemo, useCallback, useId, useRef } from 'react';
import { useParams } from 'next/navigation';
import { useTranslations } from 'next-intl';
import QRCode from 'qrcode';
import * as OTPAuth from 'otpauth';
import { Shield, Key, Smartphone, Lock, Trash2, Plus, Eye, EyeOff, Copy, Check, Loader2, Monitor, Terminal, QrCode, Unlock } from '@/components/icons';
import { Shield, Key, Smartphone, Lock, Trash2, Plus, Eye, EyeOff, Copy, Check, CheckCircle, Loader2, Monitor, Terminal, QrCode, Unlock, RefreshCw } from '@/components/icons';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
@@ -16,6 +16,8 @@ import { apiFetch, getPathPrefix } from '@/lib/browser-navigation';
import { toast } from '@/stores/toast-store';
import { cn } from '@/lib/utils';
import { sanitizeI18nHtml } from '@/lib/email-sanitization';
import { IS_LITE } from '@/lib/lite';
import { useConfig } from '@/hooks/use-config';
function PasswordChangeSection() {
const t = useTranslations('settings.security');
@@ -913,34 +915,131 @@ function EmailClientSection() {
);
}
// Cross-device QR login. A signed-in (OAuth/SSO) session mints a short-lived
// pairing code via /api/auth/pair/create; we render it as a QR that the mobile
// app scans to sign in without re-typing credentials. The QR payload carries
// only the server URL and the one-time code — never tokens.
function LinkDeviceSection() {
type LinkDevicePhase = 'idle' | 'password' | 'code' | 'redeemed' | 'expired';
interface LinkDeviceMessage {
text: string;
tone: 'error' | 'info';
}
interface PairCreateOptions {
/** Step-up proof from the password form; omitted on the first try. */
password?: string;
totp?: string;
/** Just back from the IdP re-auth: never bounce to the IdP again. */
fromResume?: boolean;
}
const PAIR_STATUS_POLL_MS = 2000;
const PAIR_DEFAULT_EXPIRES_IN = 120;
// Cross-device sign-in for the mobile app. /api/auth/pair/create mints a
// short-lived, single-use code for the active account; we show it as a QR
// (plus a copyable bulwarkmail:// link) that the app redeems against this
// webmail for a sign-in of its own. The payload carries only the webmail base
// URL and the code, never tokens or the password.
//
// Minting needs a fresh proof of identity, so the server answers
// `reauth_required` until the user has just re-authenticated: accounts that
// signed in through the identity provider go back there (prompt=login),
// password accounts confirm their password (and TOTP code) right here.
export function LinkDeviceSection() {
const t = useTranslations('settings.security');
const params = useParams();
const locale = params.locale as string;
const otpEnabled = useAccountSecurityStore((s) => s.otpEnabled);
const { oauthEnabled } = useConfig();
const [phase, setPhase] = useState<LinkDevicePhase>('idle');
const [loading, setLoading] = useState(false);
const [message, setMessage] = useState<LinkDeviceMessage | null>(null);
const [password, setPassword] = useState('');
const [totp, setTotp] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [totpRequested, setTotpRequested] = useState(false);
const [qrDataUrl, setQrDataUrl] = useState<string | null>(null);
const [signInLink, setSignInLink] = useState('');
const [statusId, setStatusId] = useState<string | null>(null);
const [expiresAt, setExpiresAt] = useState(0);
const [remaining, setRemaining] = useState(0);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [hasGenerated, setHasGenerated] = useState(false);
const [copied, setCopied] = useState(false);
const fieldId = useId();
const passwordInputRef = useRef<HTMLInputElement>(null);
const totpInputRef = useRef<HTMLInputElement>(null);
const linkInputRef = useRef<HTMLInputElement>(null);
// False once unmounted, so late responses and timers leave state alone.
const aliveRef = useRef(true);
// Bumped by every create request and by cancel: a response that is no
// longer the latest request is dropped.
const requestRef = useRef(0);
const copiedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const showTotp = otpEnabled || totpRequested;
// Tick the countdown down to zero, then drop the (now useless) QR so the
// user is nudged to generate a fresh one.
useEffect(() => {
if (remaining <= 0) {
setQrDataUrl(null);
return;
}
const timer = setInterval(() => setRemaining((r) => Math.max(0, r - 1)), 1000);
aliveRef.current = true;
return () => {
aliveRef.current = false;
if (copiedTimerRef.current) clearTimeout(copiedTimerRef.current);
};
}, []);
// Count down from the server's expiry; at zero the code is useless, so the
// QR gives way to the "expired" panel.
useEffect(() => {
if (phase !== 'code') return;
const timer = setInterval(() => {
const left = Math.max(0, Math.ceil((expiresAt - Date.now()) / 1000));
setRemaining(left);
if (left <= 0) setPhase('expired');
}, 1000);
return () => clearInterval(timer);
}, [remaining]);
}, [phase, expiresAt]);
// Watch the shown code: once the phone redeems it, swap the QR for a
// confirmation; if the server forgot or expired it, offer a new one. A new
// code (new statusId) or leaving the "code" phase restarts or stops this.
useEffect(() => {
if (phase !== 'code' || !statusId) return;
let cancelled = false;
let inFlight = false;
const poll = async () => {
if (inFlight) return;
inFlight = true;
try {
const res = await apiFetch(`/api/auth/pair/status?id=${encodeURIComponent(statusId)}`, {
credentials: 'include',
});
if (!res.ok) return;
const data = await res.json().catch(() => null);
if (cancelled) return;
const status = data && typeof data === 'object' ? (data as { status?: unknown }).status : undefined;
if (status === 'redeemed') setPhase('redeemed');
else if (status === 'expired' || status === 'unknown') setPhase('expired');
} catch {
/* transient network error: try again on the next tick */
} finally {
inFlight = false;
}
};
const timer = setInterval(() => { void poll(); }, PAIR_STATUS_POLL_MS);
return () => {
cancelled = true;
clearInterval(timer);
};
}, [phase, statusId]);
const clearCredentials = useCallback(() => {
setPassword('');
setTotp('');
setShowPassword(false);
setTotpRequested(false);
}, []);
// Send the user to the IdP for a fresh login (prompt=login). On return the
// callback page sets the pairing re-auth proof and bounces back here, where
// the resume effect below re-runs generate().
// the resume effect below calls create() again.
const startReauth = useCallback(async () => {
try {
sessionStorage.setItem('pair_reauth_resume', '1');
@@ -957,98 +1056,370 @@ function LinkDeviceSection() {
body: JSON.stringify({ redirect_uri: redirectUri, locale, purpose: 'reauth', slot }),
});
if (!res.ok) {
setError(t('link_device.error'));
if (aliveRef.current) setMessage({ text: t('link_device.error'), tone: 'error' });
return;
}
const { authorize_url } = await res.json();
window.location.href = authorize_url;
}, [locale, t]);
// `fromResume` guards against a redirect loop: if we just completed re-auth
// and pair/create still demands it, surface an error instead of bouncing to
// the IdP again.
const generate = useCallback(async (fromResume = false) => {
const create = useCallback(async ({ password: pw, totp: code, fromResume = false }: PairCreateOptions = {}) => {
const request = ++requestRef.current;
const isCurrent = () => aliveRef.current && request === requestRef.current;
const fail = (text: string, tone: LinkDeviceMessage['tone'] = 'error') => setMessage({ text, tone });
const backToIdle = (text: string) => {
clearCredentials();
setPhase('idle');
fail(text);
};
setLoading(true);
setError(null);
setMessage(null);
try {
// Pair the account whose session cookie we'll actually refresh — the
// active account's slot.
const slot = useAccountStore.getState().getActiveAccount()?.cookieSlot ?? 0;
// Pair the account whose session cookie the server reads: the active
// account's slot.
const account = useAccountStore.getState().getActiveAccount();
const slot = account?.cookieSlot ?? 0;
// The phone redeems the code against THIS webmail (where the pairing
// record lives), so the link carries the webmail base (origin plus any
// mount prefix), not the JMAP server URL. The JMAP server_url comes
// back in the redeem response.
const webmailBase = `${window.location.origin}${getPathPrefix()}`;
// This webmail's own OAuth callback, built like startReauth's. The
// server only uses it for servers that insist on a registered redirect
// during the password step-up.
const redirectUri = `${window.location.origin}${getPathPrefix(locale)}/${locale}/auth/callback`;
const body: {
slot: number;
webmail_base: string;
redirect_uri: string;
password?: string;
totp?: string;
} = {
slot,
webmail_base: webmailBase,
redirect_uri: redirectUri,
};
if (pw !== undefined) {
body.password = pw;
if (code) body.totp = code;
}
const res = await apiFetch('/api/auth/pair/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ slot }),
body: JSON.stringify(body),
});
if (!isCurrent()) return;
if (!res.ok) {
const errBody = await res.json().catch(() => null);
if (res.status === 401 && errBody?.error === 'reauth_required' && !fromResume) {
await startReauth();
return;
if (!isCurrent()) return;
const reason = errBody && typeof errBody.error === 'string' ? errBody.error : '';
switch (reason) {
case 'reauth_required':
if (account?.providerSession === true) {
// `fromResume` guards against a redirect loop: if we just came
// back from the IdP and the server still wants a re-auth, say so
// instead of bouncing to the IdP again.
if (fromResume) fail(t('link_device.error'));
else await startReauth();
return;
}
setPhase('password');
// A password we just sent was not enough: say so rather than
// silently asking again.
if (pw !== undefined) fail(t('link_device.error'));
return;
case 'invalid_credentials':
setPhase('password');
setPassword('');
setTotp('');
fail(t('link_device.error_wrong_password'));
passwordInputRef.current?.focus();
return;
case 'totp_required':
setPhase('password');
setTotpRequested(true);
setTotp('');
// With a code already sent this is a failed attempt, and Stalwart
// answers a wrong code exactly like a wrong password, so name both.
if (code) fail(t('link_device.error_password_or_code'));
else fail(t('link_device.totp_prompt'), 'info');
// Not mounted yet on the first request; its autoFocus covers that.
totpInputRef.current?.focus();
return;
case 'not_signed_in':
backToIdle(t('link_device.error_not_signed_in'));
return;
case 'impersonation':
backToIdle(t('link_device.error_impersonation'));
return;
case 'session_secret_required':
backToIdle(t('link_device.error_session_secret'));
return;
case 'pairing_unavailable':
backToIdle(t('link_device.error_pairing_unavailable'));
return;
case 'too_many_attempts':
setTotp('');
fail(t('link_device.error_too_many_attempts'));
return;
case 'server_unreachable':
setTotp('');
fail(t('link_device.error_server_unreachable'));
return;
default:
// Includes `invalid_request`.
fail(t('link_device.error'));
return;
}
setError(t('link_device.error'));
return;
}
const data = await res.json();
const code = data.pairing_code as string;
const expiresIn = typeof data.expires_in === 'number' ? data.expires_in : 120;
// The phone redeems the code against THIS webmail (where the pairing
// record lives), so the QR carries the webmail base — origin plus any
// mount prefix — not the JMAP server URL. The JMAP server_url comes back
// in the redeem response.
const webmailBase = `${window.location.origin}${getPathPrefix()}`;
const payload = `bulwarkmail://pair?server=${encodeURIComponent(webmailBase)}&code=${encodeURIComponent(code)}`;
const dataUrl = await QRCode.toDataURL(payload, { width: 240, margin: 1 });
setQrDataUrl(dataUrl);
setRemaining(expiresIn);
setHasGenerated(true);
} catch {
setError(t('link_device.error'));
} finally {
setLoading(false);
}
}, [t, startReauth]);
// Auto-resume after returning from the step-up re-auth round-trip.
const data = await res.json();
const pairingCode = typeof data?.pairing_code === 'string' ? data.pairing_code : '';
if (!pairingCode) throw new Error('pair/create returned no pairing_code');
const expiresIn = typeof data.expires_in === 'number' && data.expires_in > 0
? data.expires_in
: PAIR_DEFAULT_EXPIRES_IN;
const link = `bulwarkmail://pair?server=${encodeURIComponent(webmailBase)}&code=${encodeURIComponent(pairingCode)}`;
const dataUrl = await QRCode.toDataURL(link, { width: 240, margin: 1 });
if (!isCurrent()) return;
clearCredentials();
setQrDataUrl(dataUrl);
setSignInLink(link);
setStatusId(typeof data.status_id === 'string' && data.status_id ? data.status_id : null);
setExpiresAt(Date.now() + expiresIn * 1000);
setRemaining(expiresIn);
setCopied(false);
setPhase('code');
} catch {
if (isCurrent()) fail(t('link_device.error'));
} finally {
if (isCurrent()) {
setLoading(false);
// The resume is done only once a mounted section has shown its result.
if (fromResume) {
try { sessionStorage.removeItem('pair_reauth_done'); } catch { /* unavailable */ }
}
}
}
}, [t, locale, startReauth, clearCredentials]);
// Auto-resume after returning from the IdP re-auth round-trip. Runs once on
// mount; the ref keeps it off `create`'s identity. The flag stays until the
// result is shown: the Security tab remounts this section while it probes
// the server, and a response that lands on the unmounted one is dropped.
const createRef = useRef(create);
useEffect(() => {
createRef.current = create;
}, [create]);
useEffect(() => {
let resume = false;
try {
resume = sessionStorage.getItem('pair_reauth_done') === '1';
if (resume) sessionStorage.removeItem('pair_reauth_done');
} catch { /* sessionStorage unavailable */ }
if (resume) void generate(true);
}, [generate]);
if (resume) void createRef.current({ fromResume: true });
}, []);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (loading || !password || (showTotp && !totp.trim())) return;
void create({ password, totp: showTotp ? totp.trim() : undefined });
};
const handleCancel = () => {
requestRef.current += 1; // drop an in-flight request
clearCredentials();
setLoading(false);
setMessage(null);
setPhase('idle');
};
const handleCopyLink = () => {
if (!signInLink) return;
const clipboard = typeof navigator !== 'undefined' ? navigator.clipboard : undefined;
if (!clipboard) {
// No clipboard API (e.g. plain http): select it for a manual copy.
linkInputRef.current?.select();
return;
}
clipboard.writeText(signInLink).then(() => {
if (!aliveRef.current) return;
setCopied(true);
if (copiedTimerRef.current) clearTimeout(copiedTimerRef.current);
copiedTimerRef.current = setTimeout(() => {
if (aliveRef.current) setCopied(false);
}, 2000);
}).catch(() => {
linkInputRef.current?.select();
});
};
const messageNode = message && (
message.tone === 'error' ? (
<p role="alert" className="text-xs text-destructive">{message.text}</p>
) : (
<p role="status" className="text-xs text-muted-foreground">{message.text}</p>
)
);
const announcement =
phase === 'redeemed' ? t('link_device.success_title')
: phase === 'expired' ? t('link_device.expired')
: '';
return (
<div className="space-y-3">
<p className="sr-only" aria-live="polite">{announcement}</p>
<div className="flex items-center gap-2">
<QrCode className="w-4 h-4 text-muted-foreground" />
<h4 className="text-sm font-medium text-foreground">{t('link_device.title')}</h4>
</div>
<p className="text-xs text-muted-foreground">{t('link_device.description')}</p>
{qrDataUrl && remaining > 0 && (
<div className="p-3 bg-muted/70 dark:bg-muted/40 rounded-md space-y-2">
{phase === 'password' && (
<form onSubmit={handleSubmit} className="p-3 bg-muted/70 dark:bg-muted/40 rounded-md space-y-3">
<p className="text-xs text-muted-foreground">{t('link_device.password_prompt')}</p>
<div>
<label htmlFor={`${fieldId}-password`} className="text-xs text-muted-foreground mb-1 block">
{t('password.current')}
</label>
<div className="relative">
<Input
id={`${fieldId}-password`}
ref={passwordInputRef}
type={showPassword ? 'text' : 'password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
required
autoFocus
autoComplete="current-password"
className="pe-10"
/>
<button
type="button"
onClick={() => setShowPassword((v) => !v)}
aria-label={showPassword ? t('link_device.hide_password') : t('link_device.show_password')}
className="absolute end-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
</div>
{showTotp && (
<div>
<label htmlFor={`${fieldId}-totp`} className="text-xs text-muted-foreground mb-1 block">
{t('totp.verification_code')}
</label>
<Input
id={`${fieldId}-totp`}
ref={totpInputRef}
value={totp}
onChange={(e) => setTotp(e.target.value)}
required
autoFocus={totpRequested}
inputMode="numeric"
autoComplete="one-time-code"
maxLength={6}
/>
</div>
)}
{messageNode}
<div className="flex gap-2">
<Button type="submit" size="sm" disabled={loading || !password || (showTotp && !totp.trim())}>
{loading ? <Loader2 className="w-4 h-4 me-1 animate-spin" /> : null}
{t('link_device.continue')}
</Button>
<Button type="button" size="sm" variant="ghost" onClick={handleCancel}>
{t('app_passwords.cancel')}
</Button>
</div>
{/* Sessions from before `providerSession` was recorded land here even
when they came through the identity provider, and an SSO-only
account has no password to type. */}
{oauthEnabled && (
<button
type="button"
onClick={() => void startReauth()}
className="text-xs text-muted-foreground underline underline-offset-2 hover:text-foreground"
>
{t('link_device.use_sso')}
</button>
)}
</form>
)}
{phase === 'code' && qrDataUrl && (
<div className="p-3 bg-muted/70 dark:bg-muted/40 rounded-md space-y-3">
<div className="flex justify-center">
<img src={qrDataUrl} alt="Pairing QR code" className="rounded bg-white p-2" />
<img src={qrDataUrl} alt={t('link_device.qr_alt')} className="rounded bg-white p-2" />
</div>
<p className="text-xs text-muted-foreground text-center">{t('link_device.instructions')}</p>
<div className="space-y-1.5">
<label htmlFor={`${fieldId}-link`} className="text-xs text-muted-foreground block">
{t('link_device.link_label')}
</label>
<input
id={`${fieldId}-link`}
ref={linkInputRef}
type="text"
readOnly
dir="ltr"
value={signInLink}
onFocus={(e) => e.currentTarget.select()}
className="py-2 px-3 block w-full bg-background border border-border rounded-md text-xs font-mono text-foreground focus:border-ring focus:ring-ring"
/>
<Button type="button" variant="outline" size="sm" onClick={handleCopyLink}>
{copied ? <Check className="w-3 h-3 me-1" /> : <Copy className="w-3 h-3 me-1" />}
<span aria-live="polite">{copied ? t('link_device.copied') : t('link_device.copy_link')}</span>
</Button>
<p className="text-[11px] text-muted-foreground">{t('link_device.copy_hint')}</p>
</div>
<p className="text-[11px] text-muted-foreground text-center">
{t('link_device.expires_in', { seconds: remaining })}
</p>
</div>
)}
{error && <p className="text-xs text-destructive">{error}</p>}
{phase === 'redeemed' && (
<div className="p-3 bg-muted/70 dark:bg-muted/40 rounded-md flex flex-col items-center text-center gap-2">
<CheckCircle className="w-8 h-8 text-green-600 dark:text-green-400" aria-hidden="true" />
<p className="text-sm font-medium text-foreground">{t('link_device.success_title')}</p>
<p className="text-xs text-muted-foreground">{t('link_device.success_body')}</p>
<Button variant="outline" size="sm" onClick={() => void create()} disabled={loading}>
{loading ? <Loader2 className="w-3 h-3 me-1 animate-spin" /> : <Plus className="w-3 h-3 me-1" />}
{t('link_device.link_another')}
</Button>
</div>
)}
<Button variant="outline" size="sm" onClick={() => void generate()} disabled={loading}>
{loading ? (
<Loader2 className="w-3 h-3 me-1 animate-spin" />
) : (
<QrCode className="w-3 h-3 me-1" />
)}
{hasGenerated ? t('link_device.regenerate') : t('link_device.generate')}
</Button>
{phase === 'expired' && (
<div className="p-3 bg-muted/70 dark:bg-muted/40 rounded-md flex flex-col items-center text-center gap-2">
<p className="text-sm text-foreground">{t('link_device.expired')}</p>
<Button variant="outline" size="sm" onClick={() => void create()} disabled={loading}>
{loading ? <Loader2 className="w-3 h-3 me-1 animate-spin" /> : <RefreshCw className="w-3 h-3 me-1" />}
{t('link_device.regenerate')}
</Button>
</div>
)}
{phase !== 'password' && messageNode}
{phase === 'idle' && (
<Button variant="outline" size="sm" onClick={() => void create()} disabled={loading}>
{loading ? <Loader2 className="w-3 h-3 me-1 animate-spin" /> : <QrCode className="w-3 h-3 me-1" />}
{t('link_device.generate')}
</Button>
)}
{phase === 'code' && (
<Button variant="ghost" size="sm" onClick={() => void create()} disabled={loading}>
{loading ? <Loader2 className="w-3 h-3 me-1 animate-spin" /> : <RefreshCw className="w-3 h-3 me-1" />}
{t('link_device.regenerate')}
</Button>
)}
</div>
);
}
@@ -1093,16 +1464,17 @@ export function AccountSecuritySettings() {
if (isStalwart === false) {
// Even when Stalwart account-management isn't exposed (common for OAuth
// sessions, whose tokens may lack the management capability), the
// cross-device mobile pairing still works — it only needs the OAuth
// refresh-token cookie, not `urn:stalwart:jmap`. So surface the QR linker
// for OAuth sessions and show the "not available" note for the rest.
// sessions, whose tokens may lack the management capability), linking
// the mobile app still works for every sign-in method: it runs through
// this webmail's own /api/auth/pair routes, not `urn:stalwart:jmap`. So
// show the linker above the "not available" note (except in Lite, which
// has no server routes).
// Use t.raw (not t) because the message is hand-injected HTML; passing it
// through t() makes next-intl try to parse the <a> tag and throw
// INVALID_TAG.
return (
<SettingsSection title={t('title')} description={t('description')}>
{isOAuth ? (
{!IS_LITE ? (
<div className="space-y-6">
<LinkDeviceSection />
<div className="border-t border-border" />
@@ -1140,12 +1512,17 @@ export function AccountSecuritySettings() {
<div className="border-t border-border" />
<ApiKeysSection />
{!IS_LITE && (
<>
<div className="border-t border-border" />
<LinkDeviceSection />
</>
)}
{isOAuth && (
<>
<div className="border-t border-border" />
<EmailClientSection />
<div className="border-t border-border" />
<LinkDeviceSection />
</>
)}
<div className="border-t border-border" />
@@ -218,6 +218,7 @@ const MUTATING_HANDLERS: Array<[string, string]> = [
['@/app/api/auth/token/route', 'DELETE'],
['@/app/api/auth/pair/create/route', 'POST'],
['@/app/api/auth/pair/redeem/route', 'POST'],
['@/app/api/auth/pair/token/route', 'POST'],
['@/app/api/auth/sso/start/route', 'POST'],
['@/app/api/auth/sso/complete/route', 'POST'],
['@/app/api/auth/reauth/sso/complete/route', 'POST'],
+64
View File
@@ -0,0 +1,64 @@
// @vitest-environment node
import { beforeEach, describe, expect, it } from 'vitest';
import {
clearPairStepUpFailures,
isPairStepUpLocked,
recordPairStepUpFailure,
} from '@/lib/auth/pair-attempts';
// Failed password step-ups for "Link mobile app": five per signed-in account
// within fifteen minutes, then the account is locked out of the step-up
// until the window runs out.
const OWNER = '0:alice@example.org';
const T0 = 1_800_000_000_000;
const WINDOW_MS = 15 * 60 * 1000;
beforeEach(() => {
delete (globalThis as Record<string, unknown>).__bulwarkPairAttempts;
});
describe('pair step-up attempts', () => {
it('locks after five failures', () => {
for (let i = 0; i < 4; i++) recordPairStepUpFailure(OWNER, T0 + i);
expect(isPairStepUpLocked(OWNER, T0 + 10)).toBe(false);
recordPairStepUpFailure(OWNER, T0 + 5);
expect(isPairStepUpLocked(OWNER, T0 + 10)).toBe(true);
});
it('unlocks when the fifteen-minute window has passed', () => {
for (let i = 0; i < 5; i++) recordPairStepUpFailure(OWNER, T0);
expect(isPairStepUpLocked(OWNER, T0 + WINDOW_MS - 1)).toBe(true);
expect(isPairStepUpLocked(OWNER, T0 + WINDOW_MS)).toBe(false);
// A failure after the window starts a new count.
recordPairStepUpFailure(OWNER, T0 + WINDOW_MS);
expect(isPairStepUpLocked(OWNER, T0 + WINDOW_MS + 1)).toBe(false);
});
it('counts the window from the first failure, not the last', () => {
recordPairStepUpFailure(OWNER, T0);
for (let i = 0; i < 4; i++) recordPairStepUpFailure(OWNER, T0 + WINDOW_MS - 10 + i);
expect(isPairStepUpLocked(OWNER, T0 + WINDOW_MS - 1)).toBe(true);
expect(isPairStepUpLocked(OWNER, T0 + WINDOW_MS)).toBe(false);
});
it('keeps accounts apart', () => {
for (let i = 0; i < 5; i++) recordPairStepUpFailure(OWNER, T0);
expect(isPairStepUpLocked('1:alice@example.org', T0)).toBe(false);
expect(isPairStepUpLocked('0:bob@example.org', T0)).toBe(false);
});
it('clears the count after a successful step-up', () => {
for (let i = 0; i < 5; i++) recordPairStepUpFailure(OWNER, T0);
clearPairStepUpFailures(OWNER);
expect(isPairStepUpLocked(OWNER, T0)).toBe(false);
});
it('drops windows that ran out, so the map stays bounded', () => {
recordPairStepUpFailure('0:a@example.org', T0);
recordPairStepUpFailure('0:b@example.org', T0);
recordPairStepUpFailure('0:c@example.org', T0 + WINDOW_MS);
const map = (globalThis as unknown as { __bulwarkPairAttempts: Map<string, unknown> }).__bulwarkPairAttempts;
expect([...map.keys()]).toEqual(['0:c@example.org']);
});
});
+224
View File
@@ -0,0 +1,224 @@
// @vitest-environment node
import { beforeEach, describe, expect, it, vi } from 'vitest';
// What a paired phone receives: the provider's own token endpoint when the
// app can talk to it directly (public client, a host the app accepts), else
// the webmail's token proxy with the refresh token sealed under
// SESSION_SECRET for the `pair-refresh` purpose.
let sessionSecret = 's'.repeat(64);
vi.mock('@/lib/auth/session-secret', () => ({
getSessionSecret: () => sessionSecret,
hasSessionSecret: () => sessionSecret.length > 0,
}));
vi.mock('@/lib/logger', () => ({
logger: { warn: () => {}, error: () => {}, info: () => {}, debug: () => {} },
}));
import {
PAIR_TOKEN_PROXY_PATH,
appAcceptsTokenEndpoint,
buildRedeemBundle,
openPhoneRefreshToken,
sealPhoneRefreshToken,
type SealedRefresh,
} from '@/lib/auth/pair-bundle';
import { encryptPayload } from '@/lib/auth/crypto';
import type { PairingGrant } from '@/lib/auth/pairing-store';
const SERVER = 'https://mail.example.org';
const WEBMAIL = 'https://webmail.example.net/mail';
const NOW = 1_800_000_000_000;
function oauthGrant(overrides: Partial<Extract<PairingGrant, { flow: 'oauth' }>> = {}): PairingGrant {
return {
flow: 'oauth',
serverUrl: SERVER,
serverId: null,
accessToken: 'access-1',
refreshToken: 'refresh-1',
expiresIn: 3600,
issuedAt: NOW,
tokenEndpoint: `${SERVER}/auth/token`,
clientId: 'bulwark-webmail',
confidential: false,
trusted: true,
...overrides,
};
}
beforeEach(() => {
sessionSecret = 's'.repeat(64);
});
describe('appAcceptsTokenEndpoint (mirror of the app rule)', () => {
const cases: Array<[string, string, string, string | null, boolean]> = [
['same host as the JMAP server', `${SERVER}/auth/token`, SERVER, null, true],
['same host, other port', 'https://mail.example.org:8443/auth/token', SERVER, null, true],
['host case is ignored', 'HTTPS://MAIL.EXAMPLE.ORG/auth/token', SERVER, null, true],
['subdomain of the JMAP server', 'https://auth.mail.example.org/token', SERVER, null, true],
['parent domain of the JMAP server', 'https://example.org/token', SERVER, null, true],
['sibling subdomain', 'https://idp.example.org/token', SERVER, null, false],
['look-alike suffix', 'https://evilmail.example.org/token', SERVER, null, false],
['unrelated host', 'https://idp.example.com/token', SERVER, null, false],
['the webmail host', 'https://webmail.example.net/api/auth/pair/token', SERVER, WEBMAIL, true],
['subdomain of the webmail host', 'https://sso.webmail.example.net/token', SERVER, WEBMAIL, true],
['sibling of the webmail host', 'https://idp.example.net/token', SERVER, WEBMAIL, false],
['http on a public host', 'http://mail.example.org/auth/token', SERVER, null, false],
['http on localhost', 'http://localhost:8080/auth/token', 'http://localhost:8080', null, true],
['http on 127.0.0.1', 'http://127.0.0.1:8080/auth/token', 'http://127.0.0.1:8080', null, true],
['http on the Android emulator host', 'http://10.0.2.2:8080/auth/token', 'http://10.0.2.2:8080', null, true],
['http loopback, but not the server host', 'http://localhost:8080/auth/token', SERVER, null, false],
['other private http host', 'http://192.168.1.10/auth/token', 'http://192.168.1.10', null, false],
['not a URL', 'not a url', SERVER, null, false],
['other scheme', 'ftp://mail.example.org/token', SERVER, null, false],
];
for (const [label, endpoint, server, webmail, expected] of cases) {
it(`${expected ? 'accepts' : 'rejects'} ${label}`, () => {
expect(appAcceptsTokenEndpoint(endpoint, server, webmail)).toBe(expected);
});
}
});
describe('buildRedeemBundle', () => {
it('passes a password grant through as-is', () => {
const bundle = buildRedeemBundle(
{ flow: 'password', serverUrl: SERVER, username: 'alice@example.org', password: 'app_x', credential: 'app-password' },
WEBMAIL,
NOW,
);
expect(bundle).toEqual({ flow: 'password', server_url: SERVER, username: 'alice@example.org', password: 'app_x' });
});
it('points a public client on the server host at the provider directly, with the raw refresh token', () => {
const bundle = buildRedeemBundle(oauthGrant(), WEBMAIL, NOW);
expect(bundle).toEqual({
flow: 'oauth',
server_url: SERVER,
access_token: 'access-1',
refresh_token: 'refresh-1',
expires_in: 3600,
token_endpoint: `${SERVER}/auth/token`,
client_id: 'bulwark-webmail',
});
});
it('reduces expires_in by the time elapsed since the tokens were issued', () => {
expect(buildRedeemBundle(oauthGrant(), WEBMAIL, NOW + 100_900)).toMatchObject({ expires_in: 3500 });
expect(buildRedeemBundle(oauthGrant({ expiresIn: 60 }), WEBMAIL, NOW + 120_000)).toMatchObject({ expires_in: 0 });
expect(buildRedeemBundle(oauthGrant({ expiresIn: undefined }), WEBMAIL, NOW)).not.toHaveProperty('expires_in');
});
it('leaves out a refresh token the provider did not issue', () => {
expect(buildRedeemBundle(oauthGrant({ refreshToken: undefined }), WEBMAIL, NOW)).not.toHaveProperty('refresh_token');
expect(
buildRedeemBundle(oauthGrant({ refreshToken: undefined, confidential: true }), WEBMAIL, NOW),
).not.toHaveProperty('refresh_token');
});
it('sends a confidential client through the webmail proxy with a sealed refresh token', () => {
const grant = oauthGrant({ confidential: true, serverId: 'srv-1' });
const bundle = buildRedeemBundle(grant, `${WEBMAIL}//`, NOW);
expect(bundle.flow).toBe('oauth');
if (bundle.flow !== 'oauth') return;
expect(bundle.token_endpoint).toBe(`${WEBMAIL}${PAIR_TOKEN_PROXY_PATH}`);
expect(bundle.client_id).toBe('bulwark-webmail');
expect(bundle.access_token).toBe('access-1');
expect(bundle.refresh_token).toBeDefined();
expect(bundle.refresh_token).not.toContain('refresh-1');
expect(openPhoneRefreshToken(bundle.refresh_token!)).toEqual({
refreshToken: 'refresh-1',
serverId: 'srv-1',
clientId: 'bulwark-webmail',
tokenEndpoint: `${SERVER}/auth/token`,
trusted: true,
});
});
it('proxies a public client whose token endpoint the app would refuse', () => {
const grant = oauthGrant({ tokenEndpoint: 'https://idp.example.com/realms/x/token', clientId: 'webmail' });
const bundle = buildRedeemBundle(grant, WEBMAIL, NOW);
if (bundle.flow !== 'oauth') throw new Error('expected oauth');
expect(bundle.token_endpoint).toBe(`${WEBMAIL}${PAIR_TOKEN_PROXY_PATH}`);
expect(openPhoneRefreshToken(bundle.refresh_token!)).toMatchObject({
refreshToken: 'refresh-1',
tokenEndpoint: 'https://idp.example.com/realms/x/token',
clientId: 'webmail',
});
});
it('goes direct to an IdP on the webmail host family', () => {
const grant = oauthGrant({ tokenEndpoint: 'https://sso.webmail.example.net/token' });
const bundle = buildRedeemBundle(grant, WEBMAIL, NOW);
expect(bundle).toMatchObject({ token_endpoint: 'https://sso.webmail.example.net/token', refresh_token: 'refresh-1' });
});
it('keeps the trust flag of a user-chosen server in the sealed token', () => {
const bundle = buildRedeemBundle(oauthGrant({ confidential: true, trusted: false }), WEBMAIL, NOW);
if (bundle.flow !== 'oauth') throw new Error('expected oauth');
expect(openPhoneRefreshToken(bundle.refresh_token!)?.trusted).toBe(false);
});
});
describe('sealed phone refresh tokens', () => {
const VALUE: SealedRefresh = {
refreshToken: 'refresh-1',
serverId: null,
clientId: 'bulwark-webmail',
tokenEndpoint: `${SERVER}/auth/token`,
trusted: true,
};
it('round-trip', () => {
expect(openPhoneRefreshToken(sealPhoneRefreshToken(VALUE))).toEqual(VALUE);
const other = { ...VALUE, serverId: 'srv-2', trusted: false };
expect(openPhoneRefreshToken(sealPhoneRefreshToken(other))).toEqual(other);
});
it('are opaque', () => {
const sealed = sealPhoneRefreshToken(VALUE);
expect(sealed).not.toContain('refresh-1');
expect(Buffer.from(sealed, 'base64').toString('latin1')).not.toContain('refresh-1');
expect(sealPhoneRefreshToken(VALUE)).not.toBe(sealed);
});
it('do not open from garbage, a tampered blob or an empty string', () => {
expect(openPhoneRefreshToken('')).toBeNull();
expect(openPhoneRefreshToken('garbage')).toBeNull();
expect(openPhoneRefreshToken('refresh-1')).toBeNull();
const sealed = Buffer.from(sealPhoneRefreshToken(VALUE), 'base64');
sealed[sealed.length - 1] ^= 0x01;
expect(openPhoneRefreshToken(sealed.toString('base64'))).toBeNull();
});
it('do not open from a blob sealed for another purpose', () => {
const fields = { r: 'refresh-1', s: null, c: 'bulwark-webmail', e: `${SERVER}/auth/token`, t: true };
expect(openPhoneRefreshToken(encryptPayload(fields, 'pair-reauth'))).toBeNull();
expect(openPhoneRefreshToken(encryptPayload(fields, 'session-context'))).toBeNull();
expect(openPhoneRefreshToken(encryptPayload(fields, 'sso-pending'))).toBeNull();
// The same fields under the right purpose do open.
expect(openPhoneRefreshToken(encryptPayload(fields, 'pair-refresh'))).toEqual(VALUE);
});
it('do not open under another SESSION_SECRET', () => {
const sealed = sealPhoneRefreshToken(VALUE);
sessionSecret = 't'.repeat(64);
expect(openPhoneRefreshToken(sealed)).toBeNull();
});
it('reject payloads with missing or malformed fields', () => {
const good = { r: 'refresh-1', s: null, c: 'bulwark-webmail', e: `${SERVER}/auth/token`, t: true };
for (const bad of [
{ ...good, r: '' },
{ ...good, r: 1 },
{ ...good, c: '' },
{ ...good, e: '' },
{ ...good, s: 5 },
]) {
expect(openPhoneRefreshToken(encryptPayload(bad, 'pair-refresh'))).toBeNull();
}
// Only an explicit `true` counts as trusted.
expect(openPhoneRefreshToken(encryptPayload({ ...good, t: 'yes' }, 'pair-refresh'))?.trusted).toBe(false);
});
});
+785
View File
@@ -0,0 +1,785 @@
// @vitest-environment node
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { NextRequest } from 'next/server';
// POST /api/auth/pair/create: the desktop side of "Link mobile app". The
// step-up (current password, optionally TOTP) is checked against the signed-in
// account's own mail server and mints the phone's sign-in; the route then
// hands out a one-time code for the QR. Real crypto and a Map-backed cookie
// jar, so the encrypted cookies round-trip for real; the mail server is a
// fake behind a stubbed fetch.
let sessionSecret = 's'.repeat(64);
vi.mock('@/lib/auth/session-secret', () => ({
getSessionSecret: () => sessionSecret,
hasSessionSecret: () => sessionSecret.length > 0,
}));
const logger = vi.hoisted(() => ({ warn: vi.fn(), error: vi.fn(), info: vi.fn(), debug: vi.fn() }));
vi.mock('@/lib/logger', () => ({ logger }));
const jar = new Map<string, string>();
const cookieWrites: Array<{ name: string; value: string; options?: Record<string, unknown> }> = [];
const cookieStore = {
get: (name: string) => (jar.has(name) ? { name, value: jar.get(name)! } : undefined),
set: (name: string, value: string, options?: Record<string, unknown>) => {
jar.set(name, value);
cookieWrites.push({ name, value, options });
},
delete: (arg: string | { name: string }) => { jar.delete(typeof arg === 'string' ? arg : arg.name); },
getAll: () => [...jar].map(([name, value]) => ({ name, value })),
};
vi.mock('next/headers', () => ({ cookies: async () => cookieStore }));
const config: Record<string, unknown> = {};
vi.mock('@/lib/admin/config-manager', () => ({
configManager: {
ensureLoaded: async () => {},
get: (key: string, fallback: unknown) => (key in config ? config[key] : fallback),
},
}));
const publicFetch = vi.fn();
vi.mock('@/lib/security/url-guard', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/security/url-guard')>();
return {
...actual,
isPublicHttpUrl: async () => true,
fetchPublicUrl: (...args: unknown[]) => publicFetch(...args),
};
});
import { decryptPayload } from '@/lib/auth/crypto';
import { setStalwartAuthContextInStore, type StalwartAuthContext } from '@/lib/stalwart/auth-context';
import { IMPERSONATION_GRANT_COOKIE, sealImpersonationGrant } from '@/lib/impersonation/grant-cookie';
import { resetPairingStoreForTests, stashGrant, type PairingGrant } from '@/lib/auth/pairing-store';
import { readPairReauthFromStore, setPairReauthInStore } from '@/lib/auth/pair-reauth';
import { openPhoneRefreshToken } from '@/lib/auth/pair-bundle';
import { POST as createPOST } from '@/app/api/auth/pair/create/route';
import { POST as redeemPOST } from '@/app/api/auth/pair/redeem/route';
import { GET as statusGET } from '@/app/api/auth/pair/status/route';
const SERVER = 'https://mail.example.org';
const CUSTOM_SERVER = 'https://custom.example';
const WEBMAIL = 'https://webmail.example';
const USER = 'alice@example.org';
const PASSWORD = 'Tr0ub4dor&3-correct-horse';
const WRONG_PASSWORD = 'hunter2-wrong-guess';
const APP_SECRET = 'app_7bq9mobile-secret';
const TOKENS = { access_token: 'access-1', refresh_token: 'refresh-1', expires_in: 3600 };
const HOSTILE = 'http://169.254.169.254/latest/meta-data';
// --- fake mail server --------------------------------------------------------
interface UpstreamCall {
url: string;
method: string;
headers: Record<string, string>;
body: string;
redirect?: string;
via: 'fetch' | 'public';
}
const calls: UpstreamCall[] = [];
type Handler = (call: UpstreamCall) => Response | Promise<Response>;
let handler: Handler;
function record(via: UpstreamCall['via']) {
return async (input: unknown, init: RequestInit = {}) => {
const headers: Record<string, string> = {};
new Headers(init.headers as HeadersInit | undefined).forEach((value, key) => { headers[key] = value; });
const call: UpstreamCall = {
url: String(input),
method: (init.method ?? 'GET').toUpperCase(),
headers,
body: typeof init.body === 'string' ? init.body : '',
redirect: init.redirect,
via,
};
calls.push(call);
return handler(call);
};
}
function json(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });
}
const authenticated = () => json({ type: 'authenticated', client_code: 'client-code-1' });
function basicFor(password: string): string {
return `Basic ${Buffer.from(`${USER}:${password}`).toString('base64')}`;
}
interface ServerOptions {
base?: string;
/** Answer of /api/auth; defaults to a Stalwart that checks the password. */
apiAuth?: (body: Record<string, unknown>) => Response | Promise<Response>;
tokens?: () => Response;
/** Whether the JMAP session advertises urn:stalwart:jmap. */
stalwart?: boolean;
appPassword?: () => Response;
}
function mailServer(opts: ServerOptions = {}): Handler {
const base = opts.base ?? SERVER;
return async (call) => {
if (!call.url.startsWith(`${base}/`)) return new Response('no such host', { status: 502 });
const path = call.url.slice(base.length);
const basicOk = call.headers.authorization === basicFor(PASSWORD);
if (path === '/api/auth' && call.method === 'POST') {
const body = JSON.parse(call.body) as Record<string, unknown>;
if (opts.apiAuth) return opts.apiAuth(body);
return body.accountSecret === PASSWORD ? authenticated() : json({ type: 'failure' });
}
if (path === '/auth/token' && call.method === 'POST') return opts.tokens ? opts.tokens() : json(TOKENS);
if (path === '/.well-known/jmap' || path === '/jmap/session') {
if (!basicOk) return new Response('unauthorized', { status: 401 });
if (path === '/jmap/session' && !opts.stalwart) return new Response('not found', { status: 404 });
const caps: Record<string, unknown> = { 'urn:ietf:params:jmap:mail': {} };
if (opts.stalwart) caps['urn:stalwart:jmap'] = {};
return json({
apiUrl: `${base}/jmap/`,
username: USER,
primaryAccounts: { 'urn:ietf:params:jmap:mail': 'a1' },
accounts: { a1: { name: USER, accountCapabilities: caps } },
capabilities: {},
});
}
if (path === '/jmap/' && call.method === 'POST') {
if (!basicOk) return new Response('unauthorized', { status: 401 });
if (opts.appPassword) return opts.appPassword();
return json({ methodResponses: [['x:AppPassword/set', { created: { imp: { id: 'ap-1', secret: APP_SECRET } } }, '0']] });
}
return new Response('not found', { status: 404 });
};
}
const noStructuredLogin = () => new Response('not found', { status: 404 });
function apiAuthCalls() {
return calls.filter((c) => c.url.endsWith('/api/auth')).map((c) => JSON.parse(c.body) as Record<string, unknown>);
}
function tokenCalls() {
return calls.filter((c) => c.url.endsWith('/auth/token')).map((c) => Object.fromEntries(new URLSearchParams(c.body)));
}
// --- requests ------------------------------------------------------------------
const createResponses: string[] = [];
async function create(body: Record<string, unknown> = {}, headers: Record<string, string | undefined> = {}) {
const merged: Record<string, string | undefined> = {
'content-type': 'application/json',
'sec-fetch-site': 'same-origin',
origin: WEBMAIL,
...headers,
};
const h: Record<string, string> = {};
for (const [k, v] of Object.entries(merged)) if (v !== undefined) h[k] = v;
const res = await createPOST(new NextRequest(`${WEBMAIL}/api/auth/pair/create`, {
method: 'POST',
headers: h,
body: JSON.stringify({ webmail_base: `${WEBMAIL}/`, ...body }),
}));
const data = await res.json();
createResponses.push(JSON.stringify(data));
return { status: res.status, body: data, cacheControl: res.headers.get('cache-control') };
}
async function redeem(code: string) {
const res = await redeemPOST(new NextRequest(`${WEBMAIL}/api/auth/pair/redeem`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ pairing_code: code }),
}));
return { status: res.status, body: await res.json() };
}
async function status(id: string) {
const res = await statusGET(new NextRequest(`${WEBMAIL}/api/auth/pair/status?id=${encodeURIComponent(id)}`));
return (await res.json()).status as string;
}
function signIn(slot: number, context: Partial<StalwartAuthContext> = {}) {
setStalwartAuthContextInStore(cookieStore as never, slot, {
serverUrl: SERVER,
username: USER,
authHeader: 'Bearer desktop-session-token',
...context,
});
}
function proofCookie(): Record<string, unknown> | null {
const raw = jar.get('pair_reauth');
return raw ? decryptPayload(raw, 'pair-reauth') : null;
}
function allLoggerCalls(): string {
return JSON.stringify([logger.warn, logger.error, logger.info, logger.debug].map((fn) => fn.mock.calls));
}
beforeEach(() => {
sessionSecret = 's'.repeat(64);
jar.clear();
for (const key of Object.keys(config)) delete config[key];
config.jmapServerUrl = SERVER;
vi.stubEnv('OAUTH_CLIENT_ID', '');
vi.stubEnv('OAUTH_CLIENT_SECRET', '');
vi.stubEnv('OAUTH_CLIENT_SECRET_FILE', '');
resetPairingStoreForTests();
delete (globalThis as Record<string, unknown>).__bulwarkPairAttempts;
calls.length = 0;
handler = mailServer();
vi.stubGlobal('fetch', vi.fn(record('fetch')));
publicFetch.mockReset().mockImplementation(record('public'));
for (const fn of Object.values(logger)) fn.mockClear();
signIn(0);
cookieWrites.length = 0;
createResponses.length = 0;
});
afterEach(() => {
vi.unstubAllGlobals();
vi.unstubAllEnvs();
});
// --- tests -----------------------------------------------------------------------
describe('pair/create gatekeeping', () => {
it('refuses a cross-site request before doing anything', async () => {
const res = await create({ password: PASSWORD }, { 'sec-fetch-site': 'cross-site', origin: 'https://evil.example' });
expect(res.status).toBe(403);
expect(calls).toHaveLength(0);
expect(cookieWrites).toHaveLength(0);
});
it('needs SESSION_SECRET', async () => {
sessionSecret = '';
const res = await create({ password: PASSWORD });
expect(res.status).toBe(500);
expect(res.body).toEqual({ error: 'session_secret_required' });
expect(calls).toHaveLength(0);
});
it.each([
['missing', undefined],
['not a string', 42],
['another origin than the request', 'https://evil.example/'],
['another port', 'https://webmail.example:8443/'],
['plain http for an https origin', 'http://webmail.example/'],
['a query string', `${WEBMAIL}/?next=/x`],
['a fragment', `${WEBMAIL}/#x`],
['credentials', 'https://user:pw@webmail.example/'],
['another scheme', 'javascript:alert(1)'],
['not a URL', 'webmail.example'],
])('rejects a webmail_base that is %s', async (_label, base) => {
const res = await create({ webmail_base: base, password: PASSWORD });
expect(res.status).toBe(400);
expect(res.body).toEqual({ error: 'invalid_request' });
expect(calls).toHaveLength(0);
});
it('checks webmail_base against Host when the browser sent no Origin', async () => {
const noOrigin = { origin: undefined, host: 'webmail.example' };
expect((await create({}, noOrigin)).body).toEqual({ error: 'reauth_required' });
expect((await create({}, { origin: undefined, host: 'other.example' })).status).toBe(400);
expect(
(await create({}, { origin: undefined, host: 'internal:3000', 'x-forwarded-host': 'webmail.example' })).body,
).toEqual({ error: 'reauth_required' });
});
it('needs a signed-in session context for the slot', async () => {
const res = await create({ slot: 2, password: PASSWORD });
expect(res.status).toBe(401);
expect(res.body).toEqual({ error: 'not_signed_in' });
expect(calls).toHaveLength(0);
});
it('falls back to slot 0 for a malformed slot', async () => {
signIn(1, { username: 'bob@example.org' });
for (const slot of ['1', 1.5, -1, 99]) {
const res = await create({ slot, password: PASSWORD });
expect(res.status).toBe(200);
expect(proofCookie()?.owner).toBe(`0:${USER}`);
}
});
it('refuses an impersonation (support) session on slot 0', async () => {
jar.set(IMPERSONATION_GRANT_COOKIE, sealImpersonationGrant({ serverUrl: SERVER, mailbox: USER, credentialId: 'c1' }));
const res = await create({ password: PASSWORD });
expect(res.status).toBe(403);
expect(res.body).toEqual({ error: 'impersonation' });
expect(calls).toHaveLength(0);
expect(jar.has('pair_reauth')).toBe(false);
// Other slots are real sign-ins.
signIn(1);
expect((await create({ slot: 1 })).body).toEqual({ error: 'reauth_required' });
});
it('asks for a step-up when there is neither a password nor a proof', async () => {
const res = await create({});
expect(res.status).toBe(401);
expect(res.body).toEqual({ error: 'reauth_required' });
expect(calls).toHaveLength(0);
});
it('does not accept a proof for another slot or another user', async () => {
signIn(1, { username: 'bob@example.org' });
setPairReauthInStore(cookieStore as never, { grantId: stashGrant(grant(), '1:bob@example.org'), owner: '1:bob@example.org' });
expect(await create({ slot: 0 })).toMatchObject({ status: 401, body: { error: 'reauth_required' } });
setPairReauthInStore(cookieStore as never, { grantId: stashGrant(grant(), `1:${USER}`), owner: `1:${USER}` });
expect(await create({ slot: 0 })).toMatchObject({ status: 401, body: { error: 'reauth_required' } });
setPairReauthInStore(cookieStore as never, { grantId: stashGrant(grant(), '0:mallory@example.org'), owner: '0:mallory@example.org' });
expect(await create({ slot: 0 })).toMatchObject({ status: 401, body: { error: 'reauth_required' } });
// The proof's own account gets a code.
setPairReauthInStore(cookieStore as never, { grantId: stashGrant(grant(), `0:${USER}`), owner: `0:${USER}` });
expect((await create({ slot: 0 })).status).toBe(200);
});
it('matches the proof owner case-insensitively', async () => {
signIn(0, { username: 'Alice@Example.ORG' });
setPairReauthInStore(cookieStore as never, { grantId: stashGrant(grant(), `0:${USER}`), owner: `0:${USER}` });
expect((await create({})).status).toBe(200);
});
});
function grant(): PairingGrant {
return {
flow: 'oauth',
serverUrl: SERVER,
serverId: null,
accessToken: 'seeded-access',
refreshToken: 'seeded-refresh',
expiresIn: 3600,
issuedAt: Date.now(),
tokenEndpoint: `${SERVER}/auth/token`,
clientId: 'bulwark-webmail',
confidential: false,
trusted: true,
};
}
describe('pair/create password step-up', () => {
it('mints an OAuth grant for the app client and pairs it', async () => {
const res = await create({ password: PASSWORD, totp: ' 123456 ' });
expect(res.status).toBe(200);
expect(res.cacheControl).toBe('no-store');
expect(res.body.pairing_code).toMatch(/^[0-9a-f]{64}$/);
expect(res.body.status_id).toMatch(/^[0-9a-f]{32}$/);
expect(res.body.expires_in).toBe(120);
// Structured login for the app's own client and redirect, with the TOTP
// as a separate field.
expect(apiAuthCalls()).toEqual([expect.objectContaining({
type: 'authCode',
accountName: USER,
accountSecret: PASSWORD,
mfaToken: '123456',
clientId: 'bulwark-webmail',
redirectUri: 'bulwarkmobile://auth/callback',
codeChallengeMethod: 'S256',
})]);
const [exchange] = tokenCalls();
expect(exchange).toMatchObject({
grant_type: 'authorization_code',
code: 'client-code-1',
client_id: 'bulwark-webmail',
redirect_uri: 'bulwarkmobile://auth/callback',
});
expect(exchange).not.toHaveProperty('client_secret');
// The proof remembers the grant for this account.
expect(proofCookie()).toMatchObject({ purpose: 'pair', owner: `0:${USER}`, grant_id: expect.any(String) });
expect(cookieWrites.find((w) => w.name === 'pair_reauth')?.options).toMatchObject({ httpOnly: true, maxAge: 300 });
expect(await status(res.body.status_id)).toBe('pending');
const redeemed = await redeem(res.body.pairing_code);
expect(redeemed.status).toBe(200);
expect(redeemed.body).toEqual({
flow: 'oauth',
server_url: SERVER,
access_token: 'access-1',
refresh_token: 'refresh-1',
expires_in: 3600,
token_endpoint: `${SERVER}/auth/token`,
client_id: 'bulwark-webmail',
});
expect(await status(res.body.status_id)).toBe('redeemed');
});
it('sends no mfaToken without a TOTP code', async () => {
await create({ password: PASSWORD, totp: ' ' });
expect(apiAuthCalls()[0]).not.toHaveProperty('mfaToken');
});
it('answers a wrong password with 401 invalid_credentials and locks after five', async () => {
for (let i = 0; i < 5; i++) {
const res = await create({ password: WRONG_PASSWORD });
expect(res.status).toBe(401);
expect(res.body).toEqual({ error: 'invalid_credentials' });
}
expect(apiAuthCalls()).toHaveLength(5);
expect(jar.has('pair_reauth')).toBe(false);
const locked = await create({ password: WRONG_PASSWORD });
expect(locked.status).toBe(429);
expect(locked.body).toEqual({ error: 'too_many_attempts' });
// Not even the right password gets through while locked, and the mail
// server is not asked again.
expect((await create({ password: PASSWORD })).status).toBe(429);
expect(apiAuthCalls()).toHaveLength(5);
// The lock is per signed-in account.
signIn(1, { username: 'bob@example.org' });
expect((await create({ slot: 1, password: WRONG_PASSWORD })).status).toBe(401);
});
it('resets the failure count after a successful step-up', async () => {
for (let i = 0; i < 4; i++) await create({ password: WRONG_PASSWORD });
expect((await create({ password: PASSWORD })).status).toBe(200);
for (let i = 0; i < 4; i++) expect((await create({ password: WRONG_PASSWORD })).status).toBe(401);
});
it('asks for the TOTP code, and counts a wrong code but not a missing one', async () => {
handler = mailServer({ apiAuth: () => json({ type: 'mfaRequired' }) });
for (let i = 0; i < 6; i++) {
const res = await create({ password: PASSWORD });
expect(res.status).toBe(401);
expect(res.body).toEqual({ error: 'totp_required' });
}
for (let i = 0; i < 5; i++) expect((await create({ password: PASSWORD, totp: '000000' })).status).toBe(401);
expect((await create({ password: PASSWORD, totp: '000000' })).status).toBe(429);
});
it('reports an unreachable mail server as 502 without counting it', async () => {
handler = () => { throw new TypeError('fetch failed'); };
for (let i = 0; i < 6; i++) {
const res = await create({ password: PASSWORD });
expect(res.status).toBe(502);
expect(res.body).toEqual({ error: 'server_unreachable' });
}
});
it('falls back to an app password on a Stalwart without structured login', async () => {
handler = mailServer({ apiAuth: noStructuredLogin, stalwart: true });
const res = await create({ password: PASSWORD, redirect_uri: `${WEBMAIL}/auth/callback` });
expect(res.status).toBe(200);
// No second structured-login attempt: the endpoint does not exist.
expect(apiAuthCalls()).toHaveLength(1);
const setCall = calls.find((c) => c.url === `${SERVER}/jmap/` && c.method === 'POST');
expect(setCall).toBeDefined();
expect(setCall!.headers.authorization).toBe(basicFor(PASSWORD));
const [method, args] = (JSON.parse(setCall!.body) as { methodCalls: [string, Record<string, unknown>][] }).methodCalls[0];
expect(method).toBe('x:AppPassword/set');
const created = (args.create as Record<string, Record<string, unknown>>).imp;
expect(created.description).toMatch(/^Bulwark mobile \(paired \d{4}-\d{2}-\d{2}\)$/);
expect(created).not.toHaveProperty('expiresAt');
const redeemed = await redeem(res.body.pairing_code);
expect(redeemed.body).toEqual({ flow: 'password', server_url: SERVER, username: USER, password: APP_SECRET });
expect(JSON.stringify(redeemed.body)).not.toContain(PASSWORD);
});
it('gives no credential at all when Stalwart refuses the app password', async () => {
handler = mailServer({
apiAuth: noStructuredLogin,
stalwart: true,
appPassword: () => json({ methodResponses: [['x:AppPassword/set', { notCreated: { imp: { type: 'forbidden' } } }, '0']] }),
});
const res = await create({ password: PASSWORD });
expect(res.status).toBe(502);
expect(res.body).toEqual({ error: 'pairing_unavailable' });
expect(jar.has('pair_reauth')).toBe(false);
});
it('hands out the verified account password on a server without OAuth or app passwords', async () => {
handler = mailServer({ apiAuth: noStructuredLogin, stalwart: false });
const res = await create({ password: PASSWORD });
expect(res.status).toBe(200);
expect(calls.some((c) => c.url === `${SERVER}/jmap/` && c.method === 'POST')).toBe(false);
const redeemed = await redeem(res.body.pairing_code);
expect(redeemed.body).toEqual({ flow: 'password', server_url: SERVER, username: USER, password: PASSWORD });
});
it('checks the password over Basic before falling back, and counts a wrong one', async () => {
handler = mailServer({ apiAuth: noStructuredLogin, stalwart: false });
for (let i = 0; i < 5; i++) {
expect(await create({ password: WRONG_PASSWORD })).toMatchObject({ status: 401, body: { error: 'invalid_credentials' } });
}
expect((await create({ password: WRONG_PASSWORD })).status).toBe(429);
});
// A TOTP code must not turn a wrong password into an uncounted failure, or
// the lockout could be sidestepped against a server without structured login.
it('counts a wrong password toward the lockout even when a TOTP code is sent', async () => {
handler = mailServer({ apiAuth: noStructuredLogin, stalwart: false });
for (let i = 0; i < 5; i++) await create({ password: WRONG_PASSWORD, totp: '000000' });
expect((await create({ password: WRONG_PASSWORD, totp: '000000' })).status).toBe(429);
});
// The attempt is counted before the upstream round trip, so parallel
// requests cannot all pass the check.
it('does not let parallel step-ups exceed the failure budget', async () => {
handler = mailServer({
apiAuth: async () => {
await new Promise((resolve) => setTimeout(resolve, 20));
return json({ type: 'failure' });
},
});
await Promise.all(Array.from({ length: 8 }, () => create({ password: WRONG_PASSWORD })));
expect(apiAuthCalls().length).toBeLessThanOrEqual(5);
});
it('retries with the webmail client and redirect when the server refuses the app client', async () => {
config.oauthClientId = 'webmail-client';
handler = mailServer({
apiAuth: (body) => (body.clientId === 'bulwark-webmail'
? new Response('client not registered', { status: 401 })
: body.accountSecret === PASSWORD ? authenticated() : json({ type: 'failure' })),
});
const res = await create({ password: PASSWORD, redirect_uri: `${WEBMAIL}/auth/callback` });
expect(res.status).toBe(200);
expect(apiAuthCalls()).toEqual([
expect.objectContaining({ clientId: 'bulwark-webmail', redirectUri: 'bulwarkmobile://auth/callback' }),
expect.objectContaining({ clientId: 'webmail-client', redirectUri: `${WEBMAIL}/auth/callback` }),
]);
expect(tokenCalls()).toEqual([
expect.objectContaining({ client_id: 'webmail-client', redirect_uri: `${WEBMAIL}/auth/callback` }),
]);
const redeemed = await redeem(res.body.pairing_code);
expect(redeemed.body).toMatchObject({
flow: 'oauth',
client_id: 'webmail-client',
token_endpoint: `${SERVER}/auth/token`,
refresh_token: 'refresh-1',
});
});
it('retries with the desktop redirect even when the client id is the same', async () => {
handler = mailServer({
apiAuth: (body) => (body.redirectUri === 'bulwarkmobile://auth/callback'
? new Response('redirect not registered', { status: 400 })
: authenticated()),
});
const res = await create({ password: PASSWORD, redirect_uri: `${WEBMAIL}/auth/callback` });
expect(res.status).toBe(200);
expect(apiAuthCalls().map((b) => [b.clientId, b.redirectUri])).toEqual([
['bulwark-webmail', 'bulwarkmobile://auth/callback'],
['bulwark-webmail', `${WEBMAIL}/auth/callback`],
]);
});
it('does not retry after a credential verdict', async () => {
config.oauthClientId = 'webmail-client';
const res = await create({ password: WRONG_PASSWORD, redirect_uri: `${WEBMAIL}/auth/callback` });
expect(res.body).toEqual({ error: 'invalid_credentials' });
expect(apiAuthCalls()).toHaveLength(1);
});
it('ignores a desktop redirect on another origin', async () => {
config.oauthClientId = 'webmail-client';
handler = mailServer({ apiAuth: () => new Response('nope', { status: 401 }), stalwart: true });
const res = await create({ password: PASSWORD, redirect_uri: 'https://evil.example/auth/callback' });
// No second structured login; straight to the Basic fallbacks.
expect(apiAuthCalls()).toHaveLength(1);
expect(res.status).toBe(200);
expect((await redeem(res.body.pairing_code)).body).toMatchObject({ flow: 'password', password: APP_SECRET });
});
it('uses the account the credential belongs to, not the claimed login', async () => {
signIn(0, { username: 'alice', accountName: USER });
const res = await create({ password: PASSWORD });
expect(res.status).toBe(200);
expect(apiAuthCalls()[0].accountName).toBe(USER);
// The proof is still keyed on the slot's login.
expect(proofCookie()?.owner).toBe('0:alice');
});
it('only ever talks to the session context server, whatever the body says', async () => {
const hostile = { password: PASSWORD, serverUrl: HOSTILE, server_url: HOSTILE, server: HOSTILE, jmapServerUrl: HOSTILE };
expect((await create(hostile)).status).toBe(200);
handler = mailServer({ apiAuth: noStructuredLogin, stalwart: true });
expect((await create(hostile)).status).toBe(200);
expect(calls.length).toBeGreaterThan(3);
for (const call of calls) expect(call.url.startsWith(`${SERVER}/`)).toBe(true);
expect(JSON.stringify(calls)).not.toContain('169.254');
});
it('reaches a user-chosen server only through the rebinding-safe fetch', async () => {
signIn(0, { serverUrl: CUSTOM_SERVER });
handler = mailServer({ base: CUSTOM_SERVER });
const res = await create({ password: PASSWORD });
expect(res.status).toBe(200);
expect(calls.length).toBeGreaterThan(0);
expect(calls.every((c) => c.via === 'public')).toBe(true);
expect(calls.every((c) => c.url.startsWith(`${CUSTOM_SERVER}/`))).toBe(true);
expect((await redeem(res.body.pairing_code)).body).toMatchObject({
server_url: CUSTOM_SERVER,
token_endpoint: `${CUSTOM_SERVER}/auth/token`,
});
});
// The webmail's own OAuth client secret is the admin's, for the admin's
// server: a user-chosen server (custom JMAP endpoints) never receives it.
it('does not send the webmail client secret to a user-chosen server', async () => {
config.oauthClientSecret = 'webmail-client-secret';
signIn(0, { serverUrl: CUSTOM_SERVER });
handler = mailServer({ base: CUSTOM_SERVER });
expect((await create({ password: PASSWORD })).status).toBe(200);
expect(JSON.stringify(calls)).not.toContain('webmail-client-secret');
});
});
describe('pair/create with a confidential client', () => {
it('sends the secret for the app client when it is the configured one, and proxies the phone', async () => {
config.oauthClientSecret = 'webmail-client-secret';
const res = await create({ password: PASSWORD, webmail_base: `${WEBMAIL}/mail/` });
expect(res.status).toBe(200);
expect(tokenCalls()[0]).toMatchObject({ client_id: 'bulwark-webmail', client_secret: 'webmail-client-secret' });
const redeemed = await redeem(res.body.pairing_code);
expect(redeemed.body).toMatchObject({
flow: 'oauth',
access_token: 'access-1',
token_endpoint: `${WEBMAIL}/mail/api/auth/pair/token`,
client_id: 'bulwark-webmail',
});
expect(redeemed.body.refresh_token).not.toBe('refresh-1');
expect(openPhoneRefreshToken(redeemed.body.refresh_token)).toEqual({
refreshToken: 'refresh-1',
serverId: null,
clientId: 'bulwark-webmail',
tokenEndpoint: `${SERVER}/auth/token`,
trusted: true,
});
expect(JSON.stringify(redeemed.body)).not.toContain('webmail-client-secret');
});
it('takes the secret from OAUTH_CLIENT_SECRET too', async () => {
vi.stubEnv('OAUTH_CLIENT_SECRET', 'env-secret');
const res = await create({ password: PASSWORD });
expect(tokenCalls()[0].client_secret).toBe('env-secret');
expect((await redeem(res.body.pairing_code)).body.token_endpoint).toBe(`${WEBMAIL}/api/auth/pair/token`);
});
it('keeps the server entry in the sealed token for per-server secrets', async () => {
config.jmapServers = [{ id: 'main', label: 'Main', url: SERVER, oauth: { clientSecret: 'per-server-secret' } }];
const res = await create({ password: PASSWORD });
expect(tokenCalls()[0].client_secret).toBe('per-server-secret');
const redeemed = await redeem(res.body.pairing_code);
expect(openPhoneRefreshToken(redeemed.body.refresh_token)?.serverId).toBe('main');
});
it('keeps the app client public when the secret belongs to another configured client', async () => {
config.oauthClientId = 'webmail-client';
config.oauthClientSecret = 'webmail-client-secret';
const res = await create({ password: PASSWORD });
expect(tokenCalls()[0]).not.toHaveProperty('client_secret');
expect((await redeem(res.body.pairing_code)).body).toMatchObject({
token_endpoint: `${SERVER}/auth/token`,
refresh_token: 'refresh-1',
});
});
});
describe('pair/create without a password', () => {
it('shows a new code for the same grant and supersedes the previous one', async () => {
const first = await create({ password: PASSWORD });
const grantId = readPairReauthFromStore(cookieStore as never)?.grantId;
const upstreamCalls = calls.length;
const second = await create({});
expect(second.status).toBe(200);
expect(second.cacheControl).toBe('no-store');
expect(second.body.pairing_code).not.toBe(first.body.pairing_code);
expect(second.body.status_id).not.toBe(first.body.status_id);
expect(readPairReauthFromStore(cookieStore as never)?.grantId).toBe(grantId);
// No new step-up, so the mail server is not involved.
expect(calls.length).toBe(upstreamCalls);
expect(await status(first.body.status_id)).toBe('expired');
expect(await status(second.body.status_id)).toBe('pending');
expect(await redeem(first.body.pairing_code)).toEqual({ status: 410, body: { error: 'expired_code' } });
expect((await redeem(second.body.pairing_code)).status).toBe(200);
expect(await redeem(second.body.pairing_code)).toEqual({ status: 410, body: { error: 'used_code' } });
expect(await redeem(first.body.pairing_code)).toEqual({ status: 410, body: { error: 'used_code' } });
// The grant was handed out: the next QR needs a new step-up.
expect(await create({})).toMatchObject({ status: 401, body: { error: 'reauth_required' } });
});
it('kills the previous grant when the user steps up again', async () => {
const first = await create({ password: PASSWORD });
const second = await create({ password: PASSWORD });
expect(await redeem(first.body.pairing_code)).toEqual({ status: 410, body: { error: 'expired_code' } });
expect((await redeem(second.body.pairing_code)).status).toBe(200);
});
});
describe('pair/create never exposes the typed password', () => {
it('keeps it out of logs, cookies and responses', async () => {
await create({ password: PASSWORD, totp: '123456' });
await create({ password: WRONG_PASSWORD });
handler = mailServer({ apiAuth: () => json({ type: 'mfaRequired' }) });
await create({ password: PASSWORD });
handler = mailServer({ tokens: () => new Response('token endpoint exploded', { status: 500 }) });
await create({ password: PASSWORD });
handler = mailServer({ apiAuth: () => new Response('bad client', { status: 403 }) });
await create({ password: PASSWORD, redirect_uri: `${WEBMAIL}/auth/callback` });
handler = mailServer({ apiAuth: noStructuredLogin, stalwart: true });
await create({ password: PASSWORD });
await create({ password: WRONG_PASSWORD });
handler = mailServer({
apiAuth: noStructuredLogin,
stalwart: true,
appPassword: () => json({ methodResponses: [['error', { type: 'forbidden', description: 'nope' }, '0']] }),
});
await create({ password: PASSWORD });
handler = mailServer({ apiAuth: noStructuredLogin, stalwart: false });
await create({ password: PASSWORD });
handler = () => { throw new TypeError('fetch failed'); };
await create({ password: PASSWORD });
// Every flow ran (oauth, verdicts, errors, app password, account password).
expect(logger.info).toHaveBeenCalled();
expect(logger.warn).toHaveBeenCalled();
const logs = allLoggerCalls();
for (const secret of [PASSWORD, WRONG_PASSWORD]) {
expect(logs).not.toContain(secret);
expect(createResponses.join('\n')).not.toContain(secret);
for (const write of cookieWrites) {
expect(write.value).not.toContain(secret);
expect(Buffer.from(write.value, 'base64').toString('latin1')).not.toContain(secret);
}
}
expect(logs).not.toContain(basicFor(PASSWORD).slice(6));
const proofs = cookieWrites.filter((w) => w.name === 'pair_reauth');
expect(proofs.length).toBeGreaterThan(0);
for (const proof of proofs) {
const opened = decryptPayload(proof.value, 'pair-reauth');
expect(opened).not.toBeNull();
expect(Object.keys(opened!).sort()).toEqual(['created_at', 'grant_id', 'owner', 'p', 'purpose']);
expect(JSON.stringify(opened)).not.toContain(PASSWORD);
}
});
});
@@ -0,0 +1,39 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
// OAuth discovery now keeps `scopes_supported`, which the pairing re-auth
// reads to decide whether to ask for offline_access.
const METADATA = {
issuer: 'https://idp.example.net',
authorization_endpoint: 'https://idp.example.net/authorize',
token_endpoint: 'https://idp.example.net/token',
};
async function discover(document: Record<string, unknown>) {
// A fresh module each time: discovery caches per URL.
vi.resetModules();
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: () => Promise.resolve(document) }));
const { discoverOAuth } = await import('@/lib/oauth/discovery');
return discoverOAuth('https://idp.example.net', { validateEndpoint: async () => true });
}
afterEach(() => {
vi.unstubAllGlobals();
});
describe('discoverOAuth scopes_supported', () => {
it('keeps the advertised scopes', async () => {
const metadata = await discover({ ...METADATA, scopes_supported: ['openid', 'email', 'offline_access'] });
expect(metadata?.scopes_supported).toEqual(['openid', 'email', 'offline_access']);
});
it('drops entries that are not strings', async () => {
const metadata = await discover({ ...METADATA, scopes_supported: ['openid', 42, null, { x: 1 }, 'offline_access'] });
expect(metadata?.scopes_supported).toEqual(['openid', 'offline_access']);
});
it('leaves the field out when the document has none or a malformed one', async () => {
expect(await discover(METADATA)).not.toHaveProperty('scopes_supported');
expect(await discover({ ...METADATA, scopes_supported: 'openid offline_access' })).not.toHaveProperty('scopes_supported');
});
});
+223
View File
@@ -0,0 +1,223 @@
// @vitest-environment node
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { NextRequest } from 'next/server';
// The pair_reauth cookie (lib/auth/pair-reauth.ts) remembers which pairing
// grant a step-up minted, for which signed-in account. Signing out
// (DELETE /api/auth/session) clears it and kills its grant, so a QR left on
// screen stops working - but only when it belongs to the account signing out.
vi.mock('@/lib/auth/session-secret', () => ({
getSessionSecret: () => 's'.repeat(64),
hasSessionSecret: () => true,
}));
vi.mock('@/lib/logger', () => ({
logger: { warn: () => {}, error: () => {}, info: () => {}, debug: () => {} },
}));
const jar = new Map<string, string>();
const cookieStore = {
get: (name: string) => (jar.has(name) ? { name, value: jar.get(name)! } : undefined),
set: (name: string, value: string, _options?: Record<string, unknown>) => { jar.set(name, value); },
delete: (arg: string | { name: string }) => { jar.delete(typeof arg === 'string' ? arg : arg.name); },
getAll: () => [...jar].map(([name, value]) => ({ name, value })),
};
vi.mock('next/headers', () => ({ cookies: async () => cookieStore }));
vi.mock('@/lib/admin/config-manager', () => ({
configManager: { ensureLoaded: async () => {}, get: (_key: string, fallback: unknown) => fallback },
}));
vi.mock('@/lib/wopi/revocation', () => ({ revokeWopiTokens: async () => {} }));
vi.mock('@/lib/telemetry/login-tracker', () => ({ recordLogin: () => {} }));
import { decryptPayload, encryptPayload } from '@/lib/auth/crypto';
import {
clearPairReauthInStore,
pairingOwner,
readPairReauthFromStore,
setPairReauthInStore,
} from '@/lib/auth/pair-reauth';
import {
createPairingCode,
isGrantAvailable,
redeemPairingCode,
resetPairingStoreForTests,
stashGrant,
type PairingGrant,
} from '@/lib/auth/pairing-store';
import { setStalwartAuthContextInStore } from '@/lib/stalwart/auth-context';
import { DELETE } from '@/app/api/auth/session/route';
type Store = Parameters<typeof setPairReauthInStore>[0];
const store = cookieStore as unknown as Store;
const WEBMAIL = 'https://webmail.example';
const GRANT: PairingGrant = {
flow: 'password',
serverUrl: 'https://mail.example.org',
username: 'bob@example.org',
password: 'app_secret',
credential: 'app-password',
};
function stepUp(owner: string): string {
const grantId = stashGrant(GRANT, owner);
setPairReauthInStore(store, { grantId, owner });
return grantId;
}
async function signOut(query: string) {
const res = await DELETE(new NextRequest(`${WEBMAIL}/api/auth/session${query}`, {
method: 'DELETE',
headers: { 'sec-fetch-site': 'same-origin' },
}));
return res.status;
}
beforeEach(() => {
jar.clear();
resetPairingStoreForTests();
});
afterEach(() => {
vi.useRealTimers();
});
describe('pair_reauth cookie', () => {
it('names the owner by slot and normalized login', () => {
expect(pairingOwner(3, ' Alice@Example.ORG ')).toBe('3:alice@example.org');
});
it('round-trips the grant and owner, and nothing else', () => {
const grantId = stepUp('1:bob@example.org');
expect(readPairReauthFromStore(store)).toEqual({ grantId, owner: '1:bob@example.org' });
const opened = decryptPayload(jar.get('pair_reauth')!, 'pair-reauth');
expect(Object.keys(opened!).sort()).toEqual(['created_at', 'grant_id', 'owner', 'p', 'purpose']);
expect(JSON.stringify(opened)).not.toContain('app_secret');
});
it('expires after five minutes even if the cookie survives', () => {
vi.useFakeTimers({ toFake: ['Date'] });
vi.setSystemTime(new Date('2026-09-27T10:00:00Z'));
stepUp('0:bob@example.org');
vi.setSystemTime(new Date('2026-09-27T10:04:59Z'));
expect(readPairReauthFromStore(store)).not.toBeNull();
vi.setSystemTime(new Date('2026-09-27T10:05:01Z'));
expect(readPairReauthFromStore(store)).toBeNull();
});
it('fails closed for a proof from before grants existed', () => {
jar.set('pair_reauth', encryptPayload({ purpose: 'pair', created_at: Date.now() }, 'pair-reauth'));
expect(readPairReauthFromStore(store)).toBeNull();
});
it('does not accept a blob minted for another purpose', () => {
const fields = { purpose: 'pair', created_at: Date.now(), grant_id: 'g', owner: '0:bob@example.org' };
jar.set('pair_reauth', encryptPayload(fields, 'sso-pending'));
expect(readPairReauthFromStore(store)).toBeNull();
jar.set('pair_reauth', encryptPayload(fields, 'pair-refresh'));
expect(readPairReauthFromStore(store)).toBeNull();
jar.set('pair_reauth', 'garbage');
expect(readPairReauthFromStore(store)).toBeNull();
});
it('kills the previous grant when a new step-up replaces it', () => {
const first = stepUp('0:bob@example.org');
const code = createPairingCode(first, '0:bob@example.org', WEBMAIL)!;
const second = stepUp('0:bob@example.org');
expect(isGrantAvailable(first, '0:bob@example.org')).toBe(false);
expect(isGrantAvailable(second, '0:bob@example.org')).toBe(true);
expect(redeemPairingCode(code.code)).toEqual({ ok: false, reason: 'expired' });
});
it('keeps the grant when the same one is written again', () => {
const grantId = stepUp('0:bob@example.org');
setPairReauthInStore(store, { grantId, owner: '0:bob@example.org' });
expect(isGrantAvailable(grantId, '0:bob@example.org')).toBe(true);
});
it('clears only a proof of the given slot', () => {
const grantId = stepUp('1:bob@example.org');
clearPairReauthInStore(store, 0);
clearPairReauthInStore(store, 10);
expect(jar.has('pair_reauth')).toBe(true);
expect(isGrantAvailable(grantId, '1:bob@example.org')).toBe(true);
clearPairReauthInStore(store, 1);
expect(jar.has('pair_reauth')).toBe(false);
expect(isGrantAvailable(grantId, '1:bob@example.org')).toBe(false);
});
it('clears an unreadable proof whatever the slot', () => {
jar.set('pair_reauth', 'garbage');
clearPairReauthInStore(store, 4);
expect(jar.has('pair_reauth')).toBe(false);
});
});
describe('DELETE /api/auth/session and the pairing proof', () => {
beforeEach(() => {
for (const slot of [0, 1]) {
setStalwartAuthContextInStore(store as never, slot, {
serverUrl: 'https://mail.example.org',
username: slot === 0 ? 'alice@example.org' : 'bob@example.org',
authHeader: 'Bearer t',
});
}
});
it("signing out of the proof's account clears it and kills its QR", async () => {
const grantId = stepUp('1:bob@example.org');
const code = createPairingCode(grantId, '1:bob@example.org', WEBMAIL)!;
expect(await signOut('?slot=1')).toBe(200);
expect(jar.has('pair_reauth')).toBe(false);
expect(isGrantAvailable(grantId, '1:bob@example.org')).toBe(false);
expect(createPairingCode(grantId, '1:bob@example.org', WEBMAIL)).toBeNull();
expect(redeemPairingCode(code.code)).toEqual({ ok: false, reason: 'expired' });
});
it('signing out of another account keeps it', async () => {
const grantId = stepUp('1:bob@example.org');
expect(await signOut('?slot=0')).toBe(200);
expect(jar.has('pair_reauth')).toBe(true);
expect(isGrantAvailable(grantId, '1:bob@example.org')).toBe(true);
// The slot's own session is gone, the other one is not.
expect(jar.has('jmap_stalwart_ctx')).toBe(false);
expect(jar.has('jmap_stalwart_ctx_1')).toBe(true);
});
it('signing out without a slot means slot 0', async () => {
const grantId = stepUp('0:alice@example.org');
expect(await signOut('')).toBe(200);
expect(jar.has('pair_reauth')).toBe(false);
expect(isGrantAvailable(grantId, '0:alice@example.org')).toBe(false);
});
it('signing out of every account clears it', async () => {
const grantId = stepUp('1:bob@example.org');
expect(await signOut('?all=true')).toBe(200);
expect(jar.has('pair_reauth')).toBe(false);
expect(isGrantAvailable(grantId, '1:bob@example.org')).toBe(false);
});
it('leaves a phone that already paired alone', async () => {
const grantId = stepUp('1:bob@example.org');
const code = createPairingCode(grantId, '1:bob@example.org', WEBMAIL)!;
expect(redeemPairingCode(code.code)).toMatchObject({ ok: true });
expect(await signOut('?all=true')).toBe(200);
// Nothing to revoke server-side: the phone holds its own credential.
expect(redeemPairingCode(code.code)).toEqual({ ok: false, reason: 'used' });
});
it('refuses a cross-site sign-out and keeps the proof', async () => {
const grantId = stepUp('1:bob@example.org');
const res = await DELETE(new NextRequest(`${WEBMAIL}/api/auth/session?all=true`, {
method: 'DELETE',
headers: { 'sec-fetch-site': 'cross-site', origin: 'https://evil.example' },
}));
expect(res.status).toBe(403);
expect(jar.has('pair_reauth')).toBe(true);
expect(isGrantAvailable(grantId, '1:bob@example.org')).toBe(true);
});
});
@@ -0,0 +1,316 @@
// @vitest-environment node
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { NextRequest } from 'next/server';
// POST /api/auth/reauth/sso/complete: the IdP step-up for "Link mobile app".
// The fresh login becomes the phone's grant, kept server-side, and the
// encrypted pair_reauth cookie remembers which grant for which account. The
// token exchange is mocked; identity verification runs for real against a
// fake /.well-known/jmap.
vi.mock('@/lib/auth/session-secret', () => ({
getSessionSecret: () => 's'.repeat(64),
hasSessionSecret: () => true,
}));
vi.mock('@/lib/logger', () => ({
logger: { warn: () => {}, error: () => {}, info: () => {}, debug: () => {} },
}));
const jar = new Map<string, string>();
const cookieStore = {
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 })),
};
vi.mock('next/headers', () => ({ cookies: async () => cookieStore }));
const config: Record<string, unknown> = {};
vi.mock('@/lib/admin/config-manager', () => ({
configManager: {
ensureLoaded: async () => {},
get: (key: string, fallback: unknown) => (key in config ? config[key] : fallback),
},
}));
const exchangeCodeForTokens = vi.fn();
let confidential = false;
vi.mock('@/lib/oauth/token-exchange', () => ({
exchangeCodeForTokens: (...args: unknown[]) => exchangeCodeForTokens(...args),
getTokenEndpoint: async (serverId?: string | null) =>
(serverId ? `https://idp.example.net/${serverId}/token` : 'https://idp.example.net/token'),
getRequiredConfig: (serverId?: string | null) => ({
clientId: serverId ? `client-${serverId}` : 'webmail-client',
serverUrl: 'https://mail.example.org',
discoveryUrl: 'https://idp.example.net',
}),
hasClientSecret: () => confidential,
getClientSecret: () => (confidential ? 'webmail-client-secret' : ''),
DEFAULT_CLIENT_ID: 'bulwark-webmail',
}));
import { encryptPayload } from '@/lib/auth/crypto';
import { setStalwartAuthContextInStore } from '@/lib/stalwart/auth-context';
import { readPairReauthFromStore } from '@/lib/auth/pair-reauth';
import {
createPairingCode,
isGrantAvailable,
redeemPairingCode,
resetPairingStoreForTests,
} from '@/lib/auth/pairing-store';
import { openPhoneRefreshToken } from '@/lib/auth/pair-bundle';
import { POST as completePOST } from '@/app/api/auth/reauth/sso/complete/route';
import { POST as createPOST } from '@/app/api/auth/pair/create/route';
import { POST as redeemPOST } from '@/app/api/auth/pair/redeem/route';
const SERVER = 'https://mail.example.org';
const WEBMAIL = 'https://webmail.example';
const USER = 'alice@example.org';
const FRESH = { access_token: 'fresh-access', refresh_token: 'fresh-refresh', expires_in: 300 };
/** Username the fake JMAP server reports for the fresh token. */
let sessionUser = USER;
const jmapCalls: Array<{ url: string; authorization: string | null }> = [];
function pending(overrides: Record<string, unknown> = {}) {
jar.set('sso_pending', encryptPayload({
state: 'state-1',
code_verifier: 'verifier-1',
redirect_uri: `${WEBMAIL}/auth/callback`,
created_at: Date.now(),
purpose: 'reauth',
slot: 0,
...overrides,
}, 'sso-pending'));
}
function signIn(slot: number, username = USER) {
setStalwartAuthContextInStore(cookieStore as never, slot, { serverUrl: SERVER, username, authHeader: 'Bearer desktop' });
}
async function complete(body: Record<string, unknown> = { code: 'code-1', state: 'state-1' }) {
const res = await completePOST(new NextRequest(`${WEBMAIL}/api/auth/reauth/sso/complete`, {
method: 'POST',
headers: { 'content-type': 'application/json', 'sec-fetch-site': 'same-origin' },
body: JSON.stringify(body),
}));
return { status: res.status, body: await res.json() };
}
function grantCount(): number {
return (globalThis as unknown as { __bulwarkPairingStore: { grants: Map<string, unknown> } })
.__bulwarkPairingStore.grants.size;
}
beforeEach(() => {
jar.clear();
for (const key of Object.keys(config)) delete config[key];
config.jmapServerUrl = SERVER;
confidential = false;
sessionUser = USER;
jmapCalls.length = 0;
resetPairingStoreForTests();
exchangeCodeForTokens.mockReset().mockResolvedValue({ ...FRESH });
vi.stubGlobal('fetch', vi.fn(async (input: unknown, init: RequestInit = {}) => {
const url = String(input);
jmapCalls.push({ url, authorization: new Headers(init.headers).get('authorization') });
if (url === `${SERVER}/.well-known/jmap`) {
return new Response(JSON.stringify({ apiUrl: `${SERVER}/jmap/`, username: sessionUser, accounts: {} }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
return new Response('not found', { status: 404 });
}));
signIn(0);
});
afterEach(() => {
vi.unstubAllGlobals();
});
describe('reauth/sso/complete', () => {
it('stores the fresh login as a grant and sets the pairing proof for the slot', async () => {
signIn(2);
pending({ slot: 2 });
const res = await complete();
expect(res).toEqual({ status: 200, body: { ok: true } });
expect(jar.has('sso_pending')).toBe(false);
expect(exchangeCodeForTokens).toHaveBeenCalledWith('code-1', 'verifier-1', `${WEBMAIL}/auth/callback`, null);
// The fresh token was checked against the account being paired.
expect(jmapCalls).toEqual([{ url: `${SERVER}/.well-known/jmap`, authorization: 'Bearer fresh-access' }]);
const proof = readPairReauthFromStore(cookieStore as never);
expect(proof?.owner).toBe(`2:${USER}`);
expect(isGrantAvailable(proof!.grantId, `2:${USER}`)).toBe(true);
const pairing = createPairingCode(proof!.grantId, `2:${USER}`, WEBMAIL)!;
const redeemed = redeemPairingCode(pairing.code);
expect(redeemed).toMatchObject({
ok: true,
grant: {
flow: 'oauth',
serverUrl: SERVER,
serverId: null,
accessToken: 'fresh-access',
refreshToken: 'fresh-refresh',
expiresIn: 300,
tokenEndpoint: 'https://idp.example.net/token',
clientId: 'webmail-client',
confidential: false,
trusted: true,
},
});
});
it('uses the server entry the re-auth was started for', async () => {
confidential = true;
pending({ server_id: 'corp' });
expect((await complete()).status).toBe(200);
expect(exchangeCodeForTokens).toHaveBeenCalledWith('code-1', 'verifier-1', `${WEBMAIL}/auth/callback`, 'corp');
const proof = readPairReauthFromStore(cookieStore as never)!;
const pairing = createPairingCode(proof.grantId, proof.owner, WEBMAIL)!;
expect(redeemPairingCode(pairing.code)).toMatchObject({
ok: true,
grant: { serverId: 'corp', tokenEndpoint: 'https://idp.example.net/corp/token', clientId: 'client-corp', confidential: true },
});
});
it('lets pair/create show a QR for the grant, which the phone renews through the proxy', async () => {
pending();
await complete();
const create = await createPOST(new NextRequest(`${WEBMAIL}/api/auth/pair/create`, {
method: 'POST',
headers: { 'content-type': 'application/json', 'sec-fetch-site': 'same-origin', origin: WEBMAIL },
body: JSON.stringify({ slot: 0, webmail_base: `${WEBMAIL}/` }),
}));
expect(create.status).toBe(200);
const { pairing_code: code } = await create.json();
const redeem = await redeemPOST(new NextRequest(`${WEBMAIL}/api/auth/pair/redeem`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ pairing_code: code }),
}));
const bundle = await redeem.json();
// The IdP lives on a host the app refuses: the webmail proxies renewals.
expect(bundle).toMatchObject({
flow: 'oauth',
server_url: SERVER,
access_token: 'fresh-access',
token_endpoint: `${WEBMAIL}/api/auth/pair/token`,
client_id: 'webmail-client',
});
expect(openPhoneRefreshToken(bundle.refresh_token)).toEqual({
refreshToken: 'fresh-refresh',
serverId: null,
clientId: 'webmail-client',
tokenEndpoint: 'https://idp.example.net/token',
trusted: true,
});
});
it('refuses a fresh login as another account', async () => {
sessionUser = 'mallory@example.org';
pending();
const res = await complete();
expect(res).toEqual({ status: 403, body: { error: 'account_mismatch' } });
expect(jar.has('pair_reauth')).toBe(false);
expect(grantCount()).toBe(0);
});
it('refuses a fresh token the mail server rejects', async () => {
vi.stubGlobal('fetch', vi.fn(async () => new Response('unauthorized', { status: 401 })));
pending();
expect(await complete()).toEqual({ status: 403, body: { error: 'account_mismatch' } });
expect(jar.has('pair_reauth')).toBe(false);
});
it('fails without a refresh token instead of pairing a phone that signs out in minutes', async () => {
exchangeCodeForTokens.mockResolvedValue({ access_token: 'fresh-access', expires_in: 300 });
pending();
const res = await complete();
expect(res).toEqual({ status: 502, body: { error: 'no_refresh_token' } });
expect(jar.has('pair_reauth')).toBe(false);
expect(grantCount()).toBe(0);
});
it('treats a pending state from before slots existed as slot 0', async () => {
pending({ slot: undefined });
expect((await complete()).status).toBe(200);
expect(readPairReauthFromStore(cookieStore as never)?.owner).toBe(`0:${USER}`);
});
it('uses slot 0 for a pending state without slot even when another slot is signed in', async () => {
jar.clear();
signIn(1);
pending({ slot: undefined });
expect(await complete()).toEqual({ status: 401, body: { error: 'not_signed_in' } });
expect(exchangeCodeForTokens).not.toHaveBeenCalled();
});
it('falls back to slot 0 for an out-of-range slot', async () => {
pending({ slot: 999 });
expect((await complete()).status).toBe(200);
expect(readPairReauthFromStore(cookieStore as never)?.owner).toBe(`0:${USER}`);
});
it('refuses a pending state from a normal login', async () => {
pending({ purpose: undefined, slot: undefined });
const res = await complete();
expect(res).toEqual({ status: 400, body: { error: 'Not a re-auth session' } });
expect(exchangeCodeForTokens).not.toHaveBeenCalled();
expect(jar.has('sso_pending')).toBe(false);
expect(jar.has('pair_reauth')).toBe(false);
});
it('refuses a state mismatch', async () => {
pending();
expect((await complete({ code: 'code-1', state: 'other' })).status).toBe(400);
expect(exchangeCodeForTokens).not.toHaveBeenCalled();
expect(jar.has('pair_reauth')).toBe(false);
});
it('refuses an expired pending state', async () => {
pending({ created_at: Date.now() - 6 * 60 * 1000 });
expect(await complete()).toEqual({ status: 400, body: { error: 'Re-auth session expired' } });
expect(exchangeCodeForTokens).not.toHaveBeenCalled();
});
it('refuses without a pending cookie, or with one minted for another purpose', async () => {
expect((await complete()).status).toBe(400);
jar.set('sso_pending', encryptPayload({ state: 'state-1', purpose: 'reauth', created_at: Date.now() }, 'pair-reauth'));
expect(await complete()).toEqual({ status: 400, body: { error: 'Invalid re-auth session' } });
expect(exchangeCodeForTokens).not.toHaveBeenCalled();
});
it('needs the slot to be signed in', async () => {
jar.clear();
pending();
expect(await complete()).toEqual({ status: 401, body: { error: 'not_signed_in' } });
expect(exchangeCodeForTokens).not.toHaveBeenCalled();
});
it('reports a failed code exchange without setting a proof', async () => {
exchangeCodeForTokens.mockRejectedValue(new Error('Token exchange failed'));
pending();
expect(await complete()).toEqual({ status: 401, body: { error: 'Re-authentication failed' } });
expect(jar.has('pair_reauth')).toBe(false);
expect(jar.has('sso_pending')).toBe(false);
});
it('replaces the previous grant, so its QR stops working', async () => {
pending();
await complete();
const first = readPairReauthFromStore(cookieStore as never)!;
const firstCode = createPairingCode(first.grantId, first.owner, WEBMAIL)!;
pending();
await complete();
const second = readPairReauthFromStore(cookieStore as never)!;
expect(second.grantId).not.toBe(first.grantId);
expect(redeemPairingCode(firstCode.code)).toEqual({ ok: false, reason: 'expired' });
});
});
@@ -0,0 +1,216 @@
// @vitest-environment node
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { NextRequest } from 'next/server';
// POST /api/auth/pair/redeem (the phone) and GET /api/auth/pair/status (the
// desktop polling its QR), against a seeded pairing store and real crypto.
vi.mock('@/lib/auth/session-secret', () => ({
getSessionSecret: () => 's'.repeat(64),
hasSessionSecret: () => true,
}));
vi.mock('@/lib/logger', () => ({
logger: { warn: () => {}, error: () => {}, info: () => {}, debug: () => {} },
}));
import {
CODE_TTL_MS,
createPairingCode,
resetPairingStoreForTests,
stashGrant,
type PairingGrant,
} from '@/lib/auth/pairing-store';
import { openPhoneRefreshToken } from '@/lib/auth/pair-bundle';
import { POST as redeemPOST } from '@/app/api/auth/pair/redeem/route';
import { GET as statusGET } from '@/app/api/auth/pair/status/route';
const SERVER = 'https://mail.example.org';
const WEBMAIL = 'https://webmail.example/mail';
const OWNER = '0:alice@example.org';
function oauthGrant(overrides: Partial<Extract<PairingGrant, { flow: 'oauth' }>> = {}): PairingGrant {
return {
flow: 'oauth',
serverUrl: SERVER,
serverId: null,
accessToken: 'access-1',
refreshToken: 'refresh-1',
expiresIn: 3600,
issuedAt: Date.now(),
tokenEndpoint: `${SERVER}/auth/token`,
clientId: 'bulwark-webmail',
confidential: false,
trusted: true,
...overrides,
};
}
function seed(grant: PairingGrant = oauthGrant(), now = Date.now(), base: string | null = WEBMAIL) {
const grantId = stashGrant(grant, OWNER, now);
const pairing = createPairingCode(grantId, OWNER, base, now);
if (!pairing) throw new Error('expected a code');
return { grantId, ...pairing };
}
async function redeem(body: unknown, headers: Record<string, string> = {}) {
const res = await redeemPOST(new NextRequest(`${WEBMAIL}/api/auth/pair/redeem`, {
method: 'POST',
headers: { 'content-type': 'application/json', ...headers },
body: typeof body === 'string' ? body : JSON.stringify(body),
}));
return { status: res.status, body: await res.json(), cacheControl: res.headers.get('cache-control') };
}
async function status(query: string) {
const res = await statusGET(new NextRequest(`${WEBMAIL}/api/auth/pair/status${query}`));
return { status: (await res.json()).status as string, cacheControl: res.headers.get('cache-control') };
}
beforeEach(() => {
resetPairingStoreForTests();
});
describe('pair/redeem', () => {
it.each([
['no code', {}],
['a number', { pairing_code: 42 }],
['too short', { pairing_code: 'ab'.repeat(16) }],
['uppercase hex', { pairing_code: 'AB'.repeat(32) }],
['not hex', { pairing_code: 'zz'.repeat(32) }],
['a body that is not JSON', 'pairing_code=abc'],
])('rejects %s as invalid_code', async (_label, body) => {
const res = await redeem(body);
expect(res.status).toBe(400);
expect(res.body).toEqual({ error: 'invalid_code' });
expect(res.cacheControl).toBe('no-store');
});
it('rejects a well-formed code nobody issued', async () => {
const res = await redeem({ pairing_code: '0'.repeat(64) });
expect(res.status).toBe(400);
expect(res.body).toEqual({ error: 'invalid_code' });
expect(res.cacheControl).toBe('no-store');
});
it('rejects an expired code with 410 expired_code', async () => {
const { code } = seed(oauthGrant(), Date.now() - CODE_TTL_MS - 1000);
const res = await redeem({ pairing_code: code });
expect(res.status).toBe(410);
expect(res.body).toEqual({ error: 'expired_code' });
expect(res.cacheControl).toBe('no-store');
});
it('hands the grant out once, then answers 410 used_code', async () => {
const { code } = seed();
const first = await redeem({ pairing_code: code });
expect(first.status).toBe(200);
expect(first.cacheControl).toBe('no-store');
const again = await redeem({ pairing_code: code });
expect(again.status).toBe(410);
expect(again.body).toEqual({ error: 'used_code' });
expect(again.cacheControl).toBe('no-store');
});
it('is open to the phone, which sends no browser headers', async () => {
const { code } = seed();
expect((await redeem({ pairing_code: code })).status).toBe(200);
});
it('refuses a cross-site browser request', async () => {
const { code } = seed();
const res = await redeem({ pairing_code: code }, { 'sec-fetch-site': 'cross-site', origin: 'https://evil.example' });
expect(res.status).toBe(403);
// ...without burning the code.
expect((await redeem({ pairing_code: code })).status).toBe(200);
});
it('points a public client at the provider with the raw refresh token', async () => {
const { code } = seed();
const res = await redeem({ pairing_code: code });
expect(res.body).toMatchObject({
flow: 'oauth',
server_url: SERVER,
access_token: 'access-1',
refresh_token: 'refresh-1',
token_endpoint: `${SERVER}/auth/token`,
client_id: 'bulwark-webmail',
});
});
it('proxies a confidential client through the webmail with a sealed refresh token', async () => {
const { code } = seed(oauthGrant({ confidential: true, serverId: 'main' }));
const res = await redeem({ pairing_code: code });
expect(res.status).toBe(200);
expect(res.body.token_endpoint).toBe(`${WEBMAIL}/api/auth/pair/token`);
expect(res.body.refresh_token).not.toBe('refresh-1');
expect(res.body.refresh_token).not.toContain('refresh-1');
expect(openPhoneRefreshToken(res.body.refresh_token)).toEqual({
refreshToken: 'refresh-1',
serverId: 'main',
clientId: 'bulwark-webmail',
tokenEndpoint: `${SERVER}/auth/token`,
trusted: true,
});
});
it('proxies an external IdP on another host', async () => {
const endpoint = 'https://login.idp-vendor.example/realms/acme/protocol/openid-connect/token';
const { code } = seed(oauthGrant({ tokenEndpoint: endpoint, clientId: 'webmail', confidential: false }));
const res = await redeem({ pairing_code: code });
expect(res.body.token_endpoint).toBe(`${WEBMAIL}/api/auth/pair/token`);
expect(res.body.client_id).toBe('webmail');
expect(openPhoneRefreshToken(res.body.refresh_token)).toMatchObject({ refreshToken: 'refresh-1', tokenEndpoint: endpoint });
});
it('counts the access token lifetime from when it was issued', async () => {
const { code } = seed(oauthGrant({ issuedAt: Date.now() - 90_000, expiresIn: 300 }));
const res = await redeem({ pairing_code: code });
expect(res.body.expires_in).toBeGreaterThanOrEqual(209);
expect(res.body.expires_in).toBeLessThanOrEqual(210);
});
it('hands out a password grant as username and password', async () => {
const { code } = seed({
flow: 'password',
serverUrl: SERVER,
username: 'alice@example.org',
password: 'app_secret',
credential: 'app-password',
});
const res = await redeem({ pairing_code: code });
expect(res.body).toEqual({ flow: 'password', server_url: SERVER, username: 'alice@example.org', password: 'app_secret' });
expect(res.cacheControl).toBe('no-store');
});
});
describe('pair/status', () => {
it('reports pending, then redeemed', async () => {
const { code, statusId } = seed();
expect(await status(`?id=${statusId}`)).toEqual({ status: 'pending', cacheControl: 'no-store' });
await redeem({ pairing_code: code });
expect((await status(`?id=${statusId}`)).status).toBe('redeemed');
});
it('reports a code replaced by a newer QR as expired', async () => {
const first = seed();
createPairingCode(first.grantId, OWNER, WEBMAIL);
expect((await status(`?id=${first.statusId}`)).status).toBe('expired');
});
it('does not accept the pairing code itself as a status id', async () => {
const { code } = seed();
expect((await status(`?id=${code}`)).status).toBe('unknown');
expect((await status(`?id=${code.slice(0, 32)}`)).status).toBe('unknown');
});
it.each([
['no id', ''],
['an empty id', '?id='],
['a malformed id', '?id=not-hex'],
['an unknown id', `?id=${'0'.repeat(32)}`],
['an uppercase id', `?id=${'A'.repeat(32)}`],
])('answers unknown for %s', async (_label, query) => {
expect(await status(query)).toEqual({ status: 'unknown', cacheControl: 'no-store' });
});
});
+152
View File
@@ -0,0 +1,152 @@
// @vitest-environment node
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { NextRequest } from 'next/server';
// POST /api/auth/sso/start with purpose=reauth: the IdP step-up for "Link
// mobile app". The pending state (real crypto) must name the slot being
// paired, and the phone's grant should outlive the desktop's IdP session
// (offline_access) when the provider supports it.
vi.mock('@/lib/auth/session-secret', () => ({
getSessionSecret: () => 's'.repeat(64),
hasSessionSecret: () => true,
}));
vi.mock('@/lib/logger', () => ({
logger: { warn: () => {}, error: () => {}, info: () => {}, debug: () => {} },
}));
const jar = new Map<string, string>();
const cookieStore = {
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); },
};
vi.mock('next/headers', () => ({ cookies: async () => cookieStore }));
vi.mock('@/lib/admin/config-manager', () => ({
configManager: { ensureLoaded: async () => {}, get: (_key: string, fallback: unknown) => fallback },
}));
const getRequiredConfig = vi.fn((_serverId?: string | null) => ({
clientId: 'webmail-client',
discoveryUrl: 'https://idp.example.net',
}));
vi.mock('@/lib/oauth/token-exchange', () => ({
getRequiredConfig: (serverId?: string | null) => getRequiredConfig(serverId),
getDiscoveryValidator: () => undefined,
}));
let scopesSupported: unknown = undefined;
vi.mock('@/lib/oauth/discovery', () => ({
discoverOAuth: async () => ({
issuer: 'https://idp.example.net',
authorization_endpoint: 'https://idp.example.net/authorize',
token_endpoint: 'https://idp.example.net/token',
...(scopesSupported !== undefined ? { scopes_supported: scopesSupported } : {}),
}),
}));
let configuredScopes = 'openid email profile';
vi.mock('@/lib/oauth/tokens', () => ({
getOauthScopes: () => configuredScopes,
refreshTokenServerCookieName: (slot: number) => (slot === 0 ? 'jmap_rt_server' : `jmap_rt_server_${slot}`),
}));
import { decryptPayload } from '@/lib/auth/crypto';
import { POST } from '@/app/api/auth/sso/start/route';
const WEBMAIL = 'https://webmail.example';
async function start(body: Record<string, unknown>) {
const res = await POST(new NextRequest(`${WEBMAIL}/api/auth/sso/start`, {
method: 'POST',
headers: { 'content-type': 'application/json', 'sec-fetch-site': 'same-origin', origin: WEBMAIL },
body: JSON.stringify({ redirect_uri: `${WEBMAIL}/auth/callback`, ...body }),
}));
const data = await res.json();
return {
status: res.status,
url: data.authorize_url ? new URL(data.authorize_url) : null,
pending: jar.has('sso_pending') ? decryptPayload(jar.get('sso_pending')!, 'sso-pending') : null,
};
}
beforeEach(() => {
jar.clear();
getRequiredConfig.mockClear();
scopesSupported = undefined;
configuredScopes = 'openid email profile';
});
describe('sso/start pending state for a re-auth', () => {
it('records the purpose and the slot being paired', async () => {
const res = await start({ purpose: 'reauth', slot: 3 });
expect(res.status).toBe(200);
expect(res.pending).toMatchObject({ purpose: 'reauth', slot: 3, redirect_uri: `${WEBMAIL}/auth/callback` });
expect(res.pending?.state).toBe(res.url?.searchParams.get('state'));
});
it('defaults the slot to 0', async () => {
expect((await start({ purpose: 'reauth' })).pending).toMatchObject({ purpose: 'reauth', slot: 0 });
});
it.each([-1, 1.5, '2', 50, null])('falls back to slot 0 for %s', async (slot) => {
expect((await start({ purpose: 'reauth', slot })).pending).toMatchObject({ slot: 0 });
});
it("uses the slot's server entry for the IdP", async () => {
jar.set('jmap_rt_server_3', 'corp');
const res = await start({ purpose: 'reauth', slot: 3 });
expect(getRequiredConfig).toHaveBeenCalledWith('corp');
expect(res.pending).toMatchObject({ server_id: 'corp', slot: 3 });
});
it('records neither purpose nor slot for a normal login', async () => {
jar.set('jmap_rt_server_3', 'corp');
const res = await start({ slot: 3 });
expect(res.status).toBe(200);
expect(res.pending).not.toHaveProperty('purpose');
expect(res.pending).not.toHaveProperty('slot');
expect(res.pending).not.toHaveProperty('server_id');
expect(res.url?.searchParams.has('prompt')).toBe(false);
});
it('forces a fresh login for a re-auth', async () => {
expect((await start({ purpose: 'reauth' })).url?.searchParams.get('prompt')).toBe('login');
});
});
describe('sso/start offline_access for a re-auth', () => {
it('asks for offline_access when the provider advertises it', async () => {
scopesSupported = ['openid', 'email', 'profile', 'offline_access'];
const res = await start({ purpose: 'reauth' });
expect(res.url?.searchParams.get('scope')).toBe('openid email profile offline_access');
});
it('does not ask for it when the provider does not advertise it', async () => {
scopesSupported = ['openid', 'email', 'profile'];
expect((await start({ purpose: 'reauth' })).url?.searchParams.get('scope')).toBe('openid email profile');
});
it('does not ask for it when the provider publishes no scopes', async () => {
scopesSupported = undefined;
expect((await start({ purpose: 'reauth' })).url?.searchParams.get('scope')).toBe('openid email profile');
});
it('never asks for it on a normal login', async () => {
scopesSupported = ['openid', 'offline_access'];
expect((await start({})).url?.searchParams.get('scope')).toBe('openid email profile');
});
it('does not add it twice', async () => {
scopesSupported = ['openid', 'offline_access'];
configuredScopes = 'openid offline_access email';
expect((await start({ purpose: 'reauth' })).url?.searchParams.get('scope')).toBe('openid offline_access email');
});
it('normalizes the configured scope list when it appends', async () => {
scopesSupported = ['offline_access'];
configuredScopes = ' openid email ';
expect((await start({ purpose: 'reauth' })).url?.searchParams.get('scope')).toBe('openid email offline_access');
});
});
+245
View File
@@ -0,0 +1,245 @@
// @vitest-environment node
import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
CODE_TTL_MS,
GRANT_TTL_MS,
createPairingCode,
discardGrant,
getPairingStatus,
isGrantAvailable,
redeemPairingCode,
resetPairingStoreForTests,
stashGrant,
type PairingGrant,
} from '@/lib/auth/pairing-store';
// The "Link mobile app" store: a step-up mints a grant (5 min), the desktop
// shows single-use codes for it (2 min each, a new one supersedes the old),
// and the phone redeems one code for the grant, exactly once. Spent records
// stay behind as tombstones without their secrets.
const OWNER = '0:alice@example.org';
const BASE = 'https://webmail.example';
const T0 = 1_800_000_000_000;
const TOMBSTONE_MS = 10 * 60 * 1000;
const GRANT: PairingGrant = {
flow: 'oauth',
serverUrl: 'https://mail.example.org',
serverId: null,
accessToken: 'access-1',
refreshToken: 'refresh-1',
expiresIn: 3600,
issuedAt: T0,
tokenEndpoint: 'https://mail.example.org/auth/token',
clientId: 'bulwark-webmail',
confidential: false,
trusted: true,
};
interface InternalStore {
grants: Map<string, { grant: PairingGrant | null; expiresAt: number; owner: string; forgetAt: number }>;
codes: Map<string, { grantId: string; state: string; expiresAt: number }>;
statusIndex: Map<string, string>;
}
/** The pinned store itself, to check what is (not) kept in memory. */
function internals(): InternalStore {
return (globalThis as unknown as { __bulwarkPairingStore: InternalStore }).__bulwarkPairingStore;
}
function newCode(grantId: string, now: number, owner = OWNER, base: string | null = BASE) {
const pairing = createPairingCode(grantId, owner, base, now);
if (!pairing) throw new Error('expected a code');
return pairing;
}
beforeEach(() => {
resetPairingStoreForTests();
});
describe('pairing store lifecycle', () => {
it('issues a 256-bit code and a separate status id, and redeems the grant once', () => {
const grantId = stashGrant(GRANT, OWNER, T0);
expect(isGrantAvailable(grantId, OWNER, T0)).toBe(true);
const pairing = newCode(grantId, T0);
expect(pairing.code).toMatch(/^[0-9a-f]{64}$/);
expect(pairing.statusId).toMatch(/^[0-9a-f]{32}$/);
expect(pairing.statusId).not.toBe(pairing.code.slice(0, 32));
expect(pairing.expiresIn).toBe(CODE_TTL_MS / 1000);
expect(getPairingStatus(pairing.statusId, T0 + 1)).toBe('pending');
const redeemed = redeemPairingCode(pairing.code, T0 + 1000);
expect(redeemed).toEqual({ ok: true, grant: GRANT, webmailBase: BASE });
expect(getPairingStatus(pairing.statusId, T0 + 1001)).toBe('redeemed');
// Single use.
expect(redeemPairingCode(pairing.code, T0 + 2000)).toEqual({ ok: false, reason: 'used' });
// ...and the grant is consumed: no new code for it, even within its window.
expect(isGrantAvailable(grantId, OWNER, T0 + 2000)).toBe(false);
expect(createPairingCode(grantId, OWNER, BASE, T0 + 2000)).toBeNull();
});
it('forgets the grant secrets once redeemed', () => {
const grantId = stashGrant(GRANT, OWNER, T0);
const pairing = newCode(grantId, T0);
redeemPairingCode(pairing.code, T0 + 1);
expect(internals().grants.get(grantId)?.grant).toBeNull();
});
it('issues a different code every time', () => {
const grantId = stashGrant(GRANT, OWNER, T0);
const codes = new Set<string>();
for (let i = 0; i < 20; i++) codes.add(newCode(grantId, T0 + i).code);
expect(codes.size).toBe(20);
});
it('answers invalid for a code nobody issued, and unknown for a status id nobody issued', () => {
stashGrant(GRANT, OWNER, T0);
expect(redeemPairingCode('0'.repeat(64), T0)).toEqual({ ok: false, reason: 'invalid' });
expect(redeemPairingCode('', T0)).toEqual({ ok: false, reason: 'invalid' });
expect(getPairingStatus('0'.repeat(32), T0)).toBe('unknown');
});
it('treats a code redeemed after its two minutes as expired', () => {
const grantId = stashGrant(GRANT, OWNER, T0);
const pairing = newCode(grantId, T0);
expect(getPairingStatus(pairing.statusId, T0 + CODE_TTL_MS - 1)).toBe('pending');
expect(getPairingStatus(pairing.statusId, T0 + CODE_TTL_MS)).toBe('expired');
expect(redeemPairingCode(pairing.code, T0 + CODE_TTL_MS)).toEqual({ ok: false, reason: 'expired' });
// The grant itself is still there for a new QR within its own window.
expect(isGrantAvailable(grantId, OWNER, T0 + CODE_TTL_MS)).toBe(true);
const next = newCode(grantId, T0 + CODE_TTL_MS);
expect(redeemPairingCode(next.code, T0 + CODE_TTL_MS + 1)).toMatchObject({ ok: true });
});
it('supersedes the previous code of the same grant when a new one is shown', () => {
const grantId = stashGrant(GRANT, OWNER, T0);
const first = newCode(grantId, T0);
const second = newCode(grantId, T0 + 1000);
expect(getPairingStatus(first.statusId, T0 + 1001)).toBe('expired');
expect(getPairingStatus(second.statusId, T0 + 1001)).toBe('pending');
expect(redeemPairingCode(first.code, T0 + 1001)).toEqual({ ok: false, reason: 'expired' });
// Only the QR on screen can be redeemed.
expect(redeemPairingCode(second.code, T0 + 2000)).toMatchObject({ ok: true });
});
it('marks every other code of a redeemed grant used, and its status redeemed', () => {
const grantId = stashGrant(GRANT, OWNER, T0);
const first = newCode(grantId, T0);
const second = newCode(grantId, T0 + 1000);
expect(redeemPairingCode(second.code, T0 + 2000)).toMatchObject({ ok: true });
expect(getPairingStatus(second.statusId, T0 + 2001)).toBe('redeemed');
expect(getPairingStatus(first.statusId, T0 + 2001)).toBe('expired');
// A late scan of the older QR is told it was already used, not expired.
expect(redeemPairingCode(first.code, T0 + 2002)).toEqual({ ok: false, reason: 'used' });
// ...even after its own two minutes ran out.
expect(redeemPairingCode(first.code, T0 + CODE_TTL_MS + 5000)).toEqual({ ok: false, reason: 'used' });
});
it('keeps grants of different owners apart', () => {
const grantId = stashGrant(GRANT, OWNER, T0);
expect(isGrantAvailable(grantId, '1:alice@example.org', T0)).toBe(false);
expect(isGrantAvailable(grantId, '0:mallory@example.org', T0)).toBe(false);
expect(createPairingCode(grantId, '1:alice@example.org', BASE, T0)).toBeNull();
expect(createPairingCode(grantId, '0:mallory@example.org', BASE, T0)).toBeNull();
expect(createPairingCode('f'.repeat(32), OWNER, BASE, T0)).toBeNull();
// The rightful owner still can.
expect(createPairingCode(grantId, OWNER, BASE, T0)).not.toBeNull();
});
it('wipes the grant secrets when the grant expires, keeping a tombstone', () => {
const grantId = stashGrant(GRANT, OWNER, T0);
expect(isGrantAvailable(grantId, OWNER, T0 + GRANT_TTL_MS - 1)).toBe(true);
expect(isGrantAvailable(grantId, OWNER, T0 + GRANT_TTL_MS)).toBe(false);
expect(createPairingCode(grantId, OWNER, BASE, T0 + GRANT_TTL_MS)).toBeNull();
const record = internals().grants.get(grantId);
expect(record).toBeDefined();
expect(record!.grant).toBeNull();
expect(JSON.stringify(record)).not.toContain('access-1');
expect(JSON.stringify(record)).not.toContain('refresh-1');
// The tombstone goes too, eventually.
stashGrant(GRANT, OWNER, T0 + GRANT_TTL_MS + TOMBSTONE_MS);
expect(internals().grants.has(grantId)).toBe(false);
});
it('never lets a code outlive its grant', () => {
const grantId = stashGrant(GRANT, OWNER, T0);
const late = T0 + GRANT_TTL_MS - 30_000;
const pairing = newCode(grantId, late);
expect(pairing.expiresIn).toBe(30);
expect(redeemPairingCode(pairing.code, T0 + GRANT_TTL_MS - 1)).toMatchObject({ ok: true });
const other = stashGrant(GRANT, OWNER, T0);
const code = newCode(other, late);
expect(getPairingStatus(code.statusId, T0 + GRANT_TTL_MS)).toBe('expired');
expect(redeemPairingCode(code.code, T0 + GRANT_TTL_MS)).toEqual({ ok: false, reason: 'expired' });
});
it('stops the codes of a discarded grant', () => {
const grantId = stashGrant(GRANT, OWNER, T0);
const pairing = newCode(grantId, T0);
discardGrant(grantId, T0 + 1);
expect(isGrantAvailable(grantId, OWNER, T0 + 2)).toBe(false);
expect(createPairingCode(grantId, OWNER, BASE, T0 + 2)).toBeNull();
expect(redeemPairingCode(pairing.code, T0 + 2)).toEqual({ ok: false, reason: 'expired' });
expect(internals().grants.get(grantId)?.grant).toBeNull();
});
it('remembers spent codes for status polls and late scans, then forgets them', () => {
const grantId = stashGrant(GRANT, OWNER, T0);
const pairing = newCode(grantId, T0);
redeemPairingCode(pairing.code, T0 + 1);
const forgetAt = T0 + CODE_TTL_MS + TOMBSTONE_MS;
expect(getPairingStatus(pairing.statusId, forgetAt - 1)).toBe('redeemed');
expect(redeemPairingCode(pairing.code, forgetAt - 1)).toEqual({ ok: false, reason: 'used' });
expect(getPairingStatus(pairing.statusId, forgetAt)).toBe('unknown');
expect(redeemPairingCode(pairing.code, forgetAt)).toEqual({ ok: false, reason: 'invalid' });
expect(internals().codes.size).toBe(0);
expect(internals().statusIndex.size).toBe(0);
});
it('hands out the webmail base the code was issued for', () => {
const grantId = stashGrant(GRANT, OWNER, T0);
const pairing = newCode(grantId, T0, OWNER, 'https://webmail.example/mail');
expect(redeemPairingCode(pairing.code, T0 + 1)).toMatchObject({ ok: true, webmailBase: 'https://webmail.example/mail' });
});
it('forgets everything on reset', () => {
const grantId = stashGrant(GRANT, OWNER, T0);
const pairing = newCode(grantId, T0);
resetPairingStoreForTests();
expect(getPairingStatus(pairing.statusId, T0 + 1)).toBe('unknown');
expect(redeemPairingCode(pairing.code, T0 + 1)).toEqual({ ok: false, reason: 'invalid' });
});
});
describe('pairing store instance', () => {
it('is shared by every evaluation of the module (globalThis pinning)', async () => {
const first = await import('@/lib/auth/pairing-store');
const grantId = first.stashGrant(GRANT, OWNER, T0);
const pairing = first.createPairingCode(grantId, OWNER, BASE, T0)!;
vi.resetModules();
const second = await import('@/lib/auth/pairing-store');
expect(second).not.toBe(first);
expect(second.isGrantAvailable(grantId, OWNER, T0 + 1)).toBe(true);
expect(second.getPairingStatus(pairing.statusId, T0 + 1)).toBe('pending');
expect(second.redeemPairingCode(pairing.code, T0 + 2)).toMatchObject({ ok: true, grant: GRANT });
// The first instance sees the redemption made through the second.
expect(first.getPairingStatus(pairing.statusId, T0 + 3)).toBe('redeemed');
expect(first.redeemPairingCode(pairing.code, T0 + 3)).toEqual({ ok: false, reason: 'used' });
});
});
+267
View File
@@ -0,0 +1,267 @@
// @vitest-environment node
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { NextRequest } from 'next/server';
// POST /api/auth/pair/token: the refresh proxy for paired phones that cannot
// renew at the provider themselves. It only renews refresh tokens this
// webmail sealed for a phone, at the endpoint sealed with them, adding the
// client secret server-side.
let sessionSecret = 's'.repeat(64);
vi.mock('@/lib/auth/session-secret', () => ({
getSessionSecret: () => sessionSecret,
hasSessionSecret: () => sessionSecret.length > 0,
}));
vi.mock('@/lib/logger', () => ({
logger: { warn: () => {}, error: () => {}, info: () => {}, debug: () => {} },
}));
const config: Record<string, unknown> = {};
vi.mock('@/lib/admin/config-manager', () => ({
configManager: {
ensureLoaded: async () => {},
get: (key: string, fallback: unknown) => (key in config ? config[key] : fallback),
},
}));
const publicFetch = vi.fn();
vi.mock('@/lib/security/url-guard', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/security/url-guard')>();
return {
...actual,
isPublicHttpUrl: async () => true,
fetchPublicUrl: (...args: unknown[]) => publicFetch(...args),
};
});
import { encryptPayload } from '@/lib/auth/crypto';
import { openPhoneRefreshToken, sealPhoneRefreshToken, type SealedRefresh } from '@/lib/auth/pair-bundle';
import { POST } from '@/app/api/auth/pair/token/route';
const PROVIDER = 'https://idp.example.net/realms/acme/protocol/openid-connect/token';
const SEALED: SealedRefresh = {
refreshToken: 'refresh-1',
serverId: null,
clientId: 'webmail-client',
tokenEndpoint: PROVIDER,
trusted: true,
};
const upstream = vi.fn();
function json(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });
}
async function refresh(params: Record<string, string>, headers: Record<string, string> = {}) {
const res = await POST(new NextRequest('https://webmail.example/api/auth/pair/token', {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded', ...headers },
body: new URLSearchParams(params).toString(),
}));
return {
status: res.status,
body: await res.json(),
cacheControl: res.headers.get('cache-control'),
pragma: res.headers.get('pragma'),
};
}
function refreshWith(value: SealedRefresh = SEALED) {
return refresh({ grant_type: 'refresh_token', refresh_token: sealPhoneRefreshToken(value), client_id: value.clientId });
}
function upstreamParams(mock = upstream) {
const [, init] = mock.mock.calls[0] as [string, RequestInit];
return Object.fromEntries(new URLSearchParams(String(init.body)));
}
beforeEach(() => {
sessionSecret = 's'.repeat(64);
for (const key of Object.keys(config)) delete config[key];
config.oauthClientSecret = 'webmail-client-secret';
vi.stubEnv('OAUTH_CLIENT_SECRET', '');
vi.stubEnv('OAUTH_CLIENT_SECRET_FILE', '');
// A fresh Response per call: a body can only be read once.
upstream.mockReset().mockImplementation(async () => json({ access_token: 'access-2', expires_in: 300, token_type: 'Bearer' }));
vi.stubGlobal('fetch', upstream);
publicFetch.mockReset().mockImplementation(async () => json({ access_token: 'access-2', expires_in: 300 }));
});
afterEach(() => {
vi.unstubAllGlobals();
vi.unstubAllEnvs();
});
describe('pair/token request checks', () => {
it.each([
['authorization_code', { grant_type: 'authorization_code', code: 'x' }],
['password', { grant_type: 'password', username: 'a', password: 'b' }],
['client_credentials', { grant_type: 'client_credentials' }],
['no grant_type', {}],
])('refuses the %s grant', async (_label, params) => {
const res = await refresh({ ...params, refresh_token: sealPhoneRefreshToken(SEALED) });
expect(res.status).toBe(400);
expect(res.body).toEqual({ error: 'unsupported_grant_type' });
expect(res.cacheControl).toBe('no-store');
expect(upstream).not.toHaveBeenCalled();
});
it.each([
['no refresh token', undefined],
['a raw provider refresh token', 'refresh-1'],
['garbage', 'not-even-base64!'],
['a pairing proof cookie', 'PROOF'],
['a session context blob', 'CONTEXT'],
])('refuses %s with invalid_grant, without calling the provider', async (_label, token) => {
const fields = { r: 'refresh-1', s: null, c: 'webmail-client', e: PROVIDER, t: true };
const value = token === 'PROOF'
? encryptPayload({ ...fields, purpose: 'pair', grant_id: 'g', owner: '0:a' }, 'pair-reauth')
: token === 'CONTEXT'
? encryptPayload(fields, 'session-context')
: token;
const res = await refresh({ grant_type: 'refresh_token', ...(value !== undefined ? { refresh_token: value } : {}) });
expect(res.status).toBe(400);
expect(res.body).toEqual({ error: 'invalid_grant' });
expect(upstream).not.toHaveBeenCalled();
expect(publicFetch).not.toHaveBeenCalled();
});
it('refuses a token sealed under another SESSION_SECRET', async () => {
const sealed = sealPhoneRefreshToken(SEALED);
sessionSecret = 't'.repeat(64);
const res = await refresh({ grant_type: 'refresh_token', refresh_token: sealed });
expect(res.body).toEqual({ error: 'invalid_grant' });
expect(upstream).not.toHaveBeenCalled();
});
it('refuses a cross-site browser request', async () => {
const res = await refresh(
{ grant_type: 'refresh_token', refresh_token: sealPhoneRefreshToken(SEALED) },
{ 'sec-fetch-site': 'cross-site', origin: 'https://evil.example' },
);
expect(res.status).toBe(403);
expect(upstream).not.toHaveBeenCalled();
});
});
describe('pair/token renewal', () => {
it('renews at the sealed endpoint with the raw refresh token and the client secret', async () => {
const res = await refreshWith();
expect(res.status).toBe(200);
expect(res.cacheControl).toBe('no-store');
expect(res.pragma).toBe('no-cache');
expect(upstream).toHaveBeenCalledTimes(1);
const [url, init] = upstream.mock.calls[0] as [string, RequestInit];
expect(url).toBe(PROVIDER);
expect(init.method).toBe('POST');
// The body carries the secret: a redirect must not re-send it elsewhere.
expect(init.redirect).toBe('error');
expect(new Headers(init.headers).get('content-type')).toBe('application/x-www-form-urlencoded');
expect(upstreamParams()).toEqual({
grant_type: 'refresh_token',
refresh_token: 'refresh-1',
client_id: 'webmail-client',
client_secret: 'webmail-client-secret',
});
expect(publicFetch).not.toHaveBeenCalled();
});
it('ignores a client_id or endpoint the phone sends', async () => {
await refresh({
grant_type: 'refresh_token',
refresh_token: sealPhoneRefreshToken(SEALED),
client_id: 'attacker-client',
token_endpoint: 'https://evil.example/token',
});
expect(upstream.mock.calls[0][0]).toBe(PROVIDER);
expect(upstreamParams().client_id).toBe('webmail-client');
});
it('uses the secret of the server the grant was minted for', async () => {
config.jmapServers = [{ id: 'main', label: 'Main', url: 'https://mail.example.org', oauth: { clientSecret: 'per-server-secret' } }];
await refreshWith({ ...SEALED, serverId: 'main' });
expect(upstreamParams().client_secret).toBe('per-server-secret');
});
it('sends no client_secret when none is configured', async () => {
delete config.oauthClientSecret;
await refreshWith();
expect(upstreamParams()).not.toHaveProperty('client_secret');
});
it('re-seals a rotated refresh token', async () => {
upstream.mockResolvedValue(json({ access_token: 'access-2', refresh_token: 'refresh-2', expires_in: 300 }));
const res = await refreshWith();
expect(res.body.access_token).toBe('access-2');
expect(res.body.token_type).toBe('Bearer');
expect(res.body.expires_in).toBe(300);
expect(res.body.refresh_token).not.toContain('refresh-2');
expect(openPhoneRefreshToken(res.body.refresh_token)).toEqual({ ...SEALED, refreshToken: 'refresh-2' });
});
it('re-seals the same refresh token when the provider does not rotate', async () => {
const res = await refreshWith();
expect(openPhoneRefreshToken(res.body.refresh_token)).toEqual(SEALED);
// The renewed token works on the proxy again.
upstream.mockClear();
const again = await refresh({ grant_type: 'refresh_token', refresh_token: res.body.refresh_token });
expect(again.status).toBe(200);
expect(upstreamParams().refresh_token).toBe('refresh-1');
});
it('never returns the provider refresh token in the clear', async () => {
upstream.mockResolvedValue(json({ access_token: 'access-2', refresh_token: 'refresh-2' }));
const res = await refreshWith();
expect(JSON.stringify(res.body)).not.toContain('refresh-2');
expect(JSON.stringify(res.body)).not.toContain('refresh-1');
expect(JSON.stringify(res.body)).not.toContain('webmail-client-secret');
expect(res.body).not.toHaveProperty('expires_in');
});
it.each([400, 401, 403])('reports a dead grant (upstream %i) as invalid_grant', async (code) => {
upstream.mockResolvedValue(json({ error: 'invalid_grant' }, code));
const res = await refreshWith();
expect(res.status).toBe(400);
expect(res.body).toEqual({ error: 'invalid_grant' });
expect(res.cacheControl).toBe('no-store');
});
it.each([500, 502, 503, 429])('reports an upstream %i as temporarily_unavailable', async (code) => {
upstream.mockResolvedValue(new Response('down', { status: code }));
const res = await refreshWith();
expect(res.status).toBe(503);
expect(res.body).toEqual({ error: 'temporarily_unavailable' });
});
it('reports a network error or a refused redirect as temporarily_unavailable', async () => {
upstream.mockRejectedValue(new TypeError('fetch failed'));
expect(await refreshWith()).toMatchObject({ status: 503, body: { error: 'temporarily_unavailable' } });
});
it('reports a malformed provider answer as temporarily_unavailable', async () => {
upstream.mockResolvedValue(new Response('<html>', { status: 200 }));
expect(await refreshWith()).toMatchObject({ status: 503, body: { error: 'temporarily_unavailable' } });
upstream.mockResolvedValue(json({ token_type: 'Bearer' }));
expect(await refreshWith()).toMatchObject({ status: 503, body: { error: 'temporarily_unavailable' } });
});
it('renews a grant from a user-chosen server only through the rebinding-safe fetch', async () => {
const untrusted = { ...SEALED, tokenEndpoint: 'https://custom.example/auth/token', trusted: false };
const res = await refreshWith(untrusted);
expect(res.status).toBe(200);
expect(upstream).not.toHaveBeenCalled();
expect(publicFetch).toHaveBeenCalledTimes(1);
expect(publicFetch.mock.calls[0][0]).toBe('https://custom.example/auth/token');
expect(openPhoneRefreshToken(res.body.refresh_token)).toEqual(untrusted);
});
// A grant minted at a user-chosen (untrusted) server is renewed without the
// webmail's own client secret.
it('does not send the webmail client secret to a user-chosen server', async () => {
const untrusted = { ...SEALED, tokenEndpoint: 'https://custom.example/auth/token', trusted: false };
await refreshWith(untrusted);
expect(upstreamParams(publicFetch)).not.toHaveProperty('client_secret');
});
});
+3
View File
@@ -76,6 +76,8 @@ export type PayloadPurpose =
| 'sso-pending'
/** Fresh-IdP-login proof required before a device pairing code is issued. */
| 'pair-reauth'
/** A paired phone's refresh token, renewed through the webmail's token proxy. */
| 'pair-refresh'
/** WOPI access token, scoped to one file node and a six-hour expiry. */
| 'wopi-token'
/** The app password an impersonation handoff minted, kept for revocation. */
@@ -96,6 +98,7 @@ const LEGACY_PAYLOAD_MARKERS: Record<PayloadPurpose, (payload: Record<string, un
// flow; it predates these envelope purposes and is unrelated to them.
'sso-pending': (p) => p.t === undefined && (p.purpose === undefined || p.purpose === 'reauth'),
'pair-reauth': (p) => p.purpose === 'pair',
'pair-refresh': () => false,
'wopi-token': (p) => p.t === 'wopi',
'impersonation-grant': () => false,
};
+80
View File
@@ -0,0 +1,80 @@
// Failed password step-ups for "Link mobile app", per signed-in account.
//
// The step-up checks the typed password against the mail server from this
// server's address. Stalwart bans an address after too many failed logins
// (100 a day per IP or login name) - across the whole server - so a signed-in
// session must not be able to guess passwords through the webmail and get it
// banned for everyone. A handful of mistakes is plenty for a person.
//
// An attempt counts as a failure from the moment it starts, and is given back
// only once it turns out not to have been a wrong credential: otherwise
// parallel requests would all pass the check before the first one failed.
const MAX_FAILURES = 5;
const WINDOW_MS = 15 * 60 * 1000;
const STORE_KEY = '__bulwarkPairAttempts';
interface Entry {
failures: number;
windowStart: number;
}
function store(): Map<string, Entry> {
const g = globalThis as typeof globalThis & { [STORE_KEY]?: Map<string, Entry> };
if (!g[STORE_KEY]) g[STORE_KEY] = new Map();
return g[STORE_KEY];
}
function live(key: string, now: number): Entry | null {
const entry = store().get(key);
if (!entry) return null;
if (now - entry.windowStart >= WINDOW_MS) {
store().delete(key);
return null;
}
return entry;
}
export function isPairStepUpLocked(key: string, now = Date.now()): boolean {
return (live(key, now)?.failures ?? 0) >= MAX_FAILURES;
}
export function recordPairStepUpFailure(key: string, now = Date.now()): void {
const entry = live(key, now);
if (entry) entry.failures += 1;
else store().set(key, { failures: 1, windowStart: now });
// Bound the map: drop windows that ran out.
for (const [k, e] of store()) {
if (now - e.windowStart >= WINDOW_MS) store().delete(k);
}
}
/**
* Start a step-up: false when the account is locked out, otherwise the
* attempt is counted as a failure until {@link settlePairStepUp} says better.
*/
export function beginPairStepUp(key: string, now = Date.now()): boolean {
if (isPairStepUpLocked(key, now)) return false;
recordPairStepUpFailure(key, now);
return true;
}
/**
* Finish a step-up begun with {@link beginPairStepUp}: a success clears the
* count, a wrong credential keeps its failure, anything else (the server was
* unreachable, pairing is unavailable) gives the attempt back.
*/
export function settlePairStepUp(key: string, outcome: 'success' | 'wrong_credential' | 'other'): void {
if (outcome === 'success') {
clearPairStepUpFailures(key);
return;
}
if (outcome === 'other') {
const entry = store().get(key);
if (entry) entry.failures = Math.max(0, entry.failures - 1);
}
}
export function clearPairStepUpFailures(key: string): void {
store().delete(key);
}
+136
View File
@@ -0,0 +1,136 @@
import { encryptPayload, decryptPayload } from '@/lib/auth/crypto';
import type { PairingGrant } from '@/lib/auth/pairing-store';
// What a paired phone receives, and how it renews its access token.
//
// The phone refreshes with `client_id` only, against the `token_endpoint` it
// was given, and current app builds accept that endpoint only on the JMAP
// server's or the webmail's host (or a parent/child domain of either). So
// the phone talks to the provider directly only when both hold; otherwise it
// is pointed at the webmail's token proxy (/api/auth/pair/token), which adds
// the client secret server-side. The refresh token it carries then is sealed
// under SESSION_SECRET, so the proxy renews only tokens this webmail handed
// to a phone and knows where each one belongs, and never works as a
// secret-adding relay for arbitrary tokens.
export const PAIR_TOKEN_PROXY_PATH = '/api/auth/pair/token';
export interface SealedRefresh {
refreshToken: string;
serverId: string | null;
clientId: string;
/** The endpoint that minted the grant (the provider, never the proxy). */
tokenEndpoint: string;
/** False for a user-chosen server: renewals must use the rebinding-safe fetch. */
trusted: boolean;
}
export function sealPhoneRefreshToken(value: SealedRefresh): string {
return encryptPayload(
{ r: value.refreshToken, s: value.serverId, c: value.clientId, e: value.tokenEndpoint, t: value.trusted },
'pair-refresh',
);
}
export function openPhoneRefreshToken(sealed: string): SealedRefresh | null {
let data: Record<string, unknown> | null;
try {
data = decryptPayload(sealed, 'pair-refresh');
} catch {
return null;
}
if (!data) return null;
const { r, s, c, e, t } = data;
if (typeof r !== 'string' || !r || typeof c !== 'string' || !c || typeof e !== 'string' || !e) return null;
if (s !== null && typeof s !== 'string') return null;
return { refreshToken: r, serverId: s, clientId: c, tokenEndpoint: e, trusted: t === true };
}
function hostOf(url: string): string {
try {
return new URL(url).hostname.toLowerCase();
} catch {
return '';
}
}
function isLoopbackHttp(url: string): boolean {
const host = hostOf(url);
return url.toLowerCase().startsWith('http://') && (host === 'localhost' || host === '127.0.0.1' || host === '10.0.2.2');
}
/**
* Mirrors the app's `isAcceptableTokenEndpoint` (src/lib/oauth.ts): https (or
* loopback http for development), on the host family of the JMAP server or
* the webmail.
*/
export function appAcceptsTokenEndpoint(tokenEndpoint: string, serverUrl: string, webmailBase: string | null): boolean {
if (!tokenEndpoint.toLowerCase().startsWith('https://') && !isLoopbackHttp(tokenEndpoint)) return false;
const host = hostOf(tokenEndpoint);
if (!host) return false;
const allowed = [hostOf(serverUrl), webmailBase ? hostOf(webmailBase) : ''].filter(Boolean);
return allowed.some((h) => host === h || host.endsWith(`.${h}`) || h.endsWith(`.${host}`));
}
export type RedeemBundle =
| {
flow: 'oauth';
server_url: string;
access_token: string;
refresh_token?: string;
expires_in?: number;
token_endpoint: string;
client_id: string;
}
| { flow: 'password'; server_url: string; username: string; password: string };
/**
* The redeem response for a grant. `webmailBase` is the public base the
* desktop showed in the QR (origin plus mount path), used for the proxy URL.
*/
export function buildRedeemBundle(
grant: PairingGrant,
webmailBase: string | null,
now = Date.now(),
): RedeemBundle {
if (grant.flow === 'password') {
return { flow: 'password', server_url: grant.serverUrl, username: grant.username, password: grant.password };
}
const expiresIn = typeof grant.expiresIn === 'number'
? Math.max(0, grant.expiresIn - Math.floor((now - grant.issuedAt) / 1000))
: undefined;
const direct = !grant.confidential && appAcceptsTokenEndpoint(grant.tokenEndpoint, grant.serverUrl, webmailBase);
if (direct || !webmailBase) {
return {
flow: 'oauth',
server_url: grant.serverUrl,
access_token: grant.accessToken,
...(grant.refreshToken ? { refresh_token: grant.refreshToken } : {}),
...(expiresIn !== undefined ? { expires_in: expiresIn } : {}),
token_endpoint: grant.tokenEndpoint,
client_id: grant.clientId,
};
}
return {
flow: 'oauth',
server_url: grant.serverUrl,
access_token: grant.accessToken,
...(grant.refreshToken
? {
refresh_token: sealPhoneRefreshToken({
refreshToken: grant.refreshToken,
serverId: grant.serverId,
clientId: grant.clientId,
tokenEndpoint: grant.tokenEndpoint,
trusted: grant.trusted,
}),
}
: {}),
...(expiresIn !== undefined ? { expires_in: expiresIn } : {}),
token_endpoint: `${webmailBase.replace(/\/+$/, '')}${PAIR_TOKEN_PROXY_PATH}`,
client_id: grant.clientId,
};
}
+214
View File
@@ -0,0 +1,214 @@
import { logger } from '@/lib/logger';
import { stalwartPasswordLogin, type StalwartPasswordLoginResult } from '@/lib/auth/stalwart-password-login';
import type { PairingGrant } from '@/lib/auth/pairing-store';
import { DEFAULT_CLIENT_ID, getClientSecret, getRequiredConfig } from '@/lib/oauth/token-exchange';
import { JmapAuthVerificationError, verifyJmapIdentity } from '@/lib/auth/verify-jmap-auth';
import { fetchJmapSession } from '@/lib/stalwart/jmap-api';
import { createAppPassword } from '@/lib/impersonation/app-password';
// The password step-up for "Link mobile app": the desktop user types their
// current password (and TOTP code), and the server uses it once to mint the
// phone's own sign-in. The password is never stored, logged or returned -
// except in the last fallback, for servers that offer nothing better.
//
// In order of preference:
// 1. An OAuth grant from Stalwart's structured login (/api/auth, which also
// takes the TOTP code), for the client the app itself uses natively, so
// the phone renews it directly. If the server insists on a registered
// client, the webmail's own client (as the password+TOTP login uses).
// 2. No structured login, but a Stalwart account: an app password the user
// can see and delete ("Bulwark mobile (paired <date>)").
// 3. Neither (not Stalwart): the verified account password, which is what
// the login page's mobile handoff has always passed to the app.
/** The client id and redirect the app uses for its own sign-in (src/lib/totp-login.ts). */
export const MOBILE_CLIENT_ID = DEFAULT_CLIENT_ID;
export const MOBILE_REDIRECT_URI = 'bulwarkmobile://auth/callback';
export type PasswordGrantError =
| 'invalid_credentials'
| 'totp_required'
| 'server_unreachable'
| 'pairing_unavailable';
export type PasswordGrantResult = { ok: true; grant: PairingGrant } | { ok: false; error: PasswordGrantError };
export interface PasswordGrantOptions {
/** JMAP server of the signed-in account (from its verified session context). */
serverUrl: string;
/** Whether that server is admin-configured (else: rebinding-safe fetch). */
trusted: boolean;
/** Its server entry, for per-server OAuth client settings. */
serverId: string | null;
/** The account's login name. */
username: string;
password: string;
totp?: string;
/** The desktop's OAuth callback URL, for the webmail-client attempt. */
webmailRedirectUri?: string;
now?: Date;
}
function configuredClient(serverId: string | null): { clientId: string; clientSecret: string } {
let clientId = DEFAULT_CLIENT_ID;
try {
clientId = getRequiredConfig(serverId, { fallbackClientId: DEFAULT_CLIENT_ID }).clientId;
} catch {
// No server configured (custom endpoints only): the default client.
}
return { clientId, clientSecret: getClientSecret(serverId) };
}
function oauthGrant(
opts: PasswordGrantOptions,
result: Extract<StalwartPasswordLoginResult, { ok: true }>,
clientId: string,
confidential: boolean,
): PairingGrant {
return {
flow: 'oauth',
serverUrl: opts.serverUrl,
serverId: opts.serverId,
accessToken: result.tokens.access_token,
...(result.tokens.refresh_token ? { refreshToken: result.tokens.refresh_token } : {}),
expiresIn: result.tokens.expires_in ?? 3600,
issuedAt: (opts.now ?? new Date()).getTime(),
tokenEndpoint: result.tokenEndpoint,
clientId,
confidential,
trusted: opts.trusted,
};
}
/** A credential verdict ends the attempt; anything else may try the next way. */
function verdict(result: StalwartPasswordLoginResult): PasswordGrantResult | null {
if (result.ok) return null;
switch (result.error) {
case 'invalid_credentials':
case 'totp_required':
return { ok: false, error: result.error };
case 'login_unreachable':
case 'invalid_server_url':
return { ok: false, error: 'server_unreachable' };
default:
return null;
}
}
function pairedDescription(now: Date): string {
return `Bulwark mobile (paired ${now.toISOString().slice(0, 10)})`;
}
export async function mintGrantWithPassword(opts: PasswordGrantOptions): Promise<PasswordGrantResult> {
const now = opts.now ?? new Date();
const configured = configuredClient(opts.serverId);
// The secret belongs to the admin's server; a user-chosen one never sees it.
if (!opts.trusted) configured.clientSecret = '';
// 1a. The app's own client. It is public unless the admin registered it
// with a secret for the webmail too - then the secret is ours to send, and
// the phone must renew through the webmail.
const mobileSecret = configured.clientId === MOBILE_CLIENT_ID ? configured.clientSecret : '';
const first = await stalwartPasswordLogin({
serverUrl: opts.serverUrl,
username: opts.username,
password: opts.password,
totp: opts.totp,
clientId: MOBILE_CLIENT_ID,
clientSecret: mobileSecret,
redirectUri: MOBILE_REDIRECT_URI,
trusted: opts.trusted,
});
if (first.ok) return { ok: true, grant: oauthGrant(opts, first, MOBILE_CLIENT_ID, mobileSecret !== '') };
const firstVerdict = verdict(first);
if (firstVerdict) return firstVerdict;
const hasStructuredLogin = first.error !== 'login_endpoint_missing';
// 1b. The server wants a registered client or redirect: the webmail's own,
// exactly as its password+TOTP login is set up.
if (hasStructuredLogin && opts.webmailRedirectUri
&& (configured.clientId !== MOBILE_CLIENT_ID || opts.webmailRedirectUri !== MOBILE_REDIRECT_URI)) {
const second = await stalwartPasswordLogin({
serverUrl: opts.serverUrl,
username: opts.username,
password: opts.password,
totp: opts.totp,
clientId: configured.clientId,
clientSecret: configured.clientSecret,
redirectUri: opts.webmailRedirectUri,
trusted: opts.trusted,
});
if (second.ok) {
return { ok: true, grant: oauthGrant(opts, second, configured.clientId, configured.clientSecret !== '') };
}
const secondVerdict = verdict(second);
if (secondVerdict) return secondVerdict;
logger.warn('Pair step-up: structured login refused both clients', { first: first.error, second: second.error });
}
// 2./3. Basic auth. A TOTP account cannot use it on Stalwart 0.16 (it
// answers 402 when the password is right), so with a code there is nothing
// left to try.
const authHeader = `Basic ${Buffer.from(`${opts.username}:${opts.password}`).toString('base64')}`;
try {
await verifyJmapIdentity(opts.serverUrl, authHeader, opts.username, { trusted: opts.trusted });
} catch (error) {
if (error instanceof JmapAuthVerificationError && error.status === 401) {
return { ok: false, error: 'invalid_credentials' };
}
// Stalwart answers Basic auth for a TOTP account with 402 when the
// password is right.
if (error instanceof JmapAuthVerificationError && error.upstreamStatus === 402) {
return { ok: false, error: opts.totp ? 'pairing_unavailable' : 'totp_required' };
}
logger.warn('Pair step-up: credential check failed', {
status: error instanceof JmapAuthVerificationError ? error.status : undefined,
});
return { ok: false, error: 'server_unreachable' };
}
const session = await fetchJmapSession(opts.serverUrl, authHeader, { trusted: opts.trusted }).catch(() => null);
const isStalwart = !!session && Object.values(
(session as { accounts?: Record<string, { accountCapabilities?: Record<string, unknown> }> }).accounts ?? {},
).some((account) => !!account?.accountCapabilities?.['urn:stalwart:jmap']);
if (isStalwart) {
try {
const appPassword = await createAppPassword({
serverUrl: opts.serverUrl,
authHeader,
description: pairedDescription(now),
trusted: opts.trusted,
});
return {
ok: true,
grant: {
flow: 'password',
serverUrl: opts.serverUrl,
username: opts.username,
password: appPassword.secret,
credential: 'app-password',
},
};
} catch (error) {
// An account that may not create app passwords (permissions, quota)
// gets no phone credential at all rather than its real password: on a
// Stalwart server that is a policy, not a missing feature.
logger.warn('Pair step-up: app password was not created', {
error: error instanceof Error ? error.message : 'Unknown error',
});
return { ok: false, error: 'pairing_unavailable' };
}
}
return {
ok: true,
grant: {
flow: 'password',
serverUrl: opts.serverUrl,
username: opts.username,
password: opts.password,
credential: 'account-password',
},
};
}
+50 -18
View File
@@ -1,38 +1,70 @@
import { cookies } from 'next/headers';
import type { cookies } from 'next/headers';
import { encryptPayload, decryptPayload } from '@/lib/auth/crypto';
import { getCookieOptions } from '@/lib/oauth/cookie-config';
import { discardGrant } from '@/lib/auth/pairing-store';
// Step-up re-authentication proof for device pairing. Generating a pairing QR
// provisions a new long-lived login on another device, so we require the user
// to have re-authenticated at the IdP within a short window first (see the
// reauth SSO flow). This proof is a short-lived, encrypted, httpOnly cookie —
// it is set only after a fresh IdP login and checked by /api/auth/pair/create.
// Step-up proof for device pairing. Linking a phone provisions a new
// long-lived login, so the desktop must prove who it is again first: the
// current password (and TOTP) or a fresh IdP login. The step-up mints the
// phone's own grant (see pairing-store); this short-lived, encrypted,
// httpOnly cookie remembers which grant, and for which signed-in account, so
// the desktop can show a new QR within the window without asking again.
type CookieStore = Awaited<ReturnType<typeof cookies>>;
const PAIR_REAUTH_COOKIE = 'pair_reauth';
const PAIR_REAUTH_TTL_MS = 5 * 60 * 1000; // 5 minutes — long enough to render the QR
const PAIR_REAUTH_TTL_MS = 5 * 60 * 1000; // matches the grant's lifetime
export async function setPairReauth(): Promise<void> {
const cookieStore = await cookies();
const value = encryptPayload({ purpose: 'pair', created_at: Date.now() }, 'pair-reauth');
export interface PairReauthProof {
grantId: string;
owner: string;
}
/** The account a step-up is valid for: the cookie slot and its verified user. */
export function pairingOwner(slot: number, username: string): string {
return `${slot}:${username.trim().toLowerCase()}`;
}
export function setPairReauthInStore(cookieStore: CookieStore, proof: PairReauthProof): void {
// A new step-up replaces the previous grant; its QR must stop working.
const previous = readPairReauthFromStore(cookieStore);
if (previous && previous.grantId !== proof.grantId) discardGrant(previous.grantId);
const value = encryptPayload(
{ purpose: 'pair', created_at: Date.now(), grant_id: proof.grantId, owner: proof.owner },
'pair-reauth',
);
cookieStore.set(PAIR_REAUTH_COOKIE, value, {
...getCookieOptions(),
maxAge: Math.floor(PAIR_REAUTH_TTL_MS / 1000),
});
}
export async function hasValidPairReauth(): Promise<boolean> {
const cookieStore = await cookies();
/**
* The live proof, or null. A proof from before grants existed (no grant id)
* fails closed, so the user is simply asked to confirm again.
*/
export function readPairReauthFromStore(cookieStore: CookieStore): PairReauthProof | null {
const raw = cookieStore.get(PAIR_REAUTH_COOKIE)?.value;
if (!raw) return false;
if (!raw) return null;
const data = decryptPayload(raw, 'pair-reauth');
if (!data || data.purpose !== 'pair') return false;
if (!data || data.purpose !== 'pair') return null;
const createdAt = typeof data.created_at === 'number' ? data.created_at : 0;
// Belt-and-suspenders alongside the cookie maxAge: a forged/old proof whose
// Belt-and-suspenders alongside the cookie maxAge: an old proof whose
// timestamp is outside the window is rejected even if the cookie survived.
return Date.now() - createdAt <= PAIR_REAUTH_TTL_MS;
if (Date.now() - createdAt > PAIR_REAUTH_TTL_MS) return null;
if (typeof data.grant_id !== 'string' || typeof data.owner !== 'string') return null;
return { grantId: data.grant_id, owner: data.owner };
}
export async function clearPairReauth(): Promise<void> {
const cookieStore = await cookies();
/**
* Sign-out: forget the proof and kill its grant, so a QR left on screen dies.
* With `slot`, only when the proof belongs to that account.
*/
export function clearPairReauthInStore(cookieStore: CookieStore, slot?: number): void {
const raw = cookieStore.get(PAIR_REAUTH_COOKIE)?.value;
if (!raw) return;
const proof = readPairReauthFromStore(cookieStore);
if (slot !== undefined && proof && !proof.owner.startsWith(`${slot}:`)) return;
if (proof) discardGrant(proof.grantId);
cookieStore.delete(PAIR_REAUTH_COOKIE);
}
+217 -44
View File
@@ -1,62 +1,235 @@
import { randomBytes } from 'crypto';
// Cross-device login pairing. A signed-in desktop session mints a short-lived,
// single-use pairing code (see /api/auth/pair/create) which is rendered as a
// QR. The mobile app scans it and redeems the code (see /api/auth/pair/redeem)
// for the OAuth token bundle, so the phone is signed in without re-typing
// anything. The code itself carries no secrets — the tokens never leave the
// server until the matching code is redeemed exactly once.
// Cross-device login pairing ("Link mobile app"). A signed-in desktop proves
// who it is again (password + TOTP, or a fresh IdP login), which mints a
// *grant*: sign-in material for the phone that is independent of the
// desktop's own session. /api/auth/pair/create then issues a short-lived,
// single-use *code* for that grant, rendered as a QR. The app scans it and
// redeems the code at /api/auth/pair/redeem for the grant. The code itself
// carries no secrets; the grant never leaves the server until its code is
// redeemed, exactly once.
//
// Storage is an in-process Map. That is sufficient for the single-instance
// (pm2) deployments this webmail targets; a multi-instance deployment would
// need to swap this for a shared store (Redis) keyed the same way. Records are
// tiny and expire within PAIRING_TTL_MS, so memory pressure is negligible.
// A grant outlives a single code (the desktop may show a new QR after the
// first one expired without asking for the password again), but it is handed
// out at most once: redeeming any of its codes consumes it and invalidates
// the others.
//
// Records are kept after they stop being usable, as tombstones without their
// secrets, so a late scan can be told "already used" rather than "expired",
// and the desktop can poll whether its code was redeemed.
//
// Storage is an in-process Map, pinned on globalThis so dev HMR and duplicate
// module evaluation (route bundles) share one instance. That is sufficient for
// the single-instance deployments this webmail targets; a multi-instance
// deployment needs sticky sessions or a shared store keyed the same way.
export interface PairingTokens {
accessToken: string;
refreshToken?: string;
expiresIn?: number;
tokenEndpoint: string;
clientId: string;
serverUrl: string;
serverId: string | null;
}
export type PairingGrant =
| {
flow: 'oauth';
/** JMAP server the phone connects to. */
serverUrl: string;
/** Server entry the grant was minted against, for the refresh proxy. */
serverId: string | null;
accessToken: string;
refreshToken?: string;
/** Access token lifetime in seconds, counted from `issuedAt`. */
expiresIn?: number;
issuedAt: number;
/** The token endpoint that minted the grant (never a proxy URL). */
tokenEndpoint: string;
clientId: string;
/** Refreshing needs a client secret the phone must not have. */
confidential: boolean;
/** The token endpoint belongs to an admin-configured server or issuer. */
trusted: boolean;
}
| {
flow: 'password';
serverUrl: string;
username: string;
/** An app password minted for the phone, or the account password. */
password: string;
credential: 'app-password' | 'account-password';
};
interface PairingRecord extends PairingTokens {
expiresAt: number; // epoch ms
}
export type PairingStatus = 'pending' | 'redeemed' | 'expired' | 'unknown';
const PAIRING_TTL_MS = 2 * 60 * 1000; // 2 minutes — enough time to scan
export type RedeemResult =
| { ok: true; grant: PairingGrant; webmailBase: string | null }
| { ok: false; reason: 'invalid' | 'expired' | 'used' };
/** How long a grant stays available for new codes after the step-up. */
export const GRANT_TTL_MS = 5 * 60 * 1000;
/** How long one code can be scanned. */
export const CODE_TTL_MS = 2 * 60 * 1000;
/** How long spent records are remembered, for status polls and late scans. */
const TOMBSTONE_MS = 10 * 60 * 1000;
const CODE_BYTES = 32; // 256 bits of entropy
const ID_BYTES = 16;
const store = new Map<string, PairingRecord>();
interface GrantRecord {
grant: PairingGrant | null; // null once consumed or expired
expiresAt: number;
/** Which signed-in account the step-up was for: `<slot>:<username>`. */
owner: string;
forgetAt: number;
}
// Drop any expired records. Called on every create/consume so the Map can't
// grow without bound even if codes are minted and never redeemed.
interface CodeRecord {
grantId: string;
statusId: string;
webmailBase: string | null;
expiresAt: number;
state: 'pending' | 'redeemed' | 'superseded';
forgetAt: number;
}
interface Store {
grants: Map<string, GrantRecord>;
codes: Map<string, CodeRecord>;
statusIndex: Map<string, string>; // statusId -> code
}
const STORE_KEY = '__bulwarkPairingStore';
function store(): Store {
const g = globalThis as typeof globalThis & { [STORE_KEY]?: Store };
if (!g[STORE_KEY]) {
g[STORE_KEY] = { grants: new Map(), codes: new Map(), statusIndex: new Map() };
}
return g[STORE_KEY];
}
// Wipe secrets of anything past its expiry and forget tombstones past theirs.
// Called on every operation so memory stays bounded without a timer.
function sweep(now: number): void {
for (const [code, record] of store) {
if (record.expiresAt <= now) store.delete(code);
const { grants, codes, statusIndex } = store();
for (const [id, record] of grants) {
if (record.expiresAt <= now) record.grant = null;
if (record.forgetAt <= now) grants.delete(id);
}
for (const [code, record] of codes) {
if (record.forgetAt <= now) {
codes.delete(code);
statusIndex.delete(record.statusId);
}
}
}
export function createPairing(tokens: PairingTokens): { code: string; expiresIn: number } {
const now = Date.now();
/** Keep the step-up result until a code for it is redeemed or it expires. */
export function stashGrant(grant: PairingGrant, owner: string, now = Date.now()): string {
sweep(now);
const code = randomBytes(CODE_BYTES).toString('hex');
store.set(code, { ...tokens, expiresAt: now + PAIRING_TTL_MS });
return { code, expiresIn: Math.floor(PAIRING_TTL_MS / 1000) };
const id = randomBytes(ID_BYTES).toString('hex');
store().grants.set(id, {
grant,
owner,
expiresAt: now + GRANT_TTL_MS,
forgetAt: now + GRANT_TTL_MS + TOMBSTONE_MS,
});
return id;
}
// Single-use: a successful lookup removes the record so a code can never be
// redeemed twice. Returns null for unknown, expired, or already-redeemed codes
// — the caller must not distinguish these to avoid leaking code validity.
export function consumePairing(code: string): PairingTokens | null {
const now = Date.now();
/** Whether `grantId` can still be paired, by the account it was minted for. */
export function isGrantAvailable(grantId: string, owner: string, now = Date.now()): boolean {
sweep(now);
const record = store.get(code);
if (!record) return null;
store.delete(code);
if (record.expiresAt <= now) return null;
const { expiresAt: _expiresAt, ...tokens } = record;
return tokens;
const record = store().grants.get(grantId);
return !!record && record.grant !== null && record.owner === owner && record.expiresAt > now;
}
/** Drop a grant (sign-out, a new step-up): its codes stop working. */
export function discardGrant(grantId: string, now = Date.now()): void {
sweep(now);
const record = store().grants.get(grantId);
if (record) record.grant = null;
}
/**
* Issue a code for an available grant. Earlier codes of the same grant are
* superseded, so only the QR on screen can be redeemed. Returns null when the
* grant is gone, so the caller asks for a new step-up.
*/
export function createPairingCode(
grantId: string,
owner: string,
webmailBase: string | null,
now = Date.now(),
): { code: string; statusId: string; expiresIn: number } | null {
if (!isGrantAvailable(grantId, owner, now)) return null;
const { codes, statusIndex, grants } = store();
for (const record of codes.values()) {
if (record.grantId === grantId && record.state === 'pending') record.state = 'superseded';
}
const code = randomBytes(CODE_BYTES).toString('hex');
const statusId = randomBytes(ID_BYTES).toString('hex');
// A code never outlives its grant.
const expiresAt = Math.min(now + CODE_TTL_MS, grants.get(grantId)!.expiresAt);
codes.set(code, {
grantId,
statusId,
webmailBase,
expiresAt,
state: 'pending',
forgetAt: expiresAt + TOMBSTONE_MS,
});
statusIndex.set(statusId, code);
return { code, statusId, expiresIn: Math.max(0, Math.floor((expiresAt - now) / 1000)) };
}
/**
* Hand out the grant behind `code`, once. A code nobody issued is `invalid`;
* one that timed out, or was replaced by a newer QR, is `expired`; one that
* was redeemed before (by any code of its grant) is `used`. Distinguishing
* them leaks nothing useful: codes carry 256 bits and cannot be guessed.
*/
export function redeemPairingCode(code: string, now = Date.now()): RedeemResult {
sweep(now);
const { codes, grants } = store();
const record = codes.get(code);
if (!record) return { ok: false, reason: 'invalid' };
if (record.state === 'redeemed') return { ok: false, reason: 'used' };
const grantRecord = grants.get(record.grantId);
if (record.state === 'superseded' || record.expiresAt <= now) {
return { ok: false, reason: grantRecord && grantRecord.grant === null && wasRedeemed(record.grantId) ? 'used' : 'expired' };
}
if (!grantRecord || grantRecord.grant === null) {
return { ok: false, reason: wasRedeemed(record.grantId) ? 'used' : 'expired' };
}
const grant = grantRecord.grant;
grantRecord.grant = null;
record.state = 'redeemed';
for (const other of codes.values()) {
if (other !== record && other.grantId === record.grantId && other.state === 'pending') {
other.state = 'superseded';
}
}
return { ok: true, grant, webmailBase: record.webmailBase };
}
function wasRedeemed(grantId: string): boolean {
for (const record of store().codes.values()) {
if (record.grantId === grantId && record.state === 'redeemed') return true;
}
return false;
}
/** What the desktop shows for its QR: still waiting, used by the phone, or dead. */
export function getPairingStatus(statusId: string, now = Date.now()): PairingStatus {
sweep(now);
const { statusIndex, codes, grants } = store();
const code = statusIndex.get(statusId);
const record = code ? codes.get(code) : undefined;
if (!record) return 'unknown';
if (record.state === 'redeemed') return 'redeemed';
if (record.state === 'superseded' || record.expiresAt <= now) return 'expired';
// Its grant was discarded (sign-out, a newer step-up): the code is dead.
if (!grants.get(record.grantId)?.grant) return 'expired';
return 'pending';
}
/** Test hook: forget everything. */
export function resetPairingStoreForTests(): void {
const s = store();
s.grants.clear();
s.codes.clear();
s.statusIndex.clear();
}
+40 -13
View File
@@ -46,8 +46,9 @@ async function callStalwart(
serverUrl: string,
authHeader: string,
methodCall: [string, Record<string, unknown>, string],
trusted = true,
): Promise<Record<string, unknown>> {
const session = await fetchJmapSession(serverUrl, authHeader, { trusted: true });
const session = await fetchJmapSession(serverUrl, authHeader, { trusted });
const accountId = pickAccountId(session?.primaryAccounts);
if (!session || !accountId) {
throw new ImpersonationCredentialError('Could not open the target mailbox with the master credential');
@@ -58,7 +59,7 @@ async function callStalwart(
apiUrl,
authHeader,
JSON.stringify({ using: STALWART_USING, methodCalls: [[method, { accountId, ...args }, callId]] }),
{ trusted: true },
{ trusted },
);
if (!response.ok) {
throw new ImpersonationCredentialError(`Stalwart answered HTTP ${response.status}`);
@@ -72,6 +73,36 @@ async function callStalwart(
return result[1];
}
/**
* Create an app password on the mailbox `authHeader` opens. Without
* `expiresAt` it lives until the user deletes it. `trusted` is false for a
* user-chosen server, which must be reached through the rebinding-safe fetch.
*/
export async function createAppPassword(opts: {
serverUrl: string;
authHeader: string;
description: string;
expiresAt?: string;
trusted?: boolean;
}): Promise<{ id: string; secret: string }> {
const result = await callStalwart(
opts.serverUrl,
opts.authHeader,
[
'x:AppPassword/set',
{ create: { imp: { description: opts.description, ...(opts.expiresAt ? { expiresAt: opts.expiresAt } : {}) } } },
'0',
],
opts.trusted ?? true,
);
const created = (result.created as Record<string, { id?: string; secret?: string }> | undefined)?.imp;
if (!created?.id || !created.secret) {
const failed = (result.notCreated as Record<string, { type?: string; description?: string }> | undefined)?.imp;
throw new ImpersonationCredentialError(failed?.description || failed?.type || 'App password was not created');
}
return { id: created.id, secret: created.secret };
}
/**
* Use the master credential server-side, once, to create an expiring app
* password on the target mailbox. The browser only ever receives this app
@@ -88,17 +119,13 @@ export async function mintImpersonationCredential(opts: {
}): Promise<ImpersonationCredential> {
const now = opts.now ?? new Date();
const expiresAt = utcDate(new Date(now.getTime() + IMPERSONATION_SESSION_TTL_SEC * 1000));
const result = await callStalwart(
opts.serverUrl,
masterAuthHeader(opts.mailbox, opts.masterUser, opts.masterPassword),
['x:AppPassword/set', { create: { imp: { description: opts.description, expiresAt } } }, '0'],
);
const created = (result.created as Record<string, { id?: string; secret?: string }> | undefined)?.imp;
if (!created?.id || !created.secret) {
const failed = (result.notCreated as Record<string, { type?: string; description?: string }> | undefined)?.imp;
throw new ImpersonationCredentialError(failed?.description || failed?.type || 'App password was not created');
}
return { id: created.id, secret: created.secret, expiresAt };
const created = await createAppPassword({
serverUrl: opts.serverUrl,
authHeader: masterAuthHeader(opts.mailbox, opts.masterUser, opts.masterPassword),
description: opts.description,
expiresAt,
});
return { ...created, expiresAt };
}
/** Best-effort revocation of a credential minted by {@link mintImpersonationCredential}. */
+4
View File
@@ -4,6 +4,7 @@ export interface OAuthMetadata {
token_endpoint: string;
revocation_endpoint?: string;
end_session_endpoint?: string;
scopes_supported?: string[];
}
// Validates that a discovered endpoint URL is safe to follow. Server-side
@@ -123,6 +124,9 @@ async function attemptDiscovery(
token_endpoint: data.token_endpoint,
revocation_endpoint: data.revocation_endpoint,
end_session_endpoint: data.end_session_endpoint,
...(Array.isArray(data.scopes_supported)
? { scopes_supported: data.scopes_supported.filter((s: unknown): s is string => typeof s === 'string') }
: {}),
};
}
errors.push(`${url} response missing required endpoints`);
+10 -1
View File
@@ -121,12 +121,21 @@ export function getRequiredConfig(serverId?: string | null, options?: ClientConf
return { clientId, serverUrl, discoveryUrl, serverId: entry?.id };
}
function getClientSecret(serverId?: string | null): string {
export function getClientSecret(serverId?: string | null): string {
const entry = getServerEntry(serverId);
if (entry?.oauth?.clientSecret) return entry.oauth.clientSecret;
return getGlobalClientSecret();
}
/**
* Whether the server's client is confidential. Tokens minted for it can only
* be refreshed with the secret, which never leaves this server - so a paired
* phone renews them through the webmail (see lib/auth/pair-bundle.ts).
*/
export function hasClientSecret(serverId?: string | null): boolean {
return getClientSecret(serverId) !== '';
}
export async function getTokenEndpoint(serverId?: string | null, options?: ClientConfigOptions): Promise<string> {
const { discoveryUrl } = getRequiredConfig(serverId, options);
const metadata = await discoverOAuth(discoveryUrl, { validateEndpoint: getDiscoveryValidator(discoveryUrl) });
+25 -4
View File
@@ -1724,14 +1724,35 @@
},
"link_device": {
"title": "ربط تطبيق الجوال",
"description": "سجّل الدخول إلى تطبيق Bulwark Mail للجوال دون كتابة أي شيء. أنشئ رمز QR هنا وامسحه ضوئيًا من شاشة تسجيل الدخول في التطبيق.",
"description": "سجّل الدخول إلى تطبيق Bulwark Mail على هاتفك دون كتابة كلمة المرور فيه. يحصل هاتفك على تسجيل دخول خاص به، لذا لن يؤدي تسجيل الخروج هنا إلى تسجيل خروجه.",
"generate": "إظهار رمز QR",
"regenerate": "إظهار رمز جديد",
"instructions": "افتح تطبيق Bulwark Mail، اضغط على \"مسح رمز QR\" في شاشة تسجيل الدخول، ووجّه الكاميرا هنا.",
"instructions": "افتح تطبيق Bulwark Mail واضغط على \"Scan a sign-in code\" في شاشة تسجيل الدخول (أو \"Add account\" → \"Scan a sign-in code\" إذا كنت مسجّل الدخول بالفعل)، ثم وجّه الكاميرا إلى هنا.",
"expires_in": "تنتهي صلاحية هذا الرمز خلال {seconds} ثانية. يمكن استخدامه مرة واحدة فقط.",
"expired": "انتهت صلاحية هذا الرمز.",
"generating": "جارٍ التوليد…",
"error": "تعذّر إنشاء رمز الاقتران. يرجى المحاولة مرة أخرى."
"error": "تعذّر إنشاء رمز الاقتران. يرجى المحاولة مرة أخرى.",
"password_prompt": "أدخل كلمة المرور لتأكيد هويتك. يحصل هاتفك على تسجيل دخول خاص به، ولا تُرسَل كلمة المرور إلى الهاتف.",
"show_password": "إظهار كلمة المرور",
"hide_password": "إخفاء كلمة المرور",
"continue": "متابعة",
"error_wrong_password": "كلمة المرور غير صحيحة. حاول مرة أخرى.",
"totp_prompt": "أدخل الرمز من تطبيق المصادقة.",
"error_password_or_code": "كلمة المرور أو الرمز غير صحيح.",
"error_not_signed_in": "انتهت صلاحية جلستك. سجّل الدخول مرة أخرى لربط جهاز.",
"error_impersonation": "لا يمكن ربط الأجهزة أثناء عرض صندوق بريد مستخدم آخر.",
"error_too_many_attempts": "محاولات كثيرة جدًا. انتظر قليلًا ثم حاول مرة أخرى.",
"error_session_secret": "ربط الأجهزة غير مُعدّ على هذا الخادم (SESSION_SECRET مفقود). تواصل مع المسؤول.",
"error_server_unreachable": "تعذّر الوصول إلى خادم البريد. حاول مرة أخرى.",
"error_pairing_unavailable": "لا يسمح خادم البريد لديك بربط الأجهزة بهذه الطريقة. تواصل مع المسؤول.",
"qr_alt": "رمز QR لتسجيل الدخول",
"link_label": "رابط تسجيل الدخول",
"copy_link": "نسخ رابط تسجيل الدخول",
"copied": "تم النسخ",
"copy_hint": "لا توجد كاميرا؟ انسخ الرابط والصقه في التطبيق عبر \"Paste sign-in link\".",
"success_title": "تم تسجيل الدخول على هاتفك",
"success_body": "يمكنك إغلاق هذه الصفحة. لربط جهاز آخر، اعرض رمزًا جديدًا.",
"link_another": "ربط جهاز آخر",
"use_sso": "استخدم تسجيل الدخول الموحّد بدلاً من ذلك"
},
"public_keys": {
"name_label": "اسم المفتاح العام",
+25 -4
View File
@@ -1724,14 +1724,35 @@
},
"link_device": {
"title": "Enllaça l'aplicació mòbil",
"description": "Inicieu la sessió a l'aplicació mòbil Bulwark Mail sense escriure res. Genereu un codi QR aquí i escanegeu-lo des de la pantalla d'inici de sessió de l'aplicació.",
"description": "Inicieu la sessió a l'aplicació Bulwark Mail del telèfon sense escriure-hi la contrasenya. El telèfon obté el seu propi inici de sessió, de manera que tancar la sessió aquí no la tanca al telèfon.",
"generate": "Mostra el codi QR",
"regenerate": "Mostra un codi nou",
"instructions": "Obriu l'aplicació Bulwark Mail, toqueu «Escaneja el codi QR» a la pantalla d'inici de sessió i apunteu la càmera aquí.",
"instructions": "Obriu l'aplicació Bulwark Mail i toqueu «Scan a sign-in code» a la pantalla d'inici de sessió (o «Add account» → «Scan a sign-in code» si ja heu iniciat la sessió) i, després, apunteu la càmera aquí.",
"expires_in": "Aquest codi caduca d'aquí a {seconds} segons. Només es pot utilitzar una vegada.",
"expired": "Aquest codi ha caducat.",
"generating": "Generant…",
"error": "No s'ha pogut crear el codi d'aparellament. Torneu-ho a provar."
"error": "No s'ha pogut crear el codi d'aparellament. Torneu-ho a provar.",
"password_prompt": "Introduïu la contrasenya per confirmar que sou vós. El telèfon obté el seu propi inici de sessió; la contrasenya no s'envia al telèfon.",
"show_password": "Mostra la contrasenya",
"hide_password": "Amaga la contrasenya",
"continue": "Continua",
"error_wrong_password": "Contrasenya incorrecta. Torneu-ho a provar.",
"totp_prompt": "Introduïu el codi de l'aplicació d'autenticació.",
"error_password_or_code": "La contrasenya o el codi són incorrectes.",
"error_not_signed_in": "La sessió ha caducat. Torneu a iniciar la sessió per enllaçar un dispositiu.",
"error_impersonation": "No es poden enllaçar dispositius mentre consulteu la bústia d'un altre usuari.",
"error_too_many_attempts": "Massa intents. Espereu un moment i torneu-ho a provar.",
"error_session_secret": "L'enllaç de dispositius no està configurat en aquest servidor (falta SESSION_SECRET). Poseu-vos en contacte amb l'administrador.",
"error_server_unreachable": "No s'ha pogut connectar amb el servidor de correu. Torneu-ho a provar.",
"error_pairing_unavailable": "El vostre servidor de correu no permet enllaçar dispositius d'aquesta manera. Poseu-vos en contacte amb l'administrador.",
"qr_alt": "Codi QR d'inici de sessió",
"link_label": "Enllaç d'inici de sessió",
"copy_link": "Copia l'enllaç d'inici de sessió",
"copied": "Copiat",
"copy_hint": "No teniu càmera? Copieu l'enllaç i enganxeu-lo a l'aplicació amb «Paste sign-in link».",
"success_title": "Sessió iniciada al telèfon",
"success_body": "Podeu tancar aquesta pàgina. Per enllaçar un altre dispositiu, mostreu un codi nou.",
"link_another": "Enllaça un altre dispositiu",
"use_sso": "Utilitza l'inici de sessió únic"
},
"public_keys": {
"name_label": "Nom de la clau pública",
+25 -4
View File
@@ -1717,14 +1717,35 @@
},
"link_device": {
"title": "Propojit mobilní aplikaci",
"description": "Přihlaste se do mobilní aplikace Bulwark Mail bez psaní. Vygenerujte zde QR kód a naskenujte jej na přihlašovací obrazovce aplikace.",
"description": "Přihlaste se do aplikace Bulwark Mail v telefonu, aniž byste tam museli zadávat heslo. Telefon dostane vlastní přihlášení, takže odhlášení zde ho neodhlásí.",
"generate": "Zobrazit QR kód",
"regenerate": "Zobrazit nový kód",
"instructions": "Otevřete aplikaci Bulwark Mail, na přihlašovací obrazovce klepněte na \"Naskenovat QR kód\" a namiřte fotoaparát sem.",
"instructions": "Otevřete aplikaci Bulwark Mail a na přihlašovací obrazovce klepněte na \"Scan a sign-in code\" (nebo na \"Add account\" → \"Scan a sign-in code\", pokud jste už přihlášeni) a pak namiřte fotoaparát sem.",
"expires_in": "Tento kód vyprší za {seconds} sekund. Lze jej použít pouze jednou.",
"expired": "Platnost tohoto kódu vypršela.",
"generating": "Generování…",
"error": "Párovací kód se nepodařilo vytvořit. Zkuste to prosím znovu."
"error": "Párovací kód se nepodařilo vytvořit. Zkuste to prosím znovu.",
"password_prompt": "Zadejte heslo a potvrďte, že jste to vy. Telefon dostane vlastní přihlášení; vaše heslo se do telefonu neposílá.",
"show_password": "Zobrazit heslo",
"hide_password": "Skrýt heslo",
"continue": "Pokračovat",
"error_wrong_password": "Nesprávné heslo. Zkuste to znovu.",
"totp_prompt": "Zadejte kód z ověřovací aplikace.",
"error_password_or_code": "Heslo nebo kód je nesprávný.",
"error_not_signed_in": "Vaše relace vypršela. Pro propojení zařízení se znovu přihlaste.",
"error_impersonation": "Zařízení nelze propojit, když si prohlížíte schránku jiného uživatele.",
"error_too_many_attempts": "Příliš mnoho pokusů. Chvíli počkejte a zkuste to znovu.",
"error_session_secret": "Propojení zařízení není na tomto serveru nastaveno (chybí SESSION_SECRET). Obraťte se na správce.",
"error_server_unreachable": "Nepodařilo se spojit s vaším poštovním serverem. Zkuste to znovu.",
"error_pairing_unavailable": "Váš poštovní server neumožňuje propojit zařízení tímto způsobem. Obraťte se na správce.",
"qr_alt": "QR kód pro přihlášení",
"link_label": "Přihlašovací odkaz",
"copy_link": "Kopírovat přihlašovací odkaz",
"copied": "Zkopírováno",
"copy_hint": "Nemáte fotoaparát? Zkopírujte odkaz a vložte ho do aplikace pomocí \"Paste sign-in link\".",
"success_title": "Přihlášeno v telefonu",
"success_body": "Tuto stránku můžete zavřít. Chcete-li propojit další zařízení, zobrazte nový kód.",
"link_another": "Propojit další zařízení",
"use_sso": "Použít místo toho jednotné přihlášení"
},
"public_keys": {
"name_label": "Název veřejného klíče",
+25 -4
View File
@@ -1720,14 +1720,35 @@
},
"link_device": {
"title": "Tilknyt mobilapp",
"description": "Log ind i Bulwark Mail-mobilappen uden at skrive noget. Generér en QR-kode her, og scan den fra appens loginskærm.",
"description": "Log ind i Bulwark Mail-appen på din telefon uden at skrive din adgangskode der. Din telefon får sit eget login, så den forbliver logget ind, når du logger ud her.",
"generate": "Vis QR-kode",
"regenerate": "Vis en ny kode",
"instructions": "Åbn Bulwark Mail-appen, tryk på \"Scan QR-kode\" på loginskærmen, og ret dit kamera herhen.",
"instructions": "Åbn Bulwark Mail-appen, og tryk på \"Scan a sign-in code\" på loginskærmen (eller \"Add account\" → \"Scan a sign-in code\", hvis du allerede er logget ind), og ret derefter kameraet herhen.",
"expires_in": "Denne kode udløber om {seconds} sekunder. Den kan kun bruges én gang.",
"expired": "Denne kode er udløbet.",
"generating": "Genererer…",
"error": "Kunne ikke oprette en parringskode. Prøv igen."
"error": "Kunne ikke oprette en parringskode. Prøv igen.",
"password_prompt": "Indtast din adgangskode for at bekræfte, at det er dig. Din telefon får sit eget login; din adgangskode sendes ikke til telefonen.",
"show_password": "Vis adgangskode",
"hide_password": "Skjul adgangskode",
"continue": "Fortsæt",
"error_wrong_password": "Forkert adgangskode. Prøv igen.",
"totp_prompt": "Indtast koden fra din godkendelsesapp.",
"error_password_or_code": "Adgangskoden eller koden er forkert.",
"error_not_signed_in": "Din session er udløbet. Log ind igen for at tilknytte en enhed.",
"error_impersonation": "Enheder kan ikke tilknyttes, mens du ser en anden brugers postkasse.",
"error_too_many_attempts": "For mange forsøg. Vent et øjeblik, og prøv igen.",
"error_session_secret": "Tilknytning af enheder er ikke sat op på denne server (SESSION_SECRET mangler). Kontakt din administrator.",
"error_server_unreachable": "Kunne ikke få forbindelse til din mailserver. Prøv igen.",
"error_pairing_unavailable": "Din mailserver tillader ikke tilknytning af enheder på denne måde. Kontakt din administrator.",
"qr_alt": "QR-kode til login",
"link_label": "Loginlink",
"copy_link": "Kopiér loginlink",
"copied": "Kopieret",
"copy_hint": "Intet kamera? Kopiér linket, og indsæt det i appen med \"Paste sign-in link\".",
"success_title": "Logget ind på din telefon",
"success_body": "Du kan lukke denne side. Vis en ny kode for at tilknytte en anden enhed.",
"link_another": "Tilknyt en anden enhed",
"use_sso": "Brug single sign-on i stedet"
},
"public_keys": {
"name_label": "Navn på offentlig nøgle",
+25 -4
View File
@@ -1717,14 +1717,35 @@
},
"link_device": {
"title": "Mobile App verknüpfen",
"description": "Melden Sie sich in der Bulwark Mail App an, ohne etwas einzutippen. Erzeugen Sie hier einen QR-Code und scannen Sie ihn auf dem Anmeldebildschirm der App.",
"description": "Melden Sie sich in der Bulwark Mail App auf Ihrem Smartphone an, ohne dort Ihr Passwort einzugeben. Ihr Smartphone erhält eine eigene Anmeldung – wenn Sie sich hier abmelden, bleibt es angemeldet.",
"generate": "QR-Code anzeigen",
"regenerate": "Neuen Code anzeigen",
"instructions": "Öffnen Sie die Bulwark Mail App, tippen Sie auf dem Anmeldebildschirm auf \"QR-Code scannen\" und richten Sie die Kamera hierauf.",
"instructions": "Öffnen Sie die Bulwark Mail App und tippen Sie auf dem Anmeldebildschirm auf \"Scan a sign-in code\" (oder auf \"Add account\" → \"Scan a sign-in code\", wenn Sie bereits angemeldet sind). Richten Sie dann die Kamera hierauf.",
"expires_in": "Dieser Code läuft in {seconds} Sekunden ab. Er kann nur einmal verwendet werden.",
"expired": "Dieser Code ist abgelaufen.",
"generating": "Wird erstellt…",
"error": "Kopplungscode konnte nicht erstellt werden. Bitte versuchen Sie es erneut."
"error": "Kopplungscode konnte nicht erstellt werden. Bitte versuchen Sie es erneut.",
"password_prompt": "Geben Sie zur Bestätigung Ihr Passwort ein. Ihr Smartphone erhält eine eigene Anmeldung; Ihr Passwort wird nicht an das Smartphone übertragen.",
"show_password": "Passwort anzeigen",
"hide_password": "Passwort verbergen",
"continue": "Weiter",
"error_wrong_password": "Falsches Passwort. Bitte versuchen Sie es erneut.",
"totp_prompt": "Geben Sie den Code aus Ihrer Authenticator-App ein.",
"error_password_or_code": "Das Passwort oder der Code ist falsch.",
"error_not_signed_in": "Ihre Sitzung ist abgelaufen. Melden Sie sich erneut an, um ein Gerät zu verknüpfen.",
"error_impersonation": "Geräte können nicht verknüpft werden, während Sie das Postfach eines anderen Benutzers ansehen.",
"error_too_many_attempts": "Zu viele Versuche. Warten Sie einen Moment und versuchen Sie es erneut.",
"error_session_secret": "Das Verknüpfen von Geräten ist auf diesem Server nicht eingerichtet (SESSION_SECRET fehlt). Wenden Sie sich an Ihren Administrator.",
"error_server_unreachable": "Ihr Mailserver ist nicht erreichbar. Bitte versuchen Sie es erneut.",
"error_pairing_unavailable": "Ihr Mailserver erlaubt das Verknüpfen von Geräten auf diese Weise nicht. Wenden Sie sich an Ihren Administrator.",
"qr_alt": "QR-Code zur Anmeldung",
"link_label": "Anmeldelink",
"copy_link": "Anmeldelink kopieren",
"copied": "Kopiert",
"copy_hint": "Keine Kamera? Kopieren Sie den Link und fügen Sie ihn in der App über \"Paste sign-in link\" ein.",
"success_title": "Auf Ihrem Smartphone angemeldet",
"success_body": "Sie können diese Seite schließen. Um ein weiteres Gerät zu verknüpfen, lassen Sie einen neuen Code anzeigen.",
"link_another": "Weiteres Gerät verknüpfen",
"use_sso": "Stattdessen Single Sign-On verwenden"
},
"public_keys": {
"name_label": "Name des öffentlichen Schlüssels",
+25 -4
View File
@@ -1731,14 +1731,35 @@
},
"link_device": {
"title": "Link Mobile App",
"description": "Sign in to the Bulwark Mail mobile app without typing anything. Generate a QR code here and scan it from the app's login screen.",
"description": "Sign in to the Bulwark Mail app on your phone without typing your password there. Your phone gets its own sign-in, so signing out here doesn't sign it out.",
"generate": "Show QR code",
"regenerate": "Show a new code",
"instructions": "Open the Bulwark Mail app, tap \"Scan QR code\" on the login screen, and point your camera here.",
"instructions": "Open the Bulwark Mail app and tap \"Scan a sign-in code\" on the sign-in screen (or \"Add account\" → \"Scan a sign-in code\" if you're already signed in), then point the camera here.",
"expires_in": "This code expires in {seconds} seconds. It can only be used once.",
"expired": "This code has expired.",
"generating": "Generating…",
"error": "Couldn't create a pairing code. Please try again."
"error": "Couldn't create a pairing code. Please try again.",
"password_prompt": "Enter your password to confirm it's you. Your phone gets its own sign-in; your password is not sent to the phone.",
"show_password": "Show password",
"hide_password": "Hide password",
"continue": "Continue",
"error_wrong_password": "Wrong password. Try again.",
"totp_prompt": "Enter the code from your authenticator app.",
"error_password_or_code": "The password or code is incorrect.",
"error_not_signed_in": "Your session has expired. Sign in again to link a device.",
"error_impersonation": "Devices can't be linked while you're viewing another user's mailbox.",
"error_too_many_attempts": "Too many attempts. Wait a moment and try again.",
"error_session_secret": "Linking a device isn't set up on this server (SESSION_SECRET is missing). Ask your administrator.",
"error_server_unreachable": "Couldn't reach your mail server. Try again.",
"error_pairing_unavailable": "Your mail server doesn't allow linking a device this way. Ask your administrator.",
"qr_alt": "Sign-in QR code",
"link_label": "Sign-in link",
"copy_link": "Copy sign-in link",
"copied": "Copied",
"copy_hint": "No camera? Copy the link and paste it in the app with \"Paste sign-in link\".",
"success_title": "Signed in on your phone",
"success_body": "You can close this page. To link another device, show a new code.",
"link_another": "Link another device",
"use_sso": "Use single sign-on instead"
}
},
"identities": {
+25 -4
View File
@@ -1717,14 +1717,35 @@
},
"link_device": {
"title": "Vincular aplicación móvil",
"description": "Inicie sesión en la aplicación móvil de Bulwark Mail sin escribir nada. Genere un código QR aquí y escanéelo desde la pantalla de inicio de sesión de la aplicación.",
"description": "Inicie sesión en la aplicación Bulwark Mail de su teléfono sin escribir allí su contraseña. Su teléfono obtiene su propio inicio de sesión, así que cerrar sesión aquí no la cierra en él.",
"generate": "Mostrar código QR",
"regenerate": "Mostrar un código nuevo",
"instructions": "Abra la aplicación Bulwark Mail, toque \"Escanear código QR\" en la pantalla de inicio de sesión y apunte su cámara aquí.",
"instructions": "Abra la aplicación Bulwark Mail y toque \"Scan a sign-in code\" en la pantalla de inicio de sesión (o \"Add account\" → \"Scan a sign-in code\" si ya ha iniciado sesión) y, a continuación, apunte la cámara aquí.",
"expires_in": "Este código caduca en {seconds} segundos. Solo se puede usar una vez.",
"expired": "Este código ha caducado.",
"generating": "Generando…",
"error": "No se pudo crear un código de vinculación. Inténtelo de nuevo."
"error": "No se pudo crear un código de vinculación. Inténtelo de nuevo.",
"password_prompt": "Introduzca su contraseña para confirmar que es usted. Su teléfono obtiene su propio inicio de sesión; su contraseña no se envía al teléfono.",
"show_password": "Mostrar contraseña",
"hide_password": "Ocultar contraseña",
"continue": "Continuar",
"error_wrong_password": "Contraseña incorrecta. Inténtelo de nuevo.",
"totp_prompt": "Introduzca el código de su aplicación de autenticación.",
"error_password_or_code": "La contraseña o el código son incorrectos.",
"error_not_signed_in": "Su sesión ha caducado. Vuelva a iniciar sesión para vincular un dispositivo.",
"error_impersonation": "No se pueden vincular dispositivos mientras consulta el buzón de otro usuario.",
"error_too_many_attempts": "Demasiados intentos. Espere un momento e inténtelo de nuevo.",
"error_session_secret": "La vinculación de dispositivos no está configurada en este servidor (falta SESSION_SECRET). Consulte a su administrador.",
"error_server_unreachable": "No se pudo conectar con su servidor de correo. Inténtelo de nuevo.",
"error_pairing_unavailable": "Su servidor de correo no permite vincular dispositivos de esta forma. Consulte a su administrador.",
"qr_alt": "Código QR de inicio de sesión",
"link_label": "Enlace de inicio de sesión",
"copy_link": "Copiar enlace de inicio de sesión",
"copied": "Copiado",
"copy_hint": "¿No tiene cámara? Copie el enlace y péguelo en la aplicación con \"Paste sign-in link\".",
"success_title": "Sesión iniciada en su teléfono",
"success_body": "Puede cerrar esta página. Para vincular otro dispositivo, muestre un código nuevo.",
"link_another": "Vincular otro dispositivo",
"use_sso": "Usar el inicio de sesión único"
},
"public_keys": {
"name_label": "Nombre de la clave pública",
+25 -4
View File
@@ -1724,14 +1724,35 @@
},
"link_device": {
"title": "اتصال موبایل",
"description": "ورود با QR code",
"description": "بدون وارد کردن رمز عبور در گوشی، به برنامه Bulwark Mail وارد شوید. گوشی شما ورود جداگانه‌ای دریافت می‌کند، بنابراین خروج از اینجا باعث خروج آن نمی‌شود.",
"generate": "نمایش QR",
"regenerate": "کد جدید",
"instructions": "با برنامه اسکن کنید",
"instructions": "برنامه Bulwark Mail را باز کنید و در صفحه ورود روی \"Scan a sign-in code\" بزنید (یا اگر از قبل وارد شده‌اید، \"Add account\" → \"Scan a sign-in code\")، سپس دوربین را به این سمت بگیرید.",
"expires_in": "منقضی در {seconds} ثانیه",
"expired": "منقضی شده",
"generating": "در حال تولید…",
"error": "خطا"
"error": "خطا",
"password_prompt": "برای تأیید هویت، رمز عبور خود را وارد کنید. گوشی شما ورود جداگانه‌ای دریافت می‌کند؛ رمز عبور شما به گوشی ارسال نمی‌شود.",
"show_password": "نمایش رمز عبور",
"hide_password": "پنهان کردن رمز عبور",
"continue": "ادامه",
"error_wrong_password": "رمز عبور نادرست است. دوباره تلاش کنید.",
"totp_prompt": "کد برنامه احراز هویت خود را وارد کنید.",
"error_password_or_code": "رمز عبور یا کد نادرست است.",
"error_not_signed_in": "نشست شما منقضی شده است. برای اتصال دستگاه دوباره وارد شوید.",
"error_impersonation": "هنگام مشاهده صندوق پستی کاربر دیگر نمی‌توان دستگاه متصل کرد.",
"error_too_many_attempts": "تلاش‌های بیش از حد. کمی صبر کنید و دوباره تلاش کنید.",
"error_session_secret": "اتصال دستگاه روی این سرور راه‌اندازی نشده است (SESSION_SECRET وجود ندارد). با مدیر سیستم تماس بگیرید.",
"error_server_unreachable": "دسترسی به سرور ایمیل شما ممکن نشد. دوباره تلاش کنید.",
"error_pairing_unavailable": "سرور ایمیل شما اجازه اتصال دستگاه به این روش را نمی‌دهد. با مدیر سیستم تماس بگیرید.",
"qr_alt": "کد QR ورود",
"link_label": "پیوند ورود",
"copy_link": "کپی پیوند ورود",
"copied": "کپی شد",
"copy_hint": "دوربین ندارید؟ پیوند را کپی کنید و در برنامه با \"Paste sign-in link\" جای‌گذاری کنید.",
"success_title": "ورود در گوشی شما انجام شد",
"success_body": "می‌توانید این صفحه را ببندید. برای اتصال دستگاه دیگر، کد جدیدی نمایش دهید.",
"link_another": "اتصال دستگاه دیگر",
"use_sso": "به‌جای آن از ورود یکپارچه استفاده کنید"
},
"public_keys": {
"name_label": "نام کلید عمومی",
+25 -4
View File
@@ -1717,14 +1717,35 @@
},
"link_device": {
"title": "Associer l'application mobile",
"description": "Connectez-vous à l'application mobile Bulwark Mail sans rien saisir. Générez un QR code ici et scannez-le depuis l'écran de connexion de l'application.",
"description": "Connectez-vous à l'application Bulwark Mail sur votre téléphone sans y saisir votre mot de passe. Votre téléphone dispose de sa propre connexion : vous déconnecter ici ne le déconnecte pas.",
"generate": "Afficher le QR code",
"regenerate": "Afficher un nouveau code",
"instructions": "Ouvrez l'application Bulwark Mail, appuyez sur \"Scanner le QR code\" sur l'écran de connexion et pointez votre caméra ici.",
"instructions": "Ouvrez l'application Bulwark Mail et appuyez sur \"Scan a sign-in code\" sur l'écran de connexion (ou sur \"Add account\" → \"Scan a sign-in code\" si vous êtes déjà connecté), puis pointez la caméra ici.",
"expires_in": "Ce code expire dans {seconds} secondes. Il ne peut être utilisé qu'une seule fois.",
"expired": "Ce code a expiré.",
"generating": "Génération…",
"error": "Impossible de créer un code d'association. Veuillez réessayer."
"error": "Impossible de créer un code d'association. Veuillez réessayer.",
"password_prompt": "Saisissez votre mot de passe pour confirmer votre identité. Votre téléphone dispose de sa propre connexion ; votre mot de passe ne lui est pas envoyé.",
"show_password": "Afficher le mot de passe",
"hide_password": "Masquer le mot de passe",
"continue": "Continuer",
"error_wrong_password": "Mot de passe incorrect. Veuillez réessayer.",
"totp_prompt": "Saisissez le code de votre application d'authentification.",
"error_password_or_code": "Le mot de passe ou le code est incorrect.",
"error_not_signed_in": "Votre session a expiré. Reconnectez-vous pour associer un appareil.",
"error_impersonation": "Impossible d'associer un appareil pendant que vous consultez la boîte aux lettres d'un autre utilisateur.",
"error_too_many_attempts": "Trop de tentatives. Patientez un instant, puis réessayez.",
"error_session_secret": "L'association d'appareils n'est pas configurée sur ce serveur (SESSION_SECRET manquant). Contactez votre administrateur.",
"error_server_unreachable": "Impossible de joindre votre serveur de messagerie. Veuillez réessayer.",
"error_pairing_unavailable": "Votre serveur de messagerie ne permet pas d'associer un appareil de cette façon. Contactez votre administrateur.",
"qr_alt": "QR code de connexion",
"link_label": "Lien de connexion",
"copy_link": "Copier le lien de connexion",
"copied": "Copié",
"copy_hint": "Pas de caméra ? Copiez le lien et collez-le dans l'application avec \"Paste sign-in link\".",
"success_title": "Connecté sur votre téléphone",
"success_body": "Vous pouvez fermer cette page. Pour associer un autre appareil, affichez un nouveau code.",
"link_another": "Associer un autre appareil",
"use_sso": "Utiliser plutôt l'authentification unique"
},
"public_keys": {
"name_label": "Nom de la clé publique",
+25 -4
View File
@@ -1682,14 +1682,35 @@
},
"link_device": {
"title": "הקשר יישום נייד",
"description": "היכנס ליישום הנייד Bulwark Mail ללא הקלדת דבר. צור קוד QR כאן וסרוק אותו מהמסך ההתחברות של היישום.",
"description": "התחבר לאפליקציית Bulwark Mail בטלפון בלי להקליד שם את הסיסמה. הטלפון מקבל התחברות משלו, כך שהתנתקות כאן לא מנתקת אותו.",
"generate": "הצג קוד QR",
"regenerate": "הצג קוד חדש",
"instructions": "פתח את אפליקציית Bulwark Mail, לחץ ״סרוק קוד QR״ במסך ההתחברות, וכיוון את המצלמה שלך לכאן.",
"instructions": "פתח את אפליקציית Bulwark Mail והקש על \"Scan a sign-in code\" במסך ההתחברות (או \"Add account\" → \"Scan a sign-in code\" אם כבר התחברת), ואז כוון את המצלמה לכאן.",
"expires_in": "קוד זה פוקע בעוד {seconds} שניות. ניתן להשתמש בו רק פעם אחת.",
"expired": "קוד זה פקע.",
"generating": "יצירה…",
"error": "לא היה ניתן ליצור קוד זיווג. אנא נסה שוב."
"error": "לא היה ניתן ליצור קוד זיווג. אנא נסה שוב.",
"password_prompt": "הזן את הסיסמה כדי לאשר שזה אתה. הטלפון מקבל התחברות משלו; הסיסמה שלך לא נשלחת לטלפון.",
"show_password": "הצג סיסמה",
"hide_password": "הסתר סיסמה",
"continue": "המשך",
"error_wrong_password": "סיסמה שגויה. נסה שוב.",
"totp_prompt": "הזן את הקוד מאפליקציית האימות.",
"error_password_or_code": "הסיסמה או הקוד שגויים.",
"error_not_signed_in": "פג תוקף ההפעלה שלך. התחבר מחדש כדי לקשר מכשיר.",
"error_impersonation": "לא ניתן לקשר מכשירים בזמן צפייה בתיבת הדואר של משתמש אחר.",
"error_too_many_attempts": "יותר מדי ניסיונות. המתן רגע ונסה שוב.",
"error_session_secret": "קישור מכשירים לא הוגדר בשרת זה (SESSION_SECRET חסר). פנה למנהל המערכת.",
"error_server_unreachable": "לא ניתן להתחבר לשרת הדואר שלך. נסה שוב.",
"error_pairing_unavailable": "שרת הדואר שלך לא מאפשר לקשר מכשיר בדרך זו. פנה למנהל המערכת.",
"qr_alt": "קוד QR להתחברות",
"link_label": "קישור התחברות",
"copy_link": "העתק קישור התחברות",
"copied": "הועתק",
"copy_hint": "אין מצלמה? העתק את הקישור והדבק אותו באפליקציה באמצעות \"Paste sign-in link\".",
"success_title": "ההתחברות בטלפון הושלמה",
"success_body": "אפשר לסגור את הדף הזה. כדי לקשר מכשיר נוסף, הצג קוד חדש.",
"link_another": "קשר מכשיר נוסף",
"use_sso": "השתמש בכניסה יחידה במקום"
},
"public_keys": {
"name_label": "שם המפתח הציבורי",
+25 -4
View File
@@ -1720,14 +1720,35 @@
},
"link_device": {
"title": "Mobilalkalmazás összekapcsolása",
"description": "Jelentkezz be a Bulwark Mail mobilalkalmazásba gépelés nélkül. Generálj itt egy QR-kódot, és olvasd be az alkalmazás bejelentkezési képernyőjén.",
"description": "Jelentkezz be a telefonodon a Bulwark Mail alkalmazásba anélkül, hogy ott beírnád a jelszavad. A telefonod saját bejelentkezést kap, így ha itt kijelentkezel, a telefon bejelentkezve marad.",
"generate": "QR-kód megjelenítése",
"regenerate": "Új kód megjelenítése",
"instructions": "Nyisd meg a Bulwark Mail alkalmazást, koppints a \"QR-kód beolvasása\" lehetőségre a bejelentkezési képernyőn, és irányítsd ide a kamerát.",
"instructions": "Nyisd meg a Bulwark Mail alkalmazást, és a bejelentkezési képernyőn koppints a \"Scan a sign-in code\" lehetőségre (vagy az \"Add account\" → \"Scan a sign-in code\" lehetőségre, ha már be vagy jelentkezve), majd irányítsd ide a kamerát.",
"expires_in": "Ez a kód {seconds} másodperc múlva lejár. Csak egyszer használható.",
"expired": "Ez a kód lejárt.",
"generating": "Generálás…",
"error": "Nem sikerült párosítási kódot létrehozni. Próbáld újra."
"error": "Nem sikerült párosítási kódot létrehozni. Próbáld újra.",
"password_prompt": "Add meg a jelszavad, hogy megerősítsd, te vagy az. A telefonod saját bejelentkezést kap; a jelszavad nem kerül át a telefonra.",
"show_password": "Jelszó megjelenítése",
"hide_password": "Jelszó elrejtése",
"continue": "Tovább",
"error_wrong_password": "Hibás jelszó. Próbáld újra.",
"totp_prompt": "Add meg a hitelesítő alkalmazásodban látható kódot.",
"error_password_or_code": "A jelszó vagy a kód hibás.",
"error_not_signed_in": "A munkameneted lejárt. Jelentkezz be újra az eszköz összekapcsolásához.",
"error_impersonation": "Nem kapcsolhatsz össze eszközt, amíg egy másik felhasználó postafiókját nézed.",
"error_too_many_attempts": "Túl sok próbálkozás. Várj egy kicsit, és próbáld újra.",
"error_session_secret": "Az eszközök összekapcsolása nincs beállítva ezen a szerveren (hiányzik a SESSION_SECRET). Fordulj a rendszergazdához.",
"error_server_unreachable": "Nem sikerült elérni a levelezőszerveredet. Próbáld újra.",
"error_pairing_unavailable": "A levelezőszervered nem teszi lehetővé az eszközök ilyen módon történő összekapcsolását. Fordulj a rendszergazdához.",
"qr_alt": "Bejelentkezési QR-kód",
"link_label": "Bejelentkezési hivatkozás",
"copy_link": "Bejelentkezési hivatkozás másolása",
"copied": "Másolva",
"copy_hint": "Nincs kamerád? Másold ki a hivatkozást, és illeszd be az alkalmazásban a \"Paste sign-in link\" lehetőséggel.",
"success_title": "Bejelentkezve a telefonodon",
"success_body": "Bezárhatod ezt az oldalt. Másik eszköz összekapcsolásához jeleníts meg egy új kódot.",
"link_another": "Másik eszköz összekapcsolása",
"use_sso": "Egyszeri bejelentkezés használata helyette"
},
"public_keys": {
"name_label": "Nyilvános kulcs neve",
+25 -4
View File
@@ -1717,14 +1717,35 @@
},
"link_device": {
"title": "Collega l'app mobile",
"description": "Accedi all'app mobile Bulwark Mail senza digitare nulla. Genera qui un codice QR e scansionalo dalla schermata di accesso dell'app.",
"description": "Accedi all'app Bulwark Mail sul telefono senza digitarvi la password. Il telefono ottiene un proprio accesso, quindi disconnetterti qui non lo disconnette.",
"generate": "Mostra codice QR",
"regenerate": "Mostra un nuovo codice",
"instructions": "Apri l'app Bulwark Mail, tocca \"Scansiona codice QR\" nella schermata di accesso e inquadra qui con la fotocamera.",
"instructions": "Apri l'app Bulwark Mail e tocca \"Scan a sign-in code\" nella schermata di accesso (oppure \"Add account\" → \"Scan a sign-in code\" se hai già effettuato l'accesso), poi inquadra qui con la fotocamera.",
"expires_in": "Questo codice scade tra {seconds} secondi. Può essere usato una sola volta.",
"expired": "Questo codice è scaduto.",
"generating": "Generazione…",
"error": "Impossibile creare un codice di associazione. Riprova."
"error": "Impossibile creare un codice di associazione. Riprova.",
"password_prompt": "Inserisci la password per confermare la tua identità. Il telefono ottiene un proprio accesso; la password non viene inviata al telefono.",
"show_password": "Mostra password",
"hide_password": "Nascondi password",
"continue": "Continua",
"error_wrong_password": "Password errata. Riprova.",
"totp_prompt": "Inserisci il codice dell'app di autenticazione.",
"error_password_or_code": "La password o il codice non sono corretti.",
"error_not_signed_in": "La sessione è scaduta. Accedi di nuovo per collegare un dispositivo.",
"error_impersonation": "Non puoi collegare dispositivi mentre visualizzi la casella di posta di un altro utente.",
"error_too_many_attempts": "Troppi tentativi. Attendi un momento e riprova.",
"error_session_secret": "Il collegamento dei dispositivi non è configurato su questo server (manca SESSION_SECRET). Rivolgiti al tuo amministratore.",
"error_server_unreachable": "Impossibile raggiungere il server di posta. Riprova.",
"error_pairing_unavailable": "Il tuo server di posta non consente di collegare un dispositivo in questo modo. Rivolgiti al tuo amministratore.",
"qr_alt": "Codice QR di accesso",
"link_label": "Link di accesso",
"copy_link": "Copia link di accesso",
"copied": "Copiato",
"copy_hint": "Niente fotocamera? Copia il link e incollalo nell'app con \"Paste sign-in link\".",
"success_title": "Accesso effettuato sul telefono",
"success_body": "Puoi chiudere questa pagina. Per collegare un altro dispositivo, mostra un nuovo codice.",
"link_another": "Collega un altro dispositivo",
"use_sso": "Usa invece l'accesso unico (SSO)"
},
"public_keys": {
"name_label": "Nome della chiave pubblica",
+25 -4
View File
@@ -1717,14 +1717,35 @@
},
"link_device": {
"title": "モバイルアプリを連携",
"description": "入力なしで Bulwark Mail モバイルアプリにサインインできます。ここで QR コードを生成し、アプリのログイン画面からスキャンしてください。",
"description": "スマートフォンの Bulwark Mail アプリに、そこでパスワードを入力せずにサインインできます。スマートフォンには独自のサインインが発行されるため、ここでサインアウトしてもスマートフォンはサインアウトされません。",
"generate": "QRコードを表示",
"regenerate": "新しいコードを表示",
"instructions": "Bulwark Mail アプリを開き、ログイン画面で「QRコードをスキャン」をタップして、カメラをここに向けてください。",
"instructions": "Bulwark Mail アプリを開き、サインイン画面で「Scan a sign-in code」をタップして(すでにサインインしている場合は「Add account」→「Scan a sign-in code」)、カメラをここに向けてください。",
"expires_in": "このコードは{seconds}秒で期限切れになります。一度しか使用できません。",
"expired": "このコードは期限切れです。",
"generating": "生成中…",
"error": "ペアリングコードを作成できませんでした。もう一度お試しください。"
"error": "ペアリングコードを作成できませんでした。もう一度お試しください。",
"password_prompt": "本人確認のためパスワードを入力してください。スマートフォンには独自のサインインが発行され、パスワードがスマートフォンに送信されることはありません。",
"show_password": "パスワードを表示",
"hide_password": "パスワードを隠す",
"continue": "続行",
"error_wrong_password": "パスワードが正しくありません。もう一度お試しください。",
"totp_prompt": "認証アプリに表示されているコードを入力してください。",
"error_password_or_code": "パスワードまたはコードが正しくありません。",
"error_not_signed_in": "セッションの有効期限が切れました。デバイスを連携するには、もう一度サインインしてください。",
"error_impersonation": "他のユーザーのメールボックスを表示している間は、デバイスを連携できません。",
"error_too_many_attempts": "試行回数が多すぎます。しばらく待ってからもう一度お試しください。",
"error_session_secret": "このサーバーではデバイス連携が設定されていません(SESSION_SECRET がありません)。管理者にお問い合わせください。",
"error_server_unreachable": "メールサーバーに接続できませんでした。もう一度お試しください。",
"error_pairing_unavailable": "お使いのメールサーバーでは、この方法でデバイスを連携できません。管理者にお問い合わせください。",
"qr_alt": "サインイン用 QR コード",
"link_label": "サインインリンク",
"copy_link": "サインインリンクをコピー",
"copied": "コピーしました",
"copy_hint": "カメラがない場合は、リンクをコピーしてアプリの「Paste sign-in link」から貼り付けてください。",
"success_title": "スマートフォンでサインインしました",
"success_body": "このページは閉じてかまいません。別のデバイスを連携するには、新しいコードを表示してください。",
"link_another": "別のデバイスを連携",
"use_sso": "代わりにシングルサインオンを使う"
},
"public_keys": {
"name_label": "公開鍵の名前",
+25 -4
View File
@@ -1717,14 +1717,35 @@
},
"link_device": {
"title": "모바일 앱 연결",
"description": "아무것도 입력하지 않고 Bulwark Mail 모바일 앱에 로그인하세요. 여기에서 QR 코드를 생성하고 앱의 로그인 화면에서 스캔하세요.",
"description": "휴대폰의 Bulwark Mail 앱에 비밀번호를 입력하지 않고 로그인하세요. 휴대폰은 별도의 로그인을 받으므로 여기에서 로그아웃해도 휴대폰은 로그아웃되지 않습니다.",
"generate": "QR 코드 표시",
"regenerate": "새 코드 표시",
"instructions": "Bulwark Mail 앱을 열고 로그인 화면에서 \"QR 코드 스캔\"을 누른 다음 카메라를 여기에 비추세요.",
"instructions": "Bulwark Mail 앱을 열고 로그인 화면에서 \"Scan a sign-in code\"를 누른 다음(이미 로그인되어 있다면 \"Add account\" → \"Scan a sign-in code\") 카메라를 여기에 비추세요.",
"expires_in": "이 코드는 {seconds}초 후에 만료됩니다. 한 번만 사용할 수 있습니다.",
"expired": "이 코드는 만료되었습니다.",
"generating": "생성 중…",
"error": "페어링 코드를 만들지 못했습니다. 다시 시도해 주세요."
"error": "페어링 코드를 만들지 못했습니다. 다시 시도해 주세요.",
"password_prompt": "본인 확인을 위해 비밀번호를 입력하세요. 휴대폰은 별도의 로그인을 받으며, 비밀번호는 휴대폰으로 전송되지 않습니다.",
"show_password": "비밀번호 표시",
"hide_password": "비밀번호 숨기기",
"continue": "계속",
"error_wrong_password": "비밀번호가 올바르지 않습니다. 다시 시도하세요.",
"totp_prompt": "인증 앱에 표시된 코드를 입력하세요.",
"error_password_or_code": "비밀번호 또는 코드가 올바르지 않습니다.",
"error_not_signed_in": "세션이 만료되었습니다. 기기를 연결하려면 다시 로그인하세요.",
"error_impersonation": "다른 사용자의 사서함을 보는 동안에는 기기를 연결할 수 없습니다.",
"error_too_many_attempts": "시도 횟수가 너무 많습니다. 잠시 후 다시 시도하세요.",
"error_session_secret": "이 서버에는 기기 연결이 설정되어 있지 않습니다(SESSION_SECRET 없음). 관리자에게 문의하세요.",
"error_server_unreachable": "메일 서버에 연결할 수 없습니다. 다시 시도하세요.",
"error_pairing_unavailable": "메일 서버에서 이 방법으로 기기를 연결하는 것을 허용하지 않습니다. 관리자에게 문의하세요.",
"qr_alt": "로그인 QR 코드",
"link_label": "로그인 링크",
"copy_link": "로그인 링크 복사",
"copied": "복사됨",
"copy_hint": "카메라가 없나요? 링크를 복사한 다음 앱에서 \"Paste sign-in link\"로 붙여 넣으세요.",
"success_title": "휴대폰에서 로그인되었습니다",
"success_body": "이 페이지를 닫아도 됩니다. 다른 기기를 연결하려면 새 코드를 표시하세요.",
"link_another": "다른 기기 연결",
"use_sso": "대신 SSO(통합 로그인) 사용"
},
"public_keys": {
"name_label": "공개 키 이름",
+25 -4
View File
@@ -1717,14 +1717,35 @@
},
"link_device": {
"title": "Saistīt mobilo lietotni",
"description": "Pierakstieties Bulwark Mail mobilajā lietotnē, neko nerakstot. Šeit izveidojiet QR kodu un noskenējiet to lietotnes pierakstīšanās ekrānā.",
"description": "Pierakstieties Bulwark Mail lietotnē savā tālrunī, tur neievadot paroli. Tālrunis saņem savu pierakstīšanos, tāpēc izrakstīšanās šeit to neizraksta.",
"generate": "Rādīt QR kodu",
"regenerate": "Rādīt jaunu kodu",
"instructions": "Atveriet Bulwark Mail lietotni, pierakstīšanās ekrānā pieskarieties \"Skenēt QR kodu\" un pavērsiet kameru šeit.",
"instructions": "Atveriet Bulwark Mail lietotni un pierakstīšanās ekrānā pieskarieties \"Scan a sign-in code\" (vai \"Add account\" → \"Scan a sign-in code\", ja jau esat pierakstījies), pēc tam pavērsiet kameru šeit.",
"expires_in": "Šī koda derīgums beigsies pēc {seconds} sekundēm. To var izmantot tikai vienu reizi.",
"expired": "Šī koda derīgums ir beidzies.",
"generating": "Ģenerē…",
"error": "Neizdevās izveidot pārošanas kodu. Lūdzu, mēģiniet vēlreiz."
"error": "Neizdevās izveidot pārošanas kodu. Lūdzu, mēģiniet vēlreiz.",
"password_prompt": "Ievadiet paroli, lai apstiprinātu savu identitāti. Tālrunis saņem savu pierakstīšanos; jūsu parole uz tālruni netiek sūtīta.",
"show_password": "Rādīt paroli",
"hide_password": "Slēpt paroli",
"continue": "Turpināt",
"error_wrong_password": "Nepareiza parole. Mēģiniet vēlreiz.",
"totp_prompt": "Ievadiet kodu no autentifikācijas lietotnes.",
"error_password_or_code": "Parole vai kods ir nepareizs.",
"error_not_signed_in": "Jūsu sesija ir beigusies. Pierakstieties vēlreiz, lai saistītu ierīci.",
"error_impersonation": "Ierīces nevar saistīt, kamēr skatāt cita lietotāja pastkasti.",
"error_too_many_attempts": "Pārāk daudz mēģinājumu. Brīdi uzgaidiet un mēģiniet vēlreiz.",
"error_session_secret": "Ierīču saistīšana šajā serverī nav iestatīta (trūkst SESSION_SECRET). Sazinieties ar administratoru.",
"error_server_unreachable": "Neizdevās sazināties ar jūsu pasta serveri. Mēģiniet vēlreiz.",
"error_pairing_unavailable": "Jūsu pasta serveris neļauj šādā veidā saistīt ierīces. Sazinieties ar administratoru.",
"qr_alt": "Pierakstīšanās QR kods",
"link_label": "Pierakstīšanās saite",
"copy_link": "Kopēt pierakstīšanās saiti",
"copied": "Nokopēts",
"copy_hint": "Nav kameras? Nokopējiet saiti un ielīmējiet to lietotnē, izmantojot \"Paste sign-in link\".",
"success_title": "Pierakstīšanās tālrunī izdevās",
"success_body": "Varat aizvērt šo lapu. Lai saistītu citu ierīci, parādiet jaunu kodu.",
"link_another": "Saistīt citu ierīci",
"use_sso": "Tā vietā izmantot vienoto pieteikšanos"
},
"public_keys": {
"name_label": "Publiskās atslēgas nosaukums",
+25 -4
View File
@@ -1731,14 +1731,35 @@
},
"link_device": {
"title": "Мобайл програмыг холбоно уу",
"description": "Юу ч бичихгүйгээр Bulwark Mail гар утасны програмд нэвтэрнэ үү. Энд QR код үүсгээд програмын нэвтрэх дэлгэцээс скан хийнэ үү.",
"description": "Утсан дээрх Bulwark Mail програмд нууц үгээ тэнд бичихгүйгээр нэвтэрнэ үү. Утас тань өөрийн гэсэн нэвтрэлттэй болох тул эндээс гарахад утаснаас гарахгүй.",
"generate": "QR код харуулах",
"regenerate": "Шинэ код харуулах",
"instructions": "Bulwark Mail програмыг нээгээд, нэвтрэх дэлгэцэн дээрх \"QR кодыг скан\" гэснийг товшоод камераа энд чиглүүлээрэй.",
"instructions": "Bulwark Mail програмыг нээгээд нэвтрэх дэлгэц дээрх \"Scan a sign-in code\" гэснийг товшино уу (хэрэв аль хэдийн нэвтэрсэн бол \"Add account\" → \"Scan a sign-in code\"), дараа нь камераа энд чиглүүлнэ үү.",
"expires_in": "Энэ кодын хугацаа {seconds} секундын дотор дуусна. Үүнийг зөвхөн нэг удаа ашиглах боломжтой.",
"expired": "Энэ кодын хугацаа дууссан.",
"generating": "Үйлдвэрлэж байна...",
"error": "Хослуулах код үүсгэж чадсангүй. Дахин оролдоно уу."
"error": "Хослуулах код үүсгэж чадсангүй. Дахин оролдоно уу.",
"password_prompt": "Та өөрөө гэдгээ баталгаажуулахын тулд нууц үгээ оруулна уу. Утас тань өөрийн гэсэн нэвтрэлттэй болно; таны нууц үг утас руу илгээгдэхгүй.",
"show_password": "Нууц үгийг харуулах",
"hide_password": "Нууц үгийг нуух",
"continue": "Үргэлжлүүлэх",
"error_wrong_password": "Нууц үг буруу байна. Дахин оролдоно уу.",
"totp_prompt": "Баталгаажуулах програм дахь кодыг оруулна уу.",
"error_password_or_code": "Нууц үг эсвэл код буруу байна.",
"error_not_signed_in": "Таны сессийн хугацаа дууссан. Төхөөрөмж холбохын тулд дахин нэвтэрнэ үү.",
"error_impersonation": "Өөр хэрэглэгчийн шуудангийн хайрцгийг үзэж байх үед төхөөрөмж холбох боломжгүй.",
"error_too_many_attempts": "Хэт олон оролдлого хийлээ. Түр хүлээгээд дахин оролдоно уу.",
"error_session_secret": "Энэ сервер дээр төхөөрөмж холбох тохиргоо хийгдээгүй байна (SESSION_SECRET алга). Администратортоо хандана уу.",
"error_server_unreachable": "Таны шуудангийн серверт холбогдож чадсангүй. Дахин оролдоно уу.",
"error_pairing_unavailable": "Таны шуудангийн сервер ийм аргаар төхөөрөмж холбохыг зөвшөөрдөггүй. Администратортоо хандана уу.",
"qr_alt": "Нэвтрэх QR код",
"link_label": "Нэвтрэх холбоос",
"copy_link": "Нэвтрэх холбоосыг хуулах",
"copied": "Хуулсан",
"copy_hint": "Камер байхгүй юу? Холбоосыг хуулаад програмын \"Paste sign-in link\" хэсэгт буулгана уу.",
"success_title": "Утсан дээрээ нэвтэрлээ",
"success_body": "Та энэ хуудсыг хааж болно. Өөр төхөөрөмж холбохын тулд шинэ код харуулна уу.",
"link_another": "Өөр төхөөрөмж холбох",
"use_sso": "Оронд нь нэгдсэн нэвтрэлт ашиглах"
}
},
"identities": {
+25 -4
View File
@@ -1731,14 +1731,35 @@
},
"link_device": {
"title": "Koble til mobilappen",
"description": "Logg på Bulwark Mail-mobilappen uten å skrive inn påloggingsopplysninger. Lag en QR-kode her, og skann den fra påloggingsskjermen i appen.",
"description": "Logg på Bulwark Mail-appen på telefonen uten å skrive inn passordet der. Telefonen får sin egen pålogging, så den forblir pålogget når du logger av her.",
"generate": "Vis QR-kode",
"regenerate": "Vis en ny kode",
"instructions": "Åpne Bulwark Mail-appen, trykk på «Skann QR-kode» på påloggingsskjermen, og rett kameraet mot denne koden.",
"instructions": "Åpne Bulwark Mail-appen og trykk på «Scan a sign-in code» på påloggingsskjermen (eller «Add account» → «Scan a sign-in code» hvis du allerede er pålogget), og rett deretter kameraet hit.",
"expires_in": "Denne koden utløper om {seconds} sekunder. Den kan bare brukes én gang.",
"expired": "Denne koden er utløpt.",
"generating": "Genererer...",
"error": "Kunne ikke opprette en sammenkoblingskode. Prøv igjen."
"error": "Kunne ikke opprette en sammenkoblingskode. Prøv igjen.",
"password_prompt": "Skriv inn passordet ditt for å bekrefte at det er deg. Telefonen får sin egen pålogging; passordet ditt sendes ikke til telefonen.",
"show_password": "Vis passord",
"hide_password": "Skjul passord",
"continue": "Fortsett",
"error_wrong_password": "Feil passord. Prøv igjen.",
"totp_prompt": "Skriv inn koden fra autentiseringsappen din.",
"error_password_or_code": "Passordet eller koden er feil.",
"error_not_signed_in": "Økten din er utløpt. Logg på igjen for å koble til en enhet.",
"error_impersonation": "Enheter kan ikke kobles til mens du ser på en annen brukers postkasse.",
"error_too_many_attempts": "For mange forsøk. Vent litt og prøv igjen.",
"error_session_secret": "Tilkobling av enheter er ikke satt opp på denne serveren (SESSION_SECRET mangler). Kontakt administratoren din.",
"error_server_unreachable": "Fikk ikke kontakt med e-postserveren din. Prøv igjen.",
"error_pairing_unavailable": "E-postserveren din tillater ikke å koble til enheter på denne måten. Kontakt administratoren din.",
"qr_alt": "QR-kode for pålogging",
"link_label": "Påloggingslenke",
"copy_link": "Kopier påloggingslenke",
"copied": "Kopiert",
"copy_hint": "Ingen kamera? Kopier lenken og lim den inn i appen med «Paste sign-in link».",
"success_title": "Pålogget på telefonen",
"success_body": "Du kan lukke denne siden. Vis en ny kode for å koble til en annen enhet.",
"link_another": "Koble til en annen enhet",
"use_sso": "Bruk enkel pålogging i stedet"
}
},
"identities": {
+25 -4
View File
@@ -1717,14 +1717,35 @@
},
"link_device": {
"title": "Mobiele app koppelen",
"description": "Log in bij de Bulwark Mail mobiele app zonder iets te typen. Genereer hier een QR-code en scan deze vanaf het inlogscherm van de app.",
"description": "Log in bij de Bulwark Mail-app op uw telefoon zonder daar uw wachtwoord te typen. Uw telefoon krijgt een eigen aanmelding, dus als u hier uitlogt, blijft de telefoon ingelogd.",
"generate": "QR-code tonen",
"regenerate": "Nieuwe code tonen",
"instructions": "Open de Bulwark Mail app, tik op \"QR-code scannen\" op het inlogscherm en richt uw camera hierop.",
"instructions": "Open de Bulwark Mail-app en tik op het inlogscherm op \"Scan a sign-in code\" (of op \"Add account\" → \"Scan a sign-in code\" als u al bent ingelogd) en richt de camera hierop.",
"expires_in": "Deze code verloopt over {seconds} seconden. Hij kan maar één keer worden gebruikt.",
"expired": "Deze code is verlopen.",
"generating": "Genereren…",
"error": "Kon geen koppelingscode aanmaken. Probeer het opnieuw."
"error": "Kon geen koppelingscode aanmaken. Probeer het opnieuw.",
"password_prompt": "Voer uw wachtwoord in om te bevestigen dat u het bent. Uw telefoon krijgt een eigen aanmelding; uw wachtwoord wordt niet naar de telefoon gestuurd.",
"show_password": "Wachtwoord tonen",
"hide_password": "Wachtwoord verbergen",
"continue": "Doorgaan",
"error_wrong_password": "Onjuist wachtwoord. Probeer het opnieuw.",
"totp_prompt": "Voer de code uit uw authenticator-app in.",
"error_password_or_code": "Het wachtwoord of de code is onjuist.",
"error_not_signed_in": "Uw sessie is verlopen. Log opnieuw in om een apparaat te koppelen.",
"error_impersonation": "U kunt geen apparaten koppelen terwijl u de mailbox van een andere gebruiker bekijkt.",
"error_too_many_attempts": "Te veel pogingen. Wacht even en probeer het opnieuw.",
"error_session_secret": "Apparaten koppelen is niet ingesteld op deze server (SESSION_SECRET ontbreekt). Neem contact op met uw beheerder.",
"error_server_unreachable": "Uw mailserver is niet bereikbaar. Probeer het opnieuw.",
"error_pairing_unavailable": "Uw mailserver staat het koppelen van apparaten op deze manier niet toe. Neem contact op met uw beheerder.",
"qr_alt": "QR-code om in te loggen",
"link_label": "Inloglink",
"copy_link": "Inloglink kopiëren",
"copied": "Gekopieerd",
"copy_hint": "Geen camera? Kopieer de link en plak hem in de app via \"Paste sign-in link\".",
"success_title": "Ingelogd op uw telefoon",
"success_body": "U kunt deze pagina sluiten. Toon een nieuwe code om nog een apparaat te koppelen.",
"link_another": "Nog een apparaat koppelen",
"use_sso": "In plaats daarvan eenmalige aanmelding gebruiken"
},
"public_keys": {
"name_label": "Naam van openbare sleutel",
+25 -4
View File
@@ -1717,14 +1717,35 @@
},
"link_device": {
"title": "Połącz aplikację mobilną",
"description": "Zaloguj się do aplikacji mobilnej Bulwark Mail bez wpisywania czegokolwiek. Wygeneruj tutaj kod QR i zeskanuj go na ekranie logowania aplikacji.",
"description": "Zaloguj się do aplikacji Bulwark Mail na telefonie bez wpisywania w niej hasła. Telefon otrzymuje własne logowanie, więc wylogowanie się tutaj go nie wyloguje.",
"generate": "Pokaż kod QR",
"regenerate": "Pokaż nowy kod",
"instructions": "Otwórz aplikację Bulwark Mail, na ekranie logowania stuknij \"Skanuj kod QR\" i skieruj aparat tutaj.",
"instructions": "Otwórz aplikację Bulwark Mail i na ekranie logowania stuknij \"Scan a sign-in code\" (lub \"Add account\" → \"Scan a sign-in code\", jeśli już jesteś zalogowany), a następnie skieruj aparat tutaj.",
"expires_in": "Ten kod wygaśnie za {seconds} sekund. Można go użyć tylko raz.",
"expired": "Ten kod wygasł.",
"generating": "Generowanie…",
"error": "Nie udało się utworzyć kodu parowania. Spróbuj ponownie."
"error": "Nie udało się utworzyć kodu parowania. Spróbuj ponownie.",
"password_prompt": "Wpisz hasło, aby potwierdzić swoją tożsamość. Telefon otrzymuje własne logowanie; hasło nie jest wysyłane na telefon.",
"show_password": "Pokaż hasło",
"hide_password": "Ukryj hasło",
"continue": "Dalej",
"error_wrong_password": "Nieprawidłowe hasło. Spróbuj ponownie.",
"totp_prompt": "Wpisz kod z aplikacji uwierzytelniającej.",
"error_password_or_code": "Hasło lub kod jest nieprawidłowy.",
"error_not_signed_in": "Twoja sesja wygasła. Zaloguj się ponownie, aby połączyć urządzenie.",
"error_impersonation": "Nie można łączyć urządzeń podczas przeglądania skrzynki pocztowej innego użytkownika.",
"error_too_many_attempts": "Zbyt wiele prób. Odczekaj chwilę i spróbuj ponownie.",
"error_session_secret": "Łączenie urządzeń nie jest skonfigurowane na tym serwerze (brak SESSION_SECRET). Skontaktuj się z administratorem.",
"error_server_unreachable": "Nie udało się połączyć z serwerem poczty. Spróbuj ponownie.",
"error_pairing_unavailable": "Twój serwer poczty nie pozwala na łączenie urządzeń w ten sposób. Skontaktuj się z administratorem.",
"qr_alt": "Kod QR do logowania",
"link_label": "Link logowania",
"copy_link": "Kopiuj link logowania",
"copied": "Skopiowano",
"copy_hint": "Brak aparatu? Skopiuj link i wklej go w aplikacji za pomocą \"Paste sign-in link\".",
"success_title": "Zalogowano na telefonie",
"success_body": "Możesz zamknąć tę stronę. Aby połączyć kolejne urządzenie, pokaż nowy kod.",
"link_another": "Połącz kolejne urządzenie",
"use_sso": "Zamiast tego użyj logowania jednokrotnego"
},
"public_keys": {
"name_label": "Nazwa klucza publicznego",
+25 -4
View File
@@ -1717,14 +1717,35 @@
},
"link_device": {
"title": "Vincular aplicativo móvel",
"description": "Entre no aplicativo móvel Bulwark Mail sem digitar nada. Gere um código QR aqui e escaneie-o na tela de login do aplicativo.",
"description": "Entre no aplicativo Bulwark Mail no seu celular sem digitar sua senha nele. Seu celular recebe um acesso próprio, então sair aqui não o desconecta.",
"generate": "Mostrar código QR",
"regenerate": "Mostrar um novo código",
"instructions": "Abra o aplicativo Bulwark Mail, toque em \"Escanear código QR\" na tela de login e aponte sua câmera para cá.",
"instructions": "Abra o aplicativo Bulwark Mail e toque em \"Scan a sign-in code\" na tela de login (ou em \"Add account\" → \"Scan a sign-in code\" se você já estiver conectado) e aponte a câmera para cá.",
"expires_in": "Este código expira em {seconds} segundos. Ele só pode ser usado uma vez.",
"expired": "Este código expirou.",
"generating": "Gerando…",
"error": "Não foi possível criar um código de pareamento. Tente novamente."
"error": "Não foi possível criar um código de pareamento. Tente novamente.",
"password_prompt": "Digite sua senha para confirmar que é você. Seu celular recebe um acesso próprio; sua senha não é enviada ao celular.",
"show_password": "Mostrar senha",
"hide_password": "Ocultar senha",
"continue": "Continuar",
"error_wrong_password": "Senha incorreta. Tente novamente.",
"totp_prompt": "Digite o código do seu aplicativo autenticador.",
"error_password_or_code": "A senha ou o código está incorreto.",
"error_not_signed_in": "Sua sessão expirou. Entre novamente para vincular um dispositivo.",
"error_impersonation": "Não é possível vincular dispositivos enquanto você visualiza a caixa de correio de outro usuário.",
"error_too_many_attempts": "Muitas tentativas. Aguarde um momento e tente novamente.",
"error_session_secret": "A vinculação de dispositivos não está configurada neste servidor (SESSION_SECRET ausente). Fale com o seu administrador.",
"error_server_unreachable": "Não foi possível acessar seu servidor de e-mail. Tente novamente.",
"error_pairing_unavailable": "Seu servidor de e-mail não permite vincular um dispositivo dessa forma. Fale com o seu administrador.",
"qr_alt": "Código QR de login",
"link_label": "Link de login",
"copy_link": "Copiar link de login",
"copied": "Copiado",
"copy_hint": "Sem câmera? Copie o link e cole-o no aplicativo com \"Paste sign-in link\".",
"success_title": "Conectado no seu celular",
"success_body": "Você pode fechar esta página. Para vincular outro dispositivo, mostre um novo código.",
"link_another": "Vincular outro dispositivo",
"use_sso": "Usar o início de sessão único"
},
"public_keys": {
"name_label": "Nome da chave pública",
+25 -4
View File
@@ -1724,14 +1724,35 @@
},
"link_device": {
"title": "Aplicație mobilă",
"description": "Conectați-vă la aplicația mobilă „Bulwark” fără a introduce nimic. Generați un cod QR aici și scanați-l din ecranul de conectare al aplicației.",
"description": "Conectați-vă la aplicația Bulwark Mail de pe telefon fără să introduceți parola acolo. Telefonul primește propria conectare, așa că deconectarea de aici nu îl deconectează.",
"generate": "Afișați codul QR",
"regenerate": "Afișați un cod nou",
"instructions": "Deschideți aplicația „BulwarkMail”, atingeți „Scanează codul QR” pe ecranul de autentificare și îndreptați camera în această direcție.",
"instructions": "Deschideți aplicația Bulwark Mail și atingeți „Scan a sign-in code” pe ecranul de conectare (sau „Add account” → „Scan a sign-in code” dacă sunteți deja conectat), apoi îndreptați camera spre acest ecran.",
"expires_in": "Acest cod expiră în {seconds} secunde. Poate fi utilizat o singură dată.",
"expired": "Acest cod a expirat.",
"generating": "Se generează…",
"error": "Nu s-a putut crea un cod de asociere. Vă rugăm să încercați din nou."
"error": "Nu s-a putut crea un cod de asociere. Vă rugăm să încercați din nou.",
"password_prompt": "Introduceți parola pentru a confirma că sunteți dvs. Telefonul primește propria conectare; parola nu este trimisă către telefon.",
"show_password": "Afișați parola",
"hide_password": "Ascundeți parola",
"continue": "Continuați",
"error_wrong_password": "Parolă greșită. Încercați din nou.",
"totp_prompt": "Introduceți codul din aplicația de autentificare.",
"error_password_or_code": "Parola sau codul este incorect.",
"error_not_signed_in": "Sesiunea a expirat. Conectați-vă din nou pentru a asocia un dispozitiv.",
"error_impersonation": "Nu puteți asocia dispozitive în timp ce vizualizați căsuța poștală a altui utilizator.",
"error_too_many_attempts": "Prea multe încercări. Așteptați puțin și încercați din nou.",
"error_session_secret": "Asocierea dispozitivelor nu este configurată pe acest server (lipsește SESSION_SECRET). Contactați administratorul.",
"error_server_unreachable": "Serverul de e-mail nu a putut fi contactat. Încercați din nou.",
"error_pairing_unavailable": "Serverul dvs. de e-mail nu permite asocierea dispozitivelor în acest mod. Contactați administratorul.",
"qr_alt": "Cod QR de conectare",
"link_label": "Link de conectare",
"copy_link": "Copiați linkul de conectare",
"copied": "Copiat",
"copy_hint": "Nu aveți cameră? Copiați linkul și lipiți-l în aplicație cu „Paste sign-in link”.",
"success_title": "Conectat pe telefon",
"success_body": "Puteți închide această pagină. Pentru a asocia un alt dispozitiv, afișați un cod nou.",
"link_another": "Asociați alt dispozitiv",
"use_sso": "Folosește autentificarea unică în schimb"
},
"public_keys": {
"name_label": "Numele cheii publice",
+25 -4
View File
@@ -1717,14 +1717,35 @@
},
"link_device": {
"title": "Привязать мобильное приложение",
"description": "Войдите в мобильное приложение Bulwark Mail без ввода данных. Создайте здесь QR-код и отсканируйте его на экране входа в приложении.",
"description": "Войдите в приложение Bulwark Mail на телефоне, не вводя там пароль. Телефон получает собственный вход, поэтому выход здесь не завершает сеанс на нём.",
"generate": "Показать QR-код",
"regenerate": "Показать новый код",
"instructions": "Откройте приложение Bulwark Mail, нажмите \"Сканировать QR-код\" на экране входа и наведите камеру сюда.",
"instructions": "Откройте приложение Bulwark Mail и нажмите \"Scan a sign-in code\" на экране входа (или \"Add account\" → \"Scan a sign-in code\", если вы уже вошли), затем наведите камеру сюда.",
"expires_in": "Срок действия кода истечёт через {seconds} секунд. Его можно использовать только один раз.",
"expired": "Срок действия кода истёк.",
"generating": "Создание…",
"error": "Не удалось создать код привязки. Пожалуйста, попробуйте ещё раз."
"error": "Не удалось создать код привязки. Пожалуйста, попробуйте ещё раз.",
"password_prompt": "Введите пароль, чтобы подтвердить, что это вы. Телефон получает собственный вход; ваш пароль на телефон не передаётся.",
"show_password": "Показать пароль",
"hide_password": "Скрыть пароль",
"continue": "Продолжить",
"error_wrong_password": "Неверный пароль. Попробуйте ещё раз.",
"totp_prompt": "Введите код из приложения-аутентификатора.",
"error_password_or_code": "Неверный пароль или код.",
"error_not_signed_in": "Ваш сеанс истёк. Войдите снова, чтобы привязать устройство.",
"error_impersonation": "Нельзя привязывать устройства, пока вы просматриваете почтовый ящик другого пользователя.",
"error_too_many_attempts": "Слишком много попыток. Подождите немного и попробуйте снова.",
"error_session_secret": "Привязка устройств на этом сервере не настроена (отсутствует SESSION_SECRET). Обратитесь к администратору.",
"error_server_unreachable": "Не удалось связаться с вашим почтовым сервером. Попробуйте ещё раз.",
"error_pairing_unavailable": "Ваш почтовый сервер не позволяет привязывать устройства таким способом. Обратитесь к администратору.",
"qr_alt": "QR-код для входа",
"link_label": "Ссылка для входа",
"copy_link": "Скопировать ссылку для входа",
"copied": "Скопировано",
"copy_hint": "Нет камеры? Скопируйте ссылку и вставьте её в приложении через \"Paste sign-in link\".",
"success_title": "Вход на телефоне выполнен",
"success_body": "Эту страницу можно закрыть. Чтобы привязать ещё одно устройство, покажите новый код.",
"link_another": "Привязать ещё одно устройство",
"use_sso": "Использовать единый вход"
},
"public_keys": {
"name_label": "Название открытого ключа",
+25 -4
View File
@@ -1724,14 +1724,35 @@
},
"link_device": {
"title": "Prepojiť mobilnú aplikáciu",
"description": "Prihláste sa do mobilnej aplikácie Bulwark Mail bez písania. Vygenerujte tu QR kód a naskenujte ho z prihlasovacej obrazovky aplikácie.",
"description": "Prihláste sa do aplikácie Bulwark Mail v telefóne bez toho, aby ste tam zadávali heslo. Telefón dostane vlastné prihlásenie, takže odhlásenie tu ho neodhlási.",
"generate": "Zobraziť QR kód",
"regenerate": "Zobraziť nový kód",
"instructions": "Otvorte aplikáciu Bulwark Mail, klepnite na \"Skenovať QR kód\" na prihlasovacej obrazovke a namieste fotoaparát sem.",
"instructions": "Otvorte aplikáciu Bulwark Mail a na prihlasovacej obrazovke klepnite na \"Scan a sign-in code\" (alebo na \"Add account\" → \"Scan a sign-in code\", ak ste už prihlásení) a potom namierte fotoaparát sem.",
"expires_in": "Tento kód vyprší za {seconds} sekúnd. Dá sa použiť iba raz.",
"expired": "Platnosť tohto kódu vypršala.",
"generating": "Generovanie…",
"error": "Nepodarilo sa vytvoriť párovací kód. Skúste to znova."
"error": "Nepodarilo sa vytvoriť párovací kód. Skúste to znova.",
"password_prompt": "Zadajte heslo a potvrďte, že ste to vy. Telefón dostane vlastné prihlásenie; vaše heslo sa do telefónu neposiela.",
"show_password": "Zobraziť heslo",
"hide_password": "Skryť heslo",
"continue": "Pokračovať",
"error_wrong_password": "Nesprávne heslo. Skúste to znova.",
"totp_prompt": "Zadajte kód z overovacej aplikácie.",
"error_password_or_code": "Heslo alebo kód je nesprávny.",
"error_not_signed_in": "Vaša relácia vypršala. Ak chcete prepojiť zariadenie, znova sa prihláste.",
"error_impersonation": "Zariadenia nie je možné prepojiť, kým si prezeráte schránku iného používateľa.",
"error_too_many_attempts": "Príliš veľa pokusov. Chvíľu počkajte a skúste to znova.",
"error_session_secret": "Prepájanie zariadení nie je na tomto serveri nastavené (chýba SESSION_SECRET). Obráťte sa na správcu.",
"error_server_unreachable": "Nepodarilo sa spojiť s vaším poštovým serverom. Skúste to znova.",
"error_pairing_unavailable": "Váš poštový server neumožňuje prepojiť zariadenie týmto spôsobom. Obráťte sa na správcu.",
"qr_alt": "QR kód na prihlásenie",
"link_label": "Prihlasovací odkaz",
"copy_link": "Kopírovať prihlasovací odkaz",
"copied": "Skopírované",
"copy_hint": "Nemáte fotoaparát? Skopírujte odkaz a vložte ho do aplikácie pomocou \"Paste sign-in link\".",
"success_title": "Prihlásené v telefóne",
"success_body": "Túto stránku môžete zavrieť. Ak chcete prepojiť ďalšie zariadenie, zobrazte nový kód.",
"link_another": "Prepojiť ďalšie zariadenie",
"use_sso": "Použiť namiesto toho jednotné prihlásenie"
},
"public_keys": {
"name_label": "Názov verejného kľúča",
+25 -4
View File
@@ -1717,14 +1717,35 @@
},
"link_device": {
"title": "Mobil uygulamayı bağla",
"description": "Hiçbir şey yazmadan Bulwark Mail mobil uygulamasında oturum açın. Burada bir QR kodu oluşturun ve uygulamanın giriş ekranından tarayın.",
"description": "Telefonunuzdaki Bulwark Mail uygulamasında, orada parolanızı yazmadan oturum açın. Telefonunuz kendi oturumunu alır; bu nedenle burada oturumu kapatmanız telefondaki oturumu kapatmaz.",
"generate": "QR kodunu göster",
"regenerate": "Yeni kod göster",
"instructions": "Bulwark Mail uygulamasını açın, giriş ekranında \"QR kodu tara\" seçeneğine dokunun ve kameranızı buraya doğrultun.",
"instructions": "Bulwark Mail uygulamasını açın ve giriş ekranında \"Scan a sign-in code\" seçeneğine dokunun (zaten oturum açtıysanız \"Add account\" → \"Scan a sign-in code\"), ardından kamerayı buraya doğrultun.",
"expires_in": "Bu kodun süresi {seconds} saniye içinde dolacak. Yalnızca bir kez kullanılabilir.",
"expired": "Bu kodun süresi doldu.",
"generating": "Oluşturuluyor…",
"error": "Eşleştirme kodu oluşturulamadı. Lütfen tekrar deneyin."
"error": "Eşleştirme kodu oluşturulamadı. Lütfen tekrar deneyin.",
"password_prompt": "Siz olduğunuzu doğrulamak için parolanızı girin. Telefonunuz kendi oturumunu alır; parolanız telefona gönderilmez.",
"show_password": "Parolayı göster",
"hide_password": "Parolayı gizle",
"continue": "Devam",
"error_wrong_password": "Parola yanlış. Tekrar deneyin.",
"totp_prompt": "Kimlik doğrulama uygulamanızdaki kodu girin.",
"error_password_or_code": "Parola veya kod yanlış.",
"error_not_signed_in": "Oturumunuzun süresi doldu. Cihaz bağlamak için yeniden oturum açın.",
"error_impersonation": "Başka bir kullanıcının posta kutusunu görüntülerken cihaz bağlanamaz.",
"error_too_many_attempts": "Çok fazla deneme. Biraz bekleyip tekrar deneyin.",
"error_session_secret": "Bu sunucuda cihaz bağlama ayarlanmamış (SESSION_SECRET eksik). Yöneticinize başvurun.",
"error_server_unreachable": "Posta sunucunuza ulaşılamadı. Tekrar deneyin.",
"error_pairing_unavailable": "Posta sunucunuz bu şekilde cihaz bağlanmasına izin vermiyor. Yöneticinize başvurun.",
"qr_alt": "Giriş QR kodu",
"link_label": "Giriş bağlantısı",
"copy_link": "Giriş bağlantısını kopyala",
"copied": "Kopyalandı",
"copy_hint": "Kamera yok mu? Bağlantıyı kopyalayın ve uygulamada \"Paste sign-in link\" ile yapıştırın.",
"success_title": "Telefonunuzda oturum açıldı",
"success_body": "Bu sayfayı kapatabilirsiniz. Başka bir cihaz bağlamak için yeni bir kod gösterin.",
"link_another": "Başka bir cihaz bağla",
"use_sso": "Bunun yerine çoklu oturum açmayı kullan"
},
"public_keys": {
"name_label": "Ortak anahtar adı",
+25 -4
View File
@@ -1717,14 +1717,35 @@
},
"link_device": {
"title": "Прив'язати мобільний застосунок",
"description": "Увійдіть у мобільний застосунок Bulwark Mail без введення даних. Створіть тут QR-код і відскануйте його на екрані входу в застосунку.",
"description": "Увійдіть у застосунок Bulwark Mail на телефоні, не вводячи там пароль. Телефон отримує власний вхід, тож вихід тут не завершує сеанс на ньому.",
"generate": "Показати QR-код",
"regenerate": "Показати новий код",
"instructions": "Відкрийте застосунок Bulwark Mail, натисніть \"Сканувати QR-код\" на екрані входу та наведіть камеру сюди.",
"instructions": "Відкрийте застосунок Bulwark Mail і натисніть \"Scan a sign-in code\" на екрані входу (або \"Add account\" → \"Scan a sign-in code\", якщо ви вже увійшли), потім наведіть камеру сюди.",
"expires_in": "Термін дії коду закінчиться через {seconds} секунд. Його можна використати лише один раз.",
"expired": "Термін дії цього коду закінчився.",
"generating": "Створення…",
"error": "Не вдалося створити код прив'язки. Будь ласка, спробуйте ще раз."
"error": "Не вдалося створити код прив'язки. Будь ласка, спробуйте ще раз.",
"password_prompt": "Введіть пароль, щоб підтвердити, що це ви. Телефон отримує власний вхід; ваш пароль на телефон не передається.",
"show_password": "Показати пароль",
"hide_password": "Приховати пароль",
"continue": "Продовжити",
"error_wrong_password": "Неправильний пароль. Спробуйте ще раз.",
"totp_prompt": "Введіть код із застосунку-автентифікатора.",
"error_password_or_code": "Неправильний пароль або код.",
"error_not_signed_in": "Ваш сеанс завершився. Увійдіть знову, щоб прив'язати пристрій.",
"error_impersonation": "Не можна прив'язувати пристрої, поки ви переглядаєте поштову скриньку іншого користувача.",
"error_too_many_attempts": "Забагато спроб. Зачекайте трохи й спробуйте знову.",
"error_session_secret": "Прив'язку пристроїв на цьому сервері не налаштовано (відсутній SESSION_SECRET). Зверніться до адміністратора.",
"error_server_unreachable": "Не вдалося зв'язатися з вашим поштовим сервером. Спробуйте ще раз.",
"error_pairing_unavailable": "Ваш поштовий сервер не дозволяє прив'язувати пристрої таким способом. Зверніться до адміністратора.",
"qr_alt": "QR-код для входу",
"link_label": "Посилання для входу",
"copy_link": "Скопіювати посилання для входу",
"copied": "Скопійовано",
"copy_hint": "Немає камери? Скопіюйте посилання та вставте його в застосунку через \"Paste sign-in link\".",
"success_title": "Вхід на телефоні виконано",
"success_body": "Цю сторінку можна закрити. Щоб прив'язати ще один пристрій, покажіть новий код.",
"link_another": "Прив'язати ще один пристрій",
"use_sso": "Натомість використати єдиний вхід"
},
"public_keys": {
"name_label": "Назва відкритого ключа",
+25 -4
View File
@@ -1731,14 +1731,35 @@
},
"link_device": {
"title": "連結行動應用程式",
"description": "不需輸入即可登入 Bulwark Mail 行動應用程式。在這裡產生 QR Code,再從應用程式的登入畫面掃描。",
"description": "不需在手機上輸入密碼,即可登入手機上的 Bulwark Mail 應用程式。手機會取得獨立的登入狀態,因此在這裡登出不會讓手機登出。",
"generate": "顯示 QR Code",
"regenerate": "顯示新的 QR Code",
"instructions": "開啟 Bulwark Mail 應用程式,在登入畫面點選「掃描 QR Code」,再將相機對準這裡。",
"instructions": "開啟 Bulwark Mail 應用程式,在登入畫面點選「Scan a sign-in code」(如果已經登入,請點選「Add account」→「Scan a sign-in code」),再將相機對準這裡。",
"expires_in": "此 QR Code 將在 {seconds} 秒後到期,且只能使用一次。",
"expired": "此 QR Code 已到期。",
"generating": "正在產生…",
"error": "無法建立配對碼。請再試一次。"
"error": "無法建立配對碼。請再試一次。",
"password_prompt": "請輸入密碼以確認是你本人。手機會取得獨立的登入狀態;你的密碼不會傳送到手機。",
"show_password": "顯示密碼",
"hide_password": "隱藏密碼",
"continue": "繼續",
"error_wrong_password": "密碼錯誤,請再試一次。",
"totp_prompt": "請輸入驗證器應用程式中的驗證碼。",
"error_password_or_code": "密碼或驗證碼不正確。",
"error_not_signed_in": "你的工作階段已過期。請重新登入後再連結裝置。",
"error_impersonation": "檢視其他使用者的信箱時無法連結裝置。",
"error_too_many_attempts": "嘗試次數過多,請稍候再試。",
"error_session_secret": "此伺服器尚未設定裝置連結(缺少 SESSION_SECRET)。請洽詢管理員。",
"error_server_unreachable": "無法連線到你的郵件伺服器,請再試一次。",
"error_pairing_unavailable": "你的郵件伺服器不允許以這種方式連結裝置。請洽詢管理員。",
"qr_alt": "登入 QR Code",
"link_label": "登入連結",
"copy_link": "複製登入連結",
"copied": "已複製",
"copy_hint": "沒有相機?複製連結,然後在應用程式中透過「Paste sign-in link」貼上。",
"success_title": "已在手機上登入",
"success_body": "你可以關閉此頁面。若要連結其他裝置,請顯示新的 QR Code。",
"link_another": "連結其他裝置",
"use_sso": "改用單一登入"
}
},
"identities": {
+25 -4
View File
@@ -1717,14 +1717,35 @@
},
"link_device": {
"title": "关联移动应用",
"description": "无需输入任何内容即可登录 Bulwark Mail 移动应用。在此生成二维码,然后在应用的登录界面扫描。",
"description": "无需在手机上输入密码即可登录手机上的 Bulwark Mail 应用。手机会获得独立的登录状态,因此在此处退出登录不会让手机退出。",
"generate": "显示二维码",
"regenerate": "显示新的二维码",
"instructions": "打开 Bulwark Mail 应用,在登录界面点按\"扫描二维码\",然后将相机对准此处。",
"instructions": "打开 Bulwark Mail 应用,在登录界面点按“Scan a sign-in code”(如果已登录,则点按“Add account”→“Scan a sign-in code”),然后将相机对准此处。",
"expires_in": "此二维码将在 {seconds} 秒后过期,且只能使用一次。",
"expired": "此二维码已过期。",
"generating": "生成中…",
"error": "无法创建配对码。请重试。"
"error": "无法创建配对码。请重试。",
"password_prompt": "请输入密码以确认是你本人。手机会获得独立的登录状态;你的密码不会发送到手机。",
"show_password": "显示密码",
"hide_password": "隐藏密码",
"continue": "继续",
"error_wrong_password": "密码错误,请重试。",
"totp_prompt": "请输入身份验证器应用中的验证码。",
"error_password_or_code": "密码或验证码不正确。",
"error_not_signed_in": "你的会话已过期。请重新登录后再关联设备。",
"error_impersonation": "查看其他用户的邮箱时无法关联设备。",
"error_too_many_attempts": "尝试次数过多,请稍后再试。",
"error_session_secret": "此服务器未设置设备关联(缺少 SESSION_SECRET)。请联系管理员。",
"error_server_unreachable": "无法连接到你的邮件服务器,请重试。",
"error_pairing_unavailable": "你的邮件服务器不允许以这种方式关联设备。请联系管理员。",
"qr_alt": "登录二维码",
"link_label": "登录链接",
"copy_link": "复制登录链接",
"copied": "已复制",
"copy_hint": "没有相机?复制链接,然后在应用中通过“Paste sign-in link”粘贴。",
"success_title": "已在手机上登录",
"success_body": "你可以关闭此页面。如需关联其他设备,请显示新的二维码。",
"link_another": "关联其他设备",
"use_sso": "改用单点登录"
},
"public_keys": {
"name_label": "公钥名称",
+1
View File
@@ -94,6 +94,7 @@ export const LITE_API_STRING_ALLOWLIST = [
"/api/auth/sso/complete",
"/api/auth/reauth/sso/complete",
"/api/auth/pair/create",
"/api/auth/pair/status",
"/api/auth/totp-token-exchange",
"/api/auth/session",
"/api/auth/verify",