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';