fix: ask Stalwart 1.0 for mail, contacts, calendar and file access when signing in with SSO

This commit is contained in:
Linus Rath
2026-10-26 20:25:42 +01:00
parent 8860dde005
commit 004f0948d5
6 changed files with 66 additions and 5 deletions
+2 -2
View File
@@ -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");
+3 -1
View File
@@ -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');
+2 -1
View File
@@ -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`,
@@ -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');
});
});
+2 -1
View File
@@ -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);
}
/**
+18
View File
@@ -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(' ');
}