mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-02 00:14:23 +00:00
fix: keep the app's "Sign in via webmail" working past its first token expiry
The mobile handoff returned the provider's token endpoint and the webmail's client id as they were. With a confidential client the phone could not renew (it has no secret), and a provider on another host was refused by the app outright. The handoff now returns the same bundle a paired phone gets: such grants renew through `/api/auth/pair/token` with a sealed refresh token.
This commit is contained in:
@@ -6,7 +6,9 @@ import {
|
||||
exchangeCodeForTokens,
|
||||
getRequiredConfig,
|
||||
getTokenEndpoint,
|
||||
hasClientSecret,
|
||||
} from '@/lib/oauth/token-exchange';
|
||||
import { buildRedeemBundle } from '@/lib/auth/pair-bundle';
|
||||
import { refreshTokenCookieName, refreshTokenServerCookieName } from '@/lib/oauth/tokens';
|
||||
import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
||||
import { storeIdToken } from '@/lib/oauth/end-session';
|
||||
@@ -15,6 +17,20 @@ import { rejectCrossOriginRequest } from '@/lib/security/same-origin';
|
||||
const SSO_PENDING_COOKIE = 'sso_pending';
|
||||
const SSO_PENDING_MAX_AGE_MS = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
/**
|
||||
* The webmail's public base (origin + mount path) from the callback URL the
|
||||
* flow started with (`<base>/<locale>/auth/callback`, same-origin checked at
|
||||
* /sso/start): where a phone renews through the token proxy.
|
||||
*/
|
||||
function webmailBaseFromCallback(redirectUri: string): string | null {
|
||||
try {
|
||||
const url = new URL(redirectUri);
|
||||
return `${url.origin}${url.pathname.replace(/\/[^/]+\/auth\/callback\/?$/, '').replace(/\/+$/, '')}`;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
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.
|
||||
@@ -113,16 +129,32 @@ export async function POST(request: NextRequest) {
|
||||
// The mobile client needs the bits it can't re-derive: the refresh
|
||||
// token, the token endpoint it should hit to refresh later, and the
|
||||
// client_id the IdP expects on that refresh call. The server URL is
|
||||
// returned so the app knows which JMAP host to connect to.
|
||||
// returned so the app knows which JMAP host to connect to. A phone
|
||||
// cannot renew with a confidential client's secret, and current app
|
||||
// builds refuse a provider on another host, so those renew through
|
||||
// this webmail - the same bundle a paired phone gets.
|
||||
const { clientId, serverUrl } = getRequiredConfig(pendingServerId);
|
||||
const tokenEndpoint = await getTokenEndpoint(pendingServerId);
|
||||
const bundle = buildRedeemBundle({
|
||||
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,
|
||||
confidential: hasClientSecret(pendingServerId),
|
||||
trusted: true,
|
||||
}, webmailBaseFromCallback(redirectUri));
|
||||
if (bundle.flow !== 'oauth') throw new Error('unexpected bundle');
|
||||
return NextResponse.json({
|
||||
access_token: tokens.access_token,
|
||||
expires_in: tokens.expires_in,
|
||||
refresh_token: tokens.refresh_token,
|
||||
token_endpoint: tokenEndpoint,
|
||||
client_id: clientId,
|
||||
server_url: serverUrl,
|
||||
access_token: bundle.access_token,
|
||||
expires_in: bundle.expires_in,
|
||||
refresh_token: bundle.refresh_token,
|
||||
token_endpoint: bundle.token_endpoint,
|
||||
client_id: bundle.client_id,
|
||||
server_url: bundle.server_url,
|
||||
mobile_redirect_uri: mobileRedirectUri,
|
||||
mobile_state: mobileState,
|
||||
});
|
||||
|
||||
+58
-4
@@ -2,9 +2,10 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { NextRequest } from 'next/server';
|
||||
|
||||
// A pairing step-up (purpose `reauth`) completes at
|
||||
// /api/auth/reauth/sso/complete only. Handed to the login route, its code
|
||||
// would sign this browser in as whoever answered the provider's prompt.
|
||||
// POST /api/auth/sso/complete. A pairing step-up (purpose `reauth`)
|
||||
// completes at /api/auth/reauth/sso/complete only: handed to this route, its
|
||||
// code would sign the browser in as whoever answered the provider's prompt.
|
||||
// For the mobile app handoff, the phone gets a bundle it can renew.
|
||||
|
||||
vi.mock('@/lib/auth/session-secret', () => ({
|
||||
getSessionSecret: () => 's'.repeat(64),
|
||||
@@ -24,13 +25,17 @@ const cookieStore = {
|
||||
vi.mock('next/headers', () => ({ cookies: async () => cookieStore }));
|
||||
|
||||
const exchangeCodeForTokens = vi.fn();
|
||||
let tokenEndpoint = 'https://idp.example.net/token';
|
||||
let confidential = false;
|
||||
vi.mock('@/lib/oauth/token-exchange', () => ({
|
||||
exchangeCodeForTokens: (...args: unknown[]) => exchangeCodeForTokens(...args),
|
||||
getTokenEndpoint: async () => 'https://idp.example.net/token',
|
||||
getTokenEndpoint: async () => tokenEndpoint,
|
||||
getRequiredConfig: () => ({ clientId: 'webmail', serverUrl: 'https://mail.example.org', discoveryUrl: 'https://idp.example.net' }),
|
||||
hasClientSecret: () => confidential,
|
||||
}));
|
||||
|
||||
import { encryptPayload } from '@/lib/auth/crypto';
|
||||
import { openPhoneRefreshToken } from '@/lib/auth/pair-bundle';
|
||||
import { POST } from '@/app/api/auth/sso/complete/route';
|
||||
|
||||
function pending(extra: Record<string, unknown> = {}) {
|
||||
@@ -54,6 +59,8 @@ async function complete() {
|
||||
|
||||
beforeEach(() => {
|
||||
jar.clear();
|
||||
tokenEndpoint = 'https://idp.example.net/token';
|
||||
confidential = false;
|
||||
exchangeCodeForTokens.mockReset().mockResolvedValue({ access_token: 'at', refresh_token: 'rt', expires_in: 3600 });
|
||||
});
|
||||
|
||||
@@ -75,3 +82,50 @@ describe('/api/auth/sso/complete', () => {
|
||||
expect(jar.get('jmap_rt')).toBe('rt');
|
||||
});
|
||||
});
|
||||
|
||||
describe('/api/auth/sso/complete for the mobile app handoff', () => {
|
||||
// The app renews with client_id only, at a token endpoint on the mail
|
||||
// server's or the webmail's host. Anything else goes through the webmail.
|
||||
const mobile = () => pending({
|
||||
redirect_uri: 'https://webmail.example/mail/en/auth/callback',
|
||||
mobile_redirect_uri: 'bulwarkmobile://auth/callback',
|
||||
mobile_state: 'm-1',
|
||||
});
|
||||
|
||||
it('hands a public client on the mail server straight to the app', async () => {
|
||||
tokenEndpoint = 'https://mail.example.org/auth/token';
|
||||
mobile();
|
||||
const res = await complete();
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toMatchObject({
|
||||
refresh_token: 'rt',
|
||||
token_endpoint: 'https://mail.example.org/auth/token',
|
||||
client_id: 'webmail',
|
||||
server_url: 'https://mail.example.org',
|
||||
mobile_state: 'm-1',
|
||||
});
|
||||
expect(jar.has('jmap_rt')).toBe(false);
|
||||
});
|
||||
|
||||
it('renews a confidential client through the webmail, with a sealed token', async () => {
|
||||
tokenEndpoint = 'https://mail.example.org/auth/token';
|
||||
confidential = true;
|
||||
mobile();
|
||||
const res = await complete();
|
||||
expect(res.body.token_endpoint).toBe('https://webmail.example/mail/api/auth/pair/token');
|
||||
expect(res.body.refresh_token).not.toBe('rt');
|
||||
expect(openPhoneRefreshToken(res.body.refresh_token)).toMatchObject({
|
||||
refreshToken: 'rt',
|
||||
clientId: 'webmail',
|
||||
tokenEndpoint: 'https://mail.example.org/auth/token',
|
||||
trusted: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('renews through the webmail when the provider is on a host the app refuses', async () => {
|
||||
mobile();
|
||||
const res = await complete();
|
||||
expect(res.body.token_endpoint).toBe('https://webmail.example/mail/api/auth/pair/token');
|
||||
expect(openPhoneRefreshToken(res.body.refresh_token)?.tokenEndpoint).toBe('https://idp.example.net/token');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user