Skip to content

Commit 1767e17

Browse files
NielsCodesjbpenrath
authored andcommitted
✨(global) support silent login
Allow to enable silent login through envvar `FRONTEND_SILENT_LOGIN_ENABLED`
1 parent 1c83a51 commit 1767e17

File tree

15 files changed

+304
-24
lines changed

15 files changed

+304
-24
lines changed

docs/env.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -320,6 +320,13 @@ it can lead to memory exhaustion, increase at your own risk.
320320
| `IMAGE_PROXY_MAX_SIZE` | `5242880` (5MB) | Maximum size in bytes for external images | Optional |
321321
| `IMAGE_PROXY_CACHE_TTL` | `2592000` (30 days) | Cache TTL in seconds for external images | Optional |
322322

323+
### Frontend
324+
325+
| Variable | Default | Description | Required |
326+
|----------|---------|-------------|----------|
327+
| `FRONTEND_THEME` | `white-label` | Theme for the frontend | Optional |
328+
| `FRONTEND_SILENT_LOGIN_ENABLED` | `False` | Whether silent login is enabled | Optional |
329+
323330
### Third-party Services
324331

325332
#### Drive

env.d/development/backend.defaults

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ OIDC_OP_JWKS_ENDPOINT=http://keycloak:8802/realms/messages/protocol/openid-conne
5656
OIDC_OP_AUTHORIZATION_ENDPOINT=http://localhost:8902/realms/messages/protocol/openid-connect/auth
5757
OIDC_OP_TOKEN_ENDPOINT=http://keycloak:8802/realms/messages/protocol/openid-connect/token
5858
OIDC_OP_USER_ENDPOINT=http://keycloak:8802/realms/messages/protocol/openid-connect/userinfo
59+
OIDC_OP_LOGOUT_ENDPOINT=http://localhost:8902/realms/messages/protocol/openid-connect/logout
5960

6061
OIDC_RP_CLIENT_ID=messages
6162
OIDC_RP_CLIENT_SECRET=ThisIsAnExampleKeyForDevPurposeOnly

src/backend/core/api/openapi.json

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,11 @@
257257
"type": "integer",
258258
"description": "Maximum age in seconds for a message to be eligible for manual retry of failed deliveries",
259259
"readOnly": true
260+
},
261+
"FRONTEND_SILENT_LOGIN_ENABLED": {
262+
"type": "boolean",
263+
"description": "Whether silent OIDC login is enabled",
264+
"readOnly": true
260265
}
261266
},
262267
"required": [
@@ -277,7 +282,8 @@
277282
"IMAGE_PROXY_ENABLED",
278283
"FEATURE_MAILDOMAIN_CREATE",
279284
"FEATURE_MAILDOMAIN_MANAGE_ACCESSES",
280-
"MESSAGES_MANUAL_RETRY_MAX_AGE"
285+
"MESSAGES_MANUAL_RETRY_MAX_AGE",
286+
"FRONTEND_SILENT_LOGIN_ENABLED"
281287
]
282288
}
283289
}
@@ -5661,6 +5667,9 @@
56615667
}
56625668
},
56635669
"description": ""
5670+
},
5671+
"401": {
5672+
"description": "Authentication credentials were not provided or are invalid."
56645673
}
56655674
}
56665675
}

src/backend/core/api/viewsets/config.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,11 @@ class ConfigView(drf.views.APIView):
127127
),
128128
"readOnly": True,
129129
},
130+
"FRONTEND_SILENT_LOGIN_ENABLED": {
131+
"type": "boolean",
132+
"description": "Whether silent OIDC login is enabled",
133+
"readOnly": True,
134+
},
130135
},
131136
"required": [
132137
"ENVIRONMENT",
@@ -147,6 +152,7 @@ class ConfigView(drf.views.APIView):
147152
"FEATURE_MAILDOMAIN_CREATE",
148153
"FEATURE_MAILDOMAIN_MANAGE_ACCESSES",
149154
"MESSAGES_MANUAL_RETRY_MAX_AGE",
155+
"FRONTEND_SILENT_LOGIN_ENABLED",
150156
],
151157
},
152158
)
@@ -174,6 +180,7 @@ def get(self, request):
174180
"MAX_OUTGOING_BODY_SIZE",
175181
"MAX_INCOMING_EMAIL_SIZE",
176182
"MAX_RECIPIENTS_PER_MESSAGE",
183+
"FRONTEND_SILENT_LOGIN_ENABLED",
177184
]
178185
dict_settings = {}
179186
for setting in array_settings:

src/backend/core/api/viewsets/user.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import rest_framework as drf
66
from drf_spectacular.utils import (
77
OpenApiParameter,
8+
OpenApiResponse,
89
OpenApiTypes,
910
extend_schema,
1011
)
@@ -115,6 +116,15 @@ def list(self, request, *args, **kwargs):
115116
)
116117
return drf.response.Response(serializer.data)
117118

119+
@extend_schema(
120+
tags=["users"],
121+
responses={
122+
200: serializers.UserWithAbilitiesSerializer,
123+
401: OpenApiResponse(
124+
description="Authentication credentials were not provided or are invalid.",
125+
),
126+
},
127+
)
118128
@drf.decorators.action(
119129
detail=False,
120130
methods=["get"],

src/backend/core/tests/api/test_config.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
MAX_TEMPLATE_IMAGE_SIZE=2097152, # 2MB
3535
IMAGE_PROXY_ENABLED=False,
3636
MESSAGES_MANUAL_RETRY_MAX_AGE=86400, # 1 day in seconds
37+
FRONTEND_SILENT_LOGIN_ENABLED=True,
3738
)
3839
@pytest.mark.parametrize("is_authenticated", [False, True])
3940
def test_api_config(is_authenticated):
@@ -65,6 +66,7 @@ def test_api_config(is_authenticated):
6566
"MAX_TEMPLATE_IMAGE_SIZE": 2097152,
6667
"IMAGE_PROXY_ENABLED": False,
6768
"MESSAGES_MANUAL_RETRY_MAX_AGE": 86400,
69+
"FRONTEND_SILENT_LOGIN_ENABLED": True,
6870
}
6971

7072

src/backend/messages/settings.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -614,6 +614,9 @@ class Base(Configuration):
614614
FRONTEND_THEME = values.Value(
615615
None, environ_name="FRONTEND_THEME", environ_prefix=None
616616
)
617+
FRONTEND_SILENT_LOGIN_ENABLED = values.BooleanValue(
618+
default=False, environ_name="FRONTEND_SILENT_LOGIN_ENABLED", environ_prefix=None
619+
)
617620

618621
# Celery
619622
CELERY_BROKER_URL = values.Value(
@@ -698,6 +701,8 @@ class Base(Configuration):
698701
OIDC_RP_SCOPES = values.Value(
699702
"openid email", environ_name="OIDC_RP_SCOPES", environ_prefix=None
700703
)
704+
OIDC_AUTHENTICATE_CLASS = "lasuite.oidc_login.views.OIDCAuthenticationRequestView"
705+
OIDC_CALLBACK_CLASS = "lasuite.oidc_login.views.OIDCAuthenticationCallbackView"
701706
LOGIN_REDIRECT_URL = values.Value(
702707
None, environ_name="LOGIN_REDIRECT_URL", environ_prefix=None
703708
)

src/e2e/src/__tests__/login.spec.ts

Lines changed: 153 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
1-
import { test, expect } from '@playwright/test';
1+
import { test, expect, Page, Route } from '@playwright/test';
22
import { signInKeycloakIfNeeded } from '../utils-test';
3-
import { getStorageStatePath } from '../utils';
43

54
test.describe('Authentication with empty storage state', () => {
65
test.use({ storageState: { cookies: [], origins: [] } });
@@ -17,3 +16,155 @@ test.describe('Authentication with existing storage state', () => {
1716
});
1817
});
1918

19+
const SILENT_LOGIN_RETRY_KEY = 'messages_silent-login-retry';
20+
21+
const mockConfigApi = async (page: Page, silentLoginEnabled: boolean) => {
22+
await page.route('**/api/v1.0/config/', async (route: Route) => {
23+
const response = await route.fetch();
24+
const json = await response.json();
25+
json.FRONTEND_SILENT_LOGIN_ENABLED = silentLoginEnabled;
26+
await route.fulfill({ response, json });
27+
});
28+
};
29+
30+
const getSilentLoginRetryKey = async (page: Page): Promise<string | null> => {
31+
return page.evaluate(
32+
(key) => localStorage.getItem(key),
33+
SILENT_LOGIN_RETRY_KEY,
34+
);
35+
};
36+
37+
const clearSilentLoginRetryKey = async (page: Page) => {
38+
await page.evaluate(
39+
(key) => localStorage.removeItem(key),
40+
SILENT_LOGIN_RETRY_KEY,
41+
);
42+
};
43+
44+
// NOTE: Keycloak does not support silent login (prompt=none) when not running
45+
// behind HTTPS, so we cannot fully test the silent re-authentication flow in
46+
// this e2e environment. Instead, we verify that the correct requests are made
47+
// (redirect to /authenticate/?silent=true) and that the app handles the
48+
// outcome gracefully (showing the login page after a failed silent attempt).
49+
test.describe('Silent Login', () => {
50+
test.use({ storageState: { cookies: [], origins: [] } });
51+
52+
test('should attempt silent login with active Keycloak session', async ({
53+
page,
54+
browserName,
55+
}) => {
56+
const username = `user.e2e.${browserName}`;
57+
58+
// Sign in normally to establish a Keycloak session
59+
await signInKeycloakIfNeeded({ page, username });
60+
61+
// Clear app cookies but preserve Keycloak session cookies
62+
const cookies = await page.context().cookies();
63+
const keycloakCookies = cookies.filter((c) =>
64+
c.domain.includes('keycloak'),
65+
);
66+
await page.context().clearCookies();
67+
if (keycloakCookies.length > 0) {
68+
await page.context().addCookies(keycloakCookies);
69+
}
70+
71+
// Clear localStorage retry key to allow silent login
72+
await clearSilentLoginRetryKey(page);
73+
74+
// Enable silent login by intercepting the config endpoint
75+
await mockConfigApi(page, true);
76+
77+
// Track the silent login redirect
78+
let silentLoginRequestMade = false;
79+
page.on('request', (request) => {
80+
const url = request.url();
81+
if (url.includes('/authenticate/') && url.includes('silent=true')) {
82+
silentLoginRequestMade = true;
83+
}
84+
});
85+
86+
// Navigate to the app - the silent login redirect should occur
87+
await page.goto('/');
88+
89+
// The login page is eventually shown because Keycloak returns a
90+
// "login_failed" error when not running behind HTTPS
91+
await expect(page.locator('button.pro-connect-button')).toBeVisible({
92+
timeout: 30000,
93+
});
94+
95+
// Verify the silent login redirect occurred with the expected parameters
96+
expect(silentLoginRequestMade).toBe(true);
97+
});
98+
99+
test('should fail gracefully without Keycloak session', async ({
100+
page,
101+
context,
102+
}) => {
103+
// Ensure no session exists
104+
await context.clearCookies();
105+
106+
// Enable silent login
107+
await mockConfigApi(page, true);
108+
109+
// Track the silent login redirect
110+
let silentLoginRequestMade = false;
111+
page.on('request', (request) => {
112+
const url = request.url();
113+
if (url.includes('/authenticate/') && url.includes('silent=true')) {
114+
silentLoginRequestMade = true;
115+
}
116+
});
117+
118+
// Navigate to the app
119+
await page.goto('/');
120+
121+
// Verify the ProConnect login button is shown (silent login failed,
122+
// retry key prevents re-attempt, login page displayed)
123+
await expect(page.locator('button.pro-connect-button')).toBeVisible({
124+
timeout: 30000,
125+
});
126+
127+
// Verify the silent login redirect occurred
128+
expect(silentLoginRequestMade).toBe(true);
129+
130+
// Verify localStorage has the retry key set (preventing immediate retry)
131+
const retryKeyValue = await getSilentLoginRetryKey(page);
132+
expect(retryKeyValue).not.toBeNull();
133+
});
134+
135+
test('should show login page directly when silent login is disabled', async ({
136+
page,
137+
context,
138+
}) => {
139+
// Ensure no session exists
140+
await context.clearCookies();
141+
142+
// Disable silent login
143+
await mockConfigApi(page, false);
144+
145+
// Track to verify NO silent login redirect
146+
let silentLoginRequestMade = false;
147+
page.on('request', (request) => {
148+
const url = request.url();
149+
if (url.includes('/authenticate/') && url.includes('silent=true')) {
150+
silentLoginRequestMade = true;
151+
}
152+
});
153+
154+
// Navigate to the app
155+
await page.goto('/');
156+
157+
// Verify the ProConnect login button is shown directly (no silent login attempt)
158+
await expect(page.locator('button.pro-connect-button')).toBeVisible({
159+
timeout: 10000,
160+
});
161+
162+
// Verify no silent login redirect was attempted
163+
expect(silentLoginRequestMade).toBe(false);
164+
165+
// Verify no retry key in localStorage (silent login was never triggered)
166+
const retryKeyValue = await getSilentLoginRetryKey(page);
167+
expect(retryKeyValue).toBeNull();
168+
});
169+
});
170+

src/frontend/src/features/api/fetch-api.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ export interface fetchAPIOptions {
1515

1616
export const fetchAPI= async <T>(
1717
pathname: string,
18-
{ params, logoutOn401, ...requestInit }: RequestInit & fetchAPIOptions & { params?: Record<string, string> } = {},
18+
{ params, logoutOn401 = true, ...requestInit }: RequestInit & fetchAPIOptions & { params?: Record<string, string> } = {},
1919
): Promise<T> => {
2020
const requestUrl = getRequestUrl(pathname, params);
2121
const isMultipartFormData = requestInit.body instanceof FormData;
@@ -26,7 +26,7 @@ export const fetchAPI= async <T>(
2626
headers: getHeaders(requestInit.headers, isMultipartFormData),
2727
});
2828

29-
if ((logoutOn401 ?? true) && response.status === 401) {
29+
if (response.status === 401 && logoutOn401) {
3030
sessionStorage.setItem(SESSION_EXPIRED_KEY, 'true');
3131
logout();
3232
}

src/frontend/src/features/api/gen/models/config_retrieve200.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,4 +37,6 @@ export type ConfigRetrieve200 = {
3737
readonly FEATURE_MAILDOMAIN_MANAGE_ACCESSES: boolean;
3838
/** Maximum age in seconds for a message to be eligible for manual retry of failed deliveries */
3939
readonly MESSAGES_MANUAL_RETRY_MAX_AGE: number;
40+
/** Whether silent OIDC login is enabled */
41+
readonly FRONTEND_SILENT_LOGIN_ENABLED: boolean;
4042
};

0 commit comments

Comments
 (0)