mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
fix: ask Stalwart 1.0 for mail, contacts, calendar and file access when signing in with SSO
This commit is contained in:
@@ -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");
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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(' ');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user