feat: send an oauth resource indicator when one is configured #1461

This commit is contained in:
Linus Rath
2026-10-31 16:14:35 +01:00
parent 8a4d9e36c9
commit 5931cd1678
14 changed files with 196 additions and 5 deletions
+4 -1
View File
@@ -20,6 +20,7 @@ import { AlertCircle, Loader2, X, Info, Eye, EyeOff, LogIn, Sun, Moon, Monitor,
import { type OAuthMetadata } from "@/lib/oauth/discovery";
import { generateCodeVerifier, generateCodeChallenge, generateState } from "@/lib/oauth/pkce";
import { DEFAULT_OAUTH_SCOPES, withProviderDataScopes } from "@/lib/oauth/scopes";
import { addOauthResource } from "@/lib/oauth/resource";
import { useUpdateStore, selectBanner } from "@/stores/update-store";
import { offersOwnOAuth, type PublicJmapServerEntry } from "@/lib/admin/jmap-servers";
import { IS_LITE, getLiteInjectedClientId } from "@/lib/lite";
@@ -216,7 +217,7 @@ function LoginPageContent() {
usePrefetchMailView(!isMobileHandoff);
const { login, loginWithToken, loginDemo, isLoading, error, clearError, isAuthenticated } = useAuthStore();
const { theme, setTheme, initializeTheme } = useThemeStore(useShallow((s) => ({ theme: s.theme, setTheme: s.setTheme, initializeTheme: s.initializeTheme })));
const { appName, jmapServerUrl: configuredServerUrl, oauthEnabled, oauthOnly, oauthClientId: globalOauthClientId, oauthIssuerUrl: globalOauthIssuerUrl, oauthScopes, rememberMeEnabled, devMode, demoMode, loginLogoLightUrl, loginLogoDarkUrl, loginCompanyName, loginImprintUrl, loginPrivacyPolicyUrl, loginWebsiteUrl, loginUsernamePlaceholder, loginLogoMaxHeight, loginLogoMaxWidth, loginShowHeading, loginShowSubtitle, loginShowTotp, loginShowTokenLogin, loginShowRememberMe, loginShowVersion, isLoading: configLoading, error: configError, autoSsoEnabled, embeddedMode: _embeddedMode, allowCustomJmapEndpoint, jmapServers, jmapServerAutoPickByDomain } = useConfig();
const { appName, jmapServerUrl: configuredServerUrl, oauthEnabled, oauthOnly, oauthClientId: globalOauthClientId, oauthIssuerUrl: globalOauthIssuerUrl, oauthScopes, oauthResource, rememberMeEnabled, devMode, demoMode, loginLogoLightUrl, loginLogoDarkUrl, loginCompanyName, loginImprintUrl, loginPrivacyPolicyUrl, loginWebsiteUrl, loginUsernamePlaceholder, loginLogoMaxHeight, loginLogoMaxWidth, loginShowHeading, loginShowSubtitle, loginShowTotp, loginShowTokenLogin, loginShowRememberMe, loginShowVersion, isLoading: configLoading, error: configError, autoSsoEnabled, embeddedMode: _embeddedMode, allowCustomJmapEndpoint, jmapServers, jmapServerAutoPickByDomain } = useConfig();
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
const colorModeLocked = usePolicyStore((s) => s.getColorModePolicy().locked);
@@ -971,6 +972,8 @@ function LoginPageContent() {
authUrl.searchParams.set("state", state);
authUrl.searchParams.set("code_challenge", challenge);
authUrl.searchParams.set("code_challenge_method", "S256");
// RFC 8707 resource for the global provider only (#1461).
addOauthResource(authUrl, oauthResource, Boolean(server));
if (isAddAccountMode) {
authUrl.searchParams.set("prompt", "select_account");
}
+1
View File
@@ -305,6 +305,7 @@ export function AuthPage() {
<ConfigToggleItem label="Allow private endpoints" description="Permit discovered endpoints on other hosts to resolve to RFC-1918 / loopback addresses. Endpoints on the issuer's own host are always allowed, so split-DNS setups rarely need this." configKey="oauthAllowPrivateEndpoints" value={currentValue('oauthAllowPrivateEndpoints') as boolean} source={config.oauthAllowPrivateEndpoints?.source} onChange={handleChange} onRevert={handleRevert} />
<ConfigTextItem label="Scopes" description={`Space-separated scopes that replace the defaults (${DEFAULT_OAUTH_SCOPES}). Leave blank to use them. offline_access is not requested by default; add it here or under Extra Scopes if your provider only issues refresh tokens for it, or sessions end when the access token expires.`} configKey="oauthScopes" value={currentValue('oauthScopes') as string} source={config.oauthScopes?.source} onChange={handleChange} onRevert={handleRevert} placeholder={DEFAULT_OAUTH_SCOPES} wide />
<ConfigTextItem label="Extra scopes" description={`Additional space-separated scopes appended to the defaults (${DEFAULT_OAUTH_SCOPES}). Ignored when OAuth Scopes is set.`} configKey="oauthExtraScopes" value={currentValue('oauthExtraScopes') as string} source={config.oauthExtraScopes?.source} onChange={handleChange} onRevert={handleRevert} placeholder="urn:ietf:params:oauth:..." wide />
<ConfigTextItem label="Resource" description="RFC 8707 resource indicator sent with the authorization request, for providers that issue access tokens per API (Pocket ID, for example). The token's audience is then this resource, usually the mail server's URL. Must be an absolute URI without a fragment. Not sent when signing in to a server entry with its own provider." configKey="oauthResource" value={currentValue('oauthResource') as string} source={config.oauthResource?.source} onChange={handleChange} onRevert={handleRevert} placeholder="https://mail.example.com" wide />
</SettingsSection>
<SettingsSection id="sso" title="Single sign-on">
+5 -1
View File
@@ -110,7 +110,7 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'Invalid redirect_uri' }, { status: 400 });
}
const { clientId, discoveryUrl } = getRequiredConfig(serverId);
const { clientId, discoveryUrl, resource } = getRequiredConfig(serverId);
const metadata = await discoverOAuth(discoveryUrl, { validateEndpoint: getDiscoveryValidator(discoveryUrl) });
if (!metadata?.authorization_endpoint) {
@@ -164,6 +164,10 @@ export async function POST(request: NextRequest) {
authUrl.searchParams.set('state', state);
authUrl.searchParams.set('code_challenge', codeChallenge);
authUrl.searchParams.set('code_challenge_method', 'S256');
// RFC 8707: ask for a token for the configured API (#1461).
if (resource) {
authUrl.searchParams.set('resource', resource);
}
if (locale) {
authUrl.searchParams.set('ui_locales', locale);
+2 -1
View File
@@ -3,7 +3,7 @@ import { logger } from '@/lib/logger';
import { configManager } from '@/lib/admin/config-manager';
import { parseJmapServers, redactJmapServers } from '@/lib/admin/jmap-servers';
import { hasSessionSecret } from '@/lib/auth/session-secret';
import { getOauthScopes } from '@/lib/oauth/tokens';
import { getOauthResource, getOauthScopes } from '@/lib/oauth/tokens';
import { httpOrMailtoOrEmpty, httpUrlOrEmpty } from '@/lib/config-validation';
import {
matchDomainBranding,
@@ -66,6 +66,7 @@ export async function GET(request: NextRequest) {
oauthClientId: configManager.get<string>('oauthClientId', ''),
oauthIssuerUrl: configManager.get<string>('oauthIssuerUrl', ''),
oauthScopes: getOauthScopes(),
oauthResource: getOauthResource(),
rememberMeEnabled: hasSessionSecret(),
settingsSyncEnabled: configManager.get<boolean>('settingsSyncEnabled', false) && hasSessionSecret(),
stalwartFeaturesEnabled,
+4
View File
@@ -17,6 +17,7 @@ export interface ConfigData {
oauthClientId: string;
oauthIssuerUrl: string;
oauthScopes: string;
oauthResource: string;
rememberMeEnabled: boolean;
settingsSyncEnabled: boolean;
stalwartFeaturesEnabled: boolean;
@@ -171,6 +172,7 @@ export function useConfig(): AppConfig {
oauthClientId: configCache?.oauthClientId || '',
oauthIssuerUrl: configCache?.oauthIssuerUrl || '',
oauthScopes: configCache?.oauthScopes || '',
oauthResource: configCache?.oauthResource || '',
rememberMeEnabled: configCache?.rememberMeEnabled || false,
settingsSyncEnabled: configCache?.settingsSyncEnabled || false,
stalwartFeaturesEnabled: configCache?.stalwartFeaturesEnabled ?? true,
@@ -223,6 +225,7 @@ export function useConfig(): AppConfig {
oauthClientId: configCache.oauthClientId,
oauthIssuerUrl: configCache.oauthIssuerUrl,
oauthScopes: configCache.oauthScopes,
oauthResource: configCache.oauthResource,
rememberMeEnabled: configCache.rememberMeEnabled,
settingsSyncEnabled: configCache.settingsSyncEnabled,
stalwartFeaturesEnabled: configCache.stalwartFeaturesEnabled,
@@ -276,6 +279,7 @@ export function useConfig(): AppConfig {
oauthClientId: data.oauthClientId,
oauthIssuerUrl: data.oauthIssuerUrl,
oauthScopes: data.oauthScopes,
oauthResource: data.oauthResource,
rememberMeEnabled: data.rememberMeEnabled,
settingsSyncEnabled: data.settingsSyncEnabled,
stalwartFeaturesEnabled: data.stalwartFeaturesEnabled,
+1
View File
@@ -10,6 +10,7 @@ export function createConfig(overrides: Partial<ConfigData> = {}): ConfigData {
oauthClientId: '',
oauthIssuerUrl: '',
oauthScopes: '',
oauthResource: '',
rememberMeEnabled: false,
settingsSyncEnabled: false,
stalwartFeaturesEnabled: true,
+101
View File
@@ -0,0 +1,101 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
vi.mock('@/lib/logger', () => ({
logger: { warn: vi.fn(), error: vi.fn(), debug: vi.fn(), info: vi.fn() },
}));
vi.mock('@/lib/oauth/discovery', () => ({
discoverOAuth: vi.fn(),
}));
vi.mock('@/lib/read-file-env', () => ({
readFileEnv: () => '',
}));
const config = vi.hoisted(() => ({ values: new Map<string, unknown>() }));
vi.mock('@/lib/admin/config-manager', () => ({
configManager: {
get: (key: string, def: unknown) => (config.values.has(key) ? config.values.get(key) : def),
ensureLoaded: async () => {},
},
}));
import { normalizeOauthResource, addOauthResource } from '@/lib/oauth/resource';
import { getOauthResource } from '@/lib/oauth/tokens';
import { getRequiredConfig } from '@/lib/oauth/token-exchange';
describe('normalizeOauthResource (#1461)', () => {
it('keeps an absolute URI', () => {
expect(normalizeOauthResource('https://mail.example.com')).toBe('https://mail.example.com');
expect(normalizeOauthResource(' https://mail.example.com/jmap ')).toBe('https://mail.example.com/jmap');
expect(normalizeOauthResource('urn:example:mail')).toBe('urn:example:mail');
});
it('drops values that cannot be resource indicators', () => {
expect(normalizeOauthResource('')).toBeUndefined();
expect(normalizeOauthResource(' ')).toBeUndefined();
expect(normalizeOauthResource('mail.example.com')).toBeUndefined();
expect(normalizeOauthResource('/jmap')).toBeUndefined();
expect(normalizeOauthResource('https://mail.example.com/#api')).toBeUndefined();
expect(normalizeOauthResource('https://mail.example.com#')).toBeUndefined();
expect(normalizeOauthResource(undefined)).toBeUndefined();
expect(normalizeOauthResource(42)).toBeUndefined();
});
});
describe('addOauthResource (login page)', () => {
const authUrl = () => new URL('https://idp.example.com/authorize?client_id=c');
it('adds the resource for the global provider', () => {
const url = authUrl();
addOauthResource(url, 'https://mail.example.com', false);
expect(url.searchParams.get('resource')).toBe('https://mail.example.com');
});
it('leaves it out for a server entry', () => {
const url = authUrl();
addOauthResource(url, 'https://mail.example.com', true);
expect(url.searchParams.has('resource')).toBe(false);
});
it('leaves it out when unset or invalid', () => {
for (const value of ['', 'not a uri', 'https://mail.example.com#x', undefined]) {
const url = authUrl();
addOauthResource(url, value, false);
expect(url.searchParams.has('resource')).toBe(false);
}
});
});
describe('getRequiredConfig resource (#1461)', () => {
beforeEach(() => {
config.values.clear();
config.values.set('jmapServers', [
{ id: 'mx2', label: 'Server 2', url: 'https://mx2.example.com' },
]);
vi.stubEnv('JMAP_SERVER_URL', 'https://mail.example.com');
vi.stubEnv('OAUTH_CLIENT_ID', 'client');
});
afterEach(() => {
vi.unstubAllEnvs();
});
it('returns the configured resource for the global provider', () => {
config.values.set('oauthResource', 'https://mail.example.com');
expect(getOauthResource()).toBe('https://mail.example.com');
expect(getRequiredConfig(null).resource).toBe('https://mail.example.com');
});
it('returns no resource for a server entry', () => {
config.values.set('oauthResource', 'https://mail.example.com');
expect(getRequiredConfig('mx2').resource).toBeUndefined();
});
it('returns no resource when unset or invalid', () => {
expect(getRequiredConfig(null).resource).toBeUndefined();
config.values.set('oauthResource', 'mail.example.com');
expect(getOauthResource()).toBe('');
expect(getRequiredConfig(null).resource).toBeUndefined();
});
});
+18 -1
View File
@@ -36,7 +36,7 @@ vi.mock('@/lib/oauth/pkce-server', () => ({
generateStateServer: () => 'state-123',
}));
const getRequiredConfig = vi.fn((_serverId?: string | null) => ({
const getRequiredConfig = vi.fn((_serverId?: string | null): { clientId: string; discoveryUrl: string; resource?: string } => ({
clientId: 'client',
discoveryUrl: 'https://idp.example.com',
}));
@@ -137,6 +137,23 @@ describe('sso start route', () => {
expect(url.searchParams.has('prompt')).toBe(false);
expect(url.searchParams.has('max_age')).toBe(false);
});
it('sends the configured RFC 8707 resource with the authorization request (#1461)', async () => {
getRequiredConfig.mockReturnValueOnce({
clientId: 'client',
discoveryUrl: 'https://idp.example.com',
resource: 'https://mail.example.com',
});
const { body } = await callRoute({ redirect_uri: 'https://mail.example.com/auth/callback' });
expect(new URL(body.authorize_url).searchParams.get('resource')).toBe('https://mail.example.com');
});
it('sends no resource when none applies', async () => {
const { body } = await callRoute({ redirect_uri: 'https://mail.example.com/auth/callback' });
expect(new URL(body.authorize_url).searchParams.has('resource')).toBe(false);
});
});
describe('sso start for the mobile app', () => {
+5
View File
@@ -317,6 +317,11 @@ export const CONFIG_ENV_MAP: Record<string, { envVar: string; fileEnvVar?: strin
// authorization_endpoint.
oauthAuthorizeUrl: { envVar: 'OAUTH_AUTHORIZE_URL', type: 'url', defaultValue: '' },
oauthScopes: { envVar: 'OAUTH_SCOPES', type: 'string', defaultValue: '' },
// RFC 8707 resource indicator added to the authorization request, so a
// provider that issues tokens per API sets the mail server as audience
// (#1461). Like the issuer it belongs to the global provider and is not
// sent for a server entry. Must be an absolute URI without a fragment.
oauthResource: { envVar: 'OAUTH_RESOURCE', type: 'string', defaultValue: '' },
oauthExtraScopes: { envVar: 'OAUTH_EXTRA_SCOPES', type: 'string', defaultValue: '' },
oauthAllowPrivateEndpoints: { envVar: 'OAUTH_ALLOW_PRIVATE_ENDPOINTS', type: 'boolean', defaultValue: false },
// Signing out of an SSO account also ends the identity provider's session
+1
View File
@@ -34,6 +34,7 @@ const configValidators = {
oauthClientId: isString,
oauthIssuerUrl: isString,
oauthScopes: isString,
oauthResource: isString,
rememberMeEnabled: isBoolean,
settingsSyncEnabled: isBoolean,
stalwartFeaturesEnabled: isBoolean,
+1
View File
@@ -71,6 +71,7 @@ export function applyLiteConfig(raw: unknown, defaults?: { jmapServerUrl?: strin
oauthClientId: '',
oauthIssuerUrl: '',
oauthScopes: '',
oauthResource: '',
rememberMeEnabled: bool(input.rememberMeEnabled, true),
faviconUrl: str(input.faviconUrl, '') || '/branding/Bulwark_Favicon.svg',
appLogoLightUrl: str(input.appLogoLightUrl, ''),
+38
View File
@@ -0,0 +1,38 @@
/**
* RFC 8707 resource indicator for the authorization request (#1461).
*
* Providers that issue access tokens per API, such as Pocket ID, only set the
* token's audience to the mail server (and grant its scopes) when the client
* names that API with `resource`. Stalwart 1.0 rejects tokens whose audience
* isn't one of its resource identifiers.
*
* No server imports, so the login page can share the rule.
*/
/**
* The resource as it goes on the wire, or undefined when the value can't be a
* resource indicator: RFC 8707 requires an absolute URI without a fragment.
*/
export function normalizeOauthResource(value: unknown): string | undefined {
if (typeof value !== 'string') return undefined;
const trimmed = value.trim();
if (!trimmed || trimmed.includes('#')) return undefined;
try {
// Throws for relative references; a scheme makes it absolute.
new URL(trimmed);
} catch {
return undefined;
}
return trimmed;
}
/**
* Adds `resource` to an authorization request built in the browser. The
* configured resource names an API of the global provider, so like the issuer
* (#952) it is left out when signing in to a server entry.
*/
export function addOauthResource(authUrl: URL, resource: unknown, forServerEntry: boolean): void {
if (forServerEntry) return;
const value = normalizeOauthResource(resource);
if (value) authUrl.searchParams.set('resource', value);
}
+5 -1
View File
@@ -6,6 +6,7 @@ import { readFileEnv } from '@/lib/read-file-env';
import { configManager } from '@/lib/admin/config-manager';
import { parseJmapServers, findServerById } from '@/lib/admin/jmap-servers';
import { stalwartDefaultClientId } from '@/lib/auth/stalwart-password-login';
import { getOauthResource } from '@/lib/oauth/tokens';
// Fallback OAuth client id used when no client is configured. The password+TOTP
// login route mints tokens against the mail server's built-in OAuth with this
@@ -125,7 +126,10 @@ export function getRequiredConfig(serverId?: string | null, options?: ClientConf
const endSessionUrl = entry
? entry.oauth?.endSessionUrl
: (configManager.get<string>('oauthEndSessionUrl', '') || process.env.OAUTH_END_SESSION_URL)?.trim() || undefined;
return { clientId, serverUrl, discoveryUrl, serverId: entry?.id, endSessionUrl };
// The resource names an API of the global provider, so it follows the
// issuer's rule too: never sent to a server entry's provider (#1461).
const resource = entry ? undefined : getOauthResource() || undefined;
return { clientId, serverUrl, discoveryUrl, serverId: entry?.id, endSessionUrl, resource };
}
/**
+10
View File
@@ -1,5 +1,6 @@
import { configManager } from '@/lib/admin/config-manager';
import { DEFAULT_OAUTH_SCOPES } from './scopes';
import { normalizeOauthResource } from './resource';
/**
* Resolve the OAuth scopes to request at authorize time.
@@ -14,6 +15,15 @@ export function getOauthScopes(): string {
const extra = configManager.get<string>('oauthExtraScopes', '');
return extra ? `${DEFAULT_OAUTH_SCOPES} ${extra}`.trim() : DEFAULT_OAUTH_SCOPES;
}
/**
* The global RFC 8707 resource (admin override / OAUTH_RESOURCE), or '' when
* it is unset or not an absolute URI without a fragment (#1461).
* Server-only: callers in the browser read `oauthResource` from /api/config.
*/
export function getOauthResource(): string {
return normalizeOauthResource(configManager.get<string>('oauthResource', '')) ?? '';
}
export const REFRESH_TOKEN_COOKIE = 'jmap_rt';
export const REFRESH_TOKEN_SERVER_COOKIE = 'jmap_rts';
export const ACCESS_TOKEN_COOKIE = 'jmap_at';