diff --git a/app/(main)/[locale]/login/page.tsx b/app/(main)/[locale]/login/page.tsx index 1aa5b7c3f..efc06c706 100644 --- a/app/(main)/[locale]/login/page.tsx +++ b/app/(main)/[locale]/login/page.tsx @@ -18,7 +18,7 @@ import { cn } from "@/lib/utils"; import { AlertCircle, Loader2, X, Info, Eye, EyeOff, LogIn, Sun, Moon, Monitor, Check, Shield, Play, Copy, KeyRound } from "@/components/icons"; import { type OAuthMetadata } from "@/lib/oauth/discovery"; import { generateCodeVerifier, generateCodeChallenge, generateState } from "@/lib/oauth/pkce"; -import { DEFAULT_OAUTH_SCOPES } from "@/lib/oauth/scopes"; +import { DEFAULT_OAUTH_SCOPES, withProviderDataScopes } from "@/lib/oauth/scopes"; import { useUpdateStore, selectBanner } from "@/stores/update-store"; import { offersOwnOAuth, type PublicJmapServerEntry } from "@/lib/admin/jmap-servers"; import { IS_LITE, getLiteInjectedClientId } from "@/lib/lite"; @@ -817,7 +817,7 @@ function LoginPageContent() { authUrl.searchParams.set("response_type", "code"); authUrl.searchParams.set("client_id", clientId); authUrl.searchParams.set("redirect_uri", redirectUri); - authUrl.searchParams.set("scope", oauthScopes || DEFAULT_OAUTH_SCOPES); + authUrl.searchParams.set("scope", withProviderDataScopes(oauthScopes || DEFAULT_OAUTH_SCOPES, metadata.scopes_supported)); authUrl.searchParams.set("state", state); authUrl.searchParams.set("code_challenge", challenge); authUrl.searchParams.set("code_challenge_method", "S256"); diff --git a/app/api/auth/sso/start/route.ts b/app/api/auth/sso/start/route.ts index 288603a92..1f9d7db98 100644 --- a/app/api/auth/sso/start/route.ts +++ b/app/api/auth/sso/start/route.ts @@ -6,6 +6,7 @@ import { generateCodeVerifierServer, generateCodeChallengeServer, generateStateS import { getRequiredConfig, getDiscoveryValidator } from '@/lib/oauth/token-exchange'; import { discoverOAuth } from '@/lib/oauth/discovery'; import { getOauthScopes, refreshTokenServerCookieName } from '@/lib/oauth/tokens'; +import { withProviderDataScopes } from '@/lib/oauth/scopes'; import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils'; import { getCookieOptions } from '@/lib/oauth/cookie-config'; import { hasSessionSecret } from '@/lib/auth/session-secret'; @@ -143,7 +144,8 @@ 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', reauthScopes(getOauthScopes(), isReauth, metadata.scopes_supported)); + const scopes = withProviderDataScopes(getOauthScopes(), metadata.scopes_supported); + authUrl.searchParams.set('scope', reauthScopes(scopes, isReauth, metadata.scopes_supported)); authUrl.searchParams.set('state', state); authUrl.searchParams.set('code_challenge', codeChallenge); authUrl.searchParams.set('code_challenge_method', 'S256'); diff --git a/lib/__tests__/lite-oauth.test.ts b/lib/__tests__/lite-oauth.test.ts index cf0c2f6bb..53702ed5a 100644 --- a/lib/__tests__/lite-oauth.test.ts +++ b/lib/__tests__/lite-oauth.test.ts @@ -80,7 +80,8 @@ describe('Lite on Stalwart: OAuth discovery', () => { const found = await oauth.liteDiscoverOAuth(`${SERVER}/`, ' alice@example.com '); expect(found).toEqual({ external: false, - scope: 'openid offline_access', + // Stalwart 1.0 gives a third-party client only what its data scopes name. + scope: 'openid offline_access urn:ietf:params:oauth:scope:mail', metadata: { issuer: SERVER, authorization_endpoint: `${SERVER}/login`, diff --git a/lib/__tests__/oauth-provider-scopes.test.ts b/lib/__tests__/oauth-provider-scopes.test.ts new file mode 100644 index 000000000..4792f8e50 --- /dev/null +++ b/lib/__tests__/oauth-provider-scopes.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest'; +import { withProviderDataScopes } from '@/lib/oauth/scopes'; + +// Stalwart 1.0 as the identity provider refuses a client it doesn't treat as +// first-party when the request names a scope it doesn't know ("openid email +// profile" -> invalid_scope), and gives that client only the data the granted +// scopes name. These are the scopes_supported both servers advertise live. + +const STALWART_1_0 = [ + 'openid', + 'offline_access', + 'urn:ietf:params:oauth:scope:mail', + 'urn:ietf:params:oauth:scope:contacts', + 'urn:ietf:params:oauth:scope:calendars', + 'urn:ietf:params:oauth:scope:files', +]; +const STALWART_0_16 = STALWART_1_0.slice(0, 5); +const KEYCLOAK = ['openid', 'email', 'profile', 'offline_access', 'roles']; + +describe('withProviderDataScopes', () => { + it('asks Stalwart 1.0 for its data scopes instead of scopes it does not know', () => { + expect(withProviderDataScopes('openid email profile', STALWART_1_0)).toBe( + 'openid urn:ietf:params:oauth:scope:mail urn:ietf:params:oauth:scope:contacts ' + + 'urn:ietf:params:oauth:scope:calendars urn:ietf:params:oauth:scope:files', + ); + }); + + it('keeps scopes the provider lists', () => { + expect(withProviderDataScopes('openid offline_access', STALWART_0_16)).toBe( + 'openid offline_access urn:ietf:params:oauth:scope:mail urn:ietf:params:oauth:scope:contacts ' + + 'urn:ietf:params:oauth:scope:calendars', + ); + }); + + it('leaves other providers alone', () => { + expect(withProviderDataScopes('openid email profile', KEYCLOAK)).toBe('openid email profile'); + expect(withProviderDataScopes('openid email profile', undefined)).toBe('openid email profile'); + }); +}); diff --git a/lib/auth/lite-oauth.ts b/lib/auth/lite-oauth.ts index ece9f5af8..2e88b2221 100644 --- a/lib/auth/lite-oauth.ts +++ b/lib/auth/lite-oauth.ts @@ -1,5 +1,6 @@ import { IS_LITE_STALWART, getLiteMount } from '@/lib/lite'; import { discoverOAuth, type OAuthMetadata } from '@/lib/oauth/discovery'; +import { withProviderDataScopes } from '@/lib/oauth/scopes'; /** * OAuth sign-in (authorization code + PKCE) for Lite served by Stalwart. @@ -86,7 +87,7 @@ function rebase(endpoint: string, serverUrl: string): string { function pickScope(supported: unknown): string { if (!Array.isArray(supported)) return DEFAULT_SCOPE; if (!supported.includes('openid')) return ''; - return WANTED_SCOPES.filter((scope) => supported.includes(scope)).join(' '); + return withProviderDataScopes(WANTED_SCOPES.filter((scope) => supported.includes(scope)).join(' '), supported); } /** diff --git a/lib/oauth/scopes.ts b/lib/oauth/scopes.ts index e9126422e..11f43f25c 100644 --- a/lib/oauth/scopes.ts +++ b/lib/oauth/scopes.ts @@ -4,3 +4,21 @@ * the same list the server actually sends. */ export const DEFAULT_OAUTH_SCOPES = 'openid email profile'; + +const DATA_SCOPE_PREFIX = 'urn:ietf:params:oauth:scope:'; + +/** + * Scopes for a provider that advertises OAuth data scopes (Stalwart: mail, + * contacts, calendars, files). Stalwart 1.0 gives a client it doesn't treat + * as first-party only what these name, and refuses a request naming a scope + * it doesn't know, such as `email` or `profile`. For such a provider the + * scopes it doesn't list are dropped and every data scope it lists is added; + * any other provider gets `scopes` unchanged. + */ +export function withProviderDataScopes(scopes: string, supported: unknown): string { + if (!Array.isArray(supported)) return scopes; + const data = supported.filter((s): s is string => typeof s === 'string' && s.startsWith(DATA_SCOPE_PREFIX)); + if (data.length === 0) return scopes; + const kept = scopes.split(/\s+/).filter((s) => s && supported.includes(s)); + return [...new Set([...kept, ...data])].join(' '); +}