diff --git a/app/(main)/[locale]/login/page.tsx b/app/(main)/[locale]/login/page.tsx
index 6270b85cc..5cee22b98 100644
--- a/app/(main)/[locale]/login/page.tsx
+++ b/app/(main)/[locale]/login/page.tsx
@@ -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");
}
diff --git a/app/(main)/admin/_pages/auth.tsx b/app/(main)/admin/_pages/auth.tsx
index 8a1a4d63d..e19fd9ea1 100644
--- a/app/(main)/admin/_pages/auth.tsx
+++ b/app/(main)/admin/_pages/auth.tsx
@@ -305,6 +305,7 @@ export function AuthPage() {
+
diff --git a/app/api/auth/sso/start/route.ts b/app/api/auth/sso/start/route.ts
index 43729c012..be4666a94 100644
--- a/app/api/auth/sso/start/route.ts
+++ b/app/api/auth/sso/start/route.ts
@@ -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);
diff --git a/app/api/config/route.ts b/app/api/config/route.ts
index 064d58295..6e8208834 100644
--- a/app/api/config/route.ts
+++ b/app/api/config/route.ts
@@ -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('oauthClientId', ''),
oauthIssuerUrl: configManager.get('oauthIssuerUrl', ''),
oauthScopes: getOauthScopes(),
+ oauthResource: getOauthResource(),
rememberMeEnabled: hasSessionSecret(),
settingsSyncEnabled: configManager.get('settingsSyncEnabled', false) && hasSessionSecret(),
stalwartFeaturesEnabled,
diff --git a/hooks/use-config.ts b/hooks/use-config.ts
index 545e7d904..77329854a 100644
--- a/hooks/use-config.ts
+++ b/hooks/use-config.ts
@@ -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,
diff --git a/lib/__tests__/fixtures/config.ts b/lib/__tests__/fixtures/config.ts
index c764c4e05..06d12ddee 100644
--- a/lib/__tests__/fixtures/config.ts
+++ b/lib/__tests__/fixtures/config.ts
@@ -10,6 +10,7 @@ export function createConfig(overrides: Partial = {}): ConfigData {
oauthClientId: '',
oauthIssuerUrl: '',
oauthScopes: '',
+ oauthResource: '',
rememberMeEnabled: false,
settingsSyncEnabled: false,
stalwartFeaturesEnabled: true,
diff --git a/lib/__tests__/oauth-resource.test.ts b/lib/__tests__/oauth-resource.test.ts
new file mode 100644
index 000000000..602ec751f
--- /dev/null
+++ b/lib/__tests__/oauth-resource.test.ts
@@ -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() }));
+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();
+ });
+});
diff --git a/lib/__tests__/sso-start-route.test.ts b/lib/__tests__/sso-start-route.test.ts
index 9805d5f0d..6caf98a87 100644
--- a/lib/__tests__/sso-start-route.test.ts
+++ b/lib/__tests__/sso-start-route.test.ts
@@ -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', () => {
diff --git a/lib/admin/types.ts b/lib/admin/types.ts
index 52352c6f1..fcf317f33 100644
--- a/lib/admin/types.ts
+++ b/lib/admin/types.ts
@@ -317,6 +317,11 @@ export const CONFIG_ENV_MAP: Record('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 };
}
/**
diff --git a/lib/oauth/tokens.ts b/lib/oauth/tokens.ts
index 2661aaad1..ea451f7d1 100644
--- a/lib/oauth/tokens.ts
+++ b/lib/oauth/tokens.ts
@@ -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('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('oauthResource', '')) ?? '';
+}
export const REFRESH_TOKEN_COOKIE = 'jmap_rt';
export const REFRESH_TOKEN_SERVER_COOKIE = 'jmap_rts';
export const ACCESS_TOKEN_COOKIE = 'jmap_at';