Skip to content

Commit b664417

Browse files
boehlkeclaude
andcommitted
feat: add OIDC authentication support via Traefik middleware
- Detect OIDC sessions from Traefik-injected Authorization header - Add auth-token.service.ts for JWT management and session detection - OIDC login button and logout flow via /oauth2/logout - Extract OIDC session detection utility for auth worker Co-Authored-By: Claude Opus 4.6 <[email protected]>
1 parent e21710b commit b664417

File tree

12 files changed

+9797
-101
lines changed

12 files changed

+9797
-101
lines changed

client/package-lock.json

Lines changed: 197 additions & 83 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

client/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
"test-silently": "npm run test -- --watch=false --browsers=ChromiumHeadless",
2020
"test-coverage": "npm run test -- --watch=false --code-coverage --browsers=ChromiumHeadless --source-map",
2121
"test-live": "npm run test -- --watch=true --browsers=Chromium",
22+
"test:oidc": "npx playwright test --config=tests/playwright.oidc.config.ts",
2223
"lint": "ng lint",
2324
"lint-write": "ng lint --fix",
2425
"extract": "ngx-translate-extract -i ./src -o ./src/assets/i18n/template-en.pot --clean --sort --format pot",
@@ -70,7 +71,7 @@
7071
"@tsparticles/shape-text": "^3.9.1",
7172
"@tsparticles/slim": "^3.9.1",
7273
"chart.js": "^4.5.1",
73-
"cm-chess": "^3.6.3",
74+
"cm-chess": "^3.6.1",
7475
"cm-chessboard": "^8.11.5",
7576
"date-fns": "^4.1.0",
7677
"exceljs": "^4.4.0",

client/pnpm-lock.yaml

Lines changed: 9426 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

client/src/app/gateways/auth-adapter.service.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ interface AuthServiceResponse {
77
message: string;
88
success: boolean;
99
token?: string;
10+
user_id?: number;
1011
}
1112

1213
@Injectable({

client/src/app/openslides-main-module/interceptors/auth-token.interceptor.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ import { AuthTokenService } from '../../site/services/auth-token.service';
1313

1414
@Injectable()
1515
export class AuthTokenInterceptor implements HttpInterceptor {
16+
private isRedirecting = false;
17+
1618
public constructor(private authTokenService: AuthTokenService) {}
1719

1820
public intercept(request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
@@ -33,7 +35,12 @@ export class AuthTokenInterceptor implements HttpInterceptor {
3335
},
3436
error: (error: unknown) => {
3537
if (error instanceof HttpErrorResponse) {
36-
// Here you can cache failed responses and try again
38+
// When OIDC is enabled (via Traefik middleware) and we get 401, the token has expired.
39+
// Redirect to /oauth2/logout to clear Traefik's session cookie and force a fresh login.
40+
if ((error.status === 401) && AuthTokenService.isOidcSession() && !this.isRedirecting) {
41+
this.isRedirecting = true;
42+
location.replace('/oauth2/logout');
43+
}
3744
}
3845
}
3946
})

client/src/app/site/pages/login/pages/login-mask/components/login-mask/login-mask.component.html

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,13 +26,20 @@ <h1>
2626
}
2727

2828
@if (!loading) {
29-
@if (!samlEnabled) {
29+
@if (oidcEnabled) {
30+
<!-- OIDC via Traefik: User should already be authenticated -->
31+
<div class="login-container">
32+
<p>{{ 'You are being redirected...' | translate }}</p>
33+
<os-spinner [height]="40" [showText]="false" [width]="40"></os-spinner>
34+
</div>
35+
} @else if (!samlEnabled) {
3036
<ng-container *ngTemplateOutlet="loginform; context: { showExtra: true }"></ng-container>
3137
} @else {
3238
<div class="login-container">
3339
<button
3440
class="login-button"
3541
color="primary"
42+
data-testid="sso-login-button"
3643
mat-raised-button
3744
osAutofocus
3845
type="button"

client/src/app/site/pages/login/pages/login-mask/components/login-mask/login-mask.component.ts

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { ORGANIZATION_ID, OrganizationService } from 'src/app/site/pages/organiz
1212
import { OrganizationSettingsService } from 'src/app/site/pages/organization/services/organization-settings.service';
1313
import { ViewOrganization } from 'src/app/site/pages/organization/view-models/view-organization';
1414
import { AuthService } from 'src/app/site/services/auth.service';
15+
import { AuthTokenService } from 'src/app/site/services/auth-token.service';
1516
import { AutoupdateService } from 'src/app/site/services/autoupdate';
1617
import { ModelRequestBuilderService } from 'src/app/site/services/model-request-builder';
1718
import { OpenSlidesRouterService } from 'src/app/site/services/openslides-router.service';
@@ -79,12 +80,16 @@ export class LoginMaskComponent extends BaseMeetingComponent implements OnInit,
7980

8081
public samlEnabled = true;
8182

83+
public oidcEnabled = false;
84+
8285
public guestsEnabled = false;
8386

8487
public isWaitingOnLogin = false;
8588

8689
public loading = true;
8790

91+
private settingsLoaded = { saml: false, oidc: false };
92+
8893
public orgaPublicAccessEnabled = true;
8994

9095
private currentMeetingId: number | null = null;
@@ -143,20 +148,42 @@ export class LoginMaskComponent extends BaseMeetingComponent implements OnInit,
143148
this.checkDevice();
144149
}
145150

146-
// check if global saml auth is enabled
151+
// check if global saml/oidc auth is enabled
147152
this.subscriptions.push(
148153
this.orgaSettings.getSafe(`saml_enabled`).subscribe(enabled => {
149154
this.samlEnabled = enabled;
150-
this.loading = false;
155+
this.settingsLoaded.saml = true;
156+
this.updateLoadingState();
151157
}),
152158
this.orgaSettings.get(`saml_login_button_text`).subscribe(text => {
153159
this.samlLoginButtonText = text;
154160
})
155161
);
156162

163+
// Detect OIDC via Traefik session cookie instead of organization settings
164+
// The Traefik OIDC plugin sets cookies like TraefikOidcAuth.Session.*
165+
this.oidcEnabled = AuthTokenService.isOidcSession();
166+
this.settingsLoaded.oidc = true;
167+
this.updateLoadingState();
168+
// When OIDC is enabled via Traefik middleware, the user is already
169+
// authenticated when reaching the client. If they somehow land on
170+
// the login page and are authenticated, redirect them to the home page.
171+
if (this.oidcEnabled && this.authService.isAuthenticated()) {
172+
this.osRouter.navigateAfterLogin(this.currentMeetingId);
173+
}
174+
157175
this.checkForUnsecureConnection();
158176
}
159177

178+
/**
179+
* Update loading state - only set loading to false when both settings are loaded
180+
*/
181+
private updateLoadingState(): void {
182+
if (this.settingsLoaded.saml && this.settingsLoaded.oidc) {
183+
this.loading = false;
184+
}
185+
}
186+
160187
/**
161188
* Clear the subscription on destroy.
162189
*/

client/src/app/site/pages/meetings/services/active-meeting.subscription.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,11 +58,11 @@ export function getActiveMeetingSubscriptionConfig(id: Id, settingsKeys: string[
5858
}, // TODO: Remove and count unread messages by chat_group_ids/chat_message_ids
5959
{
6060
idField: `poll_ids`,
61-
fieldset: [`title`, `state`, `entitled_group_ids`, `sequential_number`],
61+
fieldset: [`title`, `state`, `entitled_group_ids`],
6262
follow: [
6363
{
6464
idField: `content_object_id`,
65-
fieldset: [`title`, `sequential_number`],
65+
fieldset: [`title`],
6666
follow: [{ idField: `agenda_item_id`, fieldset: [`item_number`, `content_object_id`] }]
6767
}
6868
]

client/src/app/site/services/auth-token.service.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,14 +19,54 @@ export class AuthTokenService {
1919
return this._accessTokenSubject.getValue();
2020
}
2121

22+
/**
23+
* In OIDC mode, the user ID comes from the response body, not from a JWT.
24+
* This property tracks the OIDC user ID separately.
25+
*/
26+
public get oidcUserId(): number | null {
27+
return this._oidcUserId;
28+
}
29+
30+
/**
31+
* Returns true if the user is authenticated (either via JWT or OIDC).
32+
*/
33+
public get isAuthenticated(): boolean {
34+
return !!this.accessToken || !!this._oidcUserId;
35+
}
36+
37+
/**
38+
* Check if the current session is an OIDC session by looking for the
39+
* Traefik OIDC plugin session cookie.
40+
*/
41+
public static isOidcSession(): boolean {
42+
return document.cookie.includes(`TraefikOidcAuth.Session`);
43+
}
44+
2245
// Use undefined as the state of not being initialized
2346
private _accessTokenSubject = new BehaviorSubject<AuthToken | null>(null);
2447
private _rawAccessToken: string | null = null;
48+
private _oidcUserId: number | null = null;
2549

2650
public setRawAccessToken(rawToken: string | null): void {
2751
this._rawAccessToken = rawToken;
2852
const token = this.parseToken(rawToken);
2953
this._accessTokenSubject.next(token);
54+
// Clear OIDC user ID when setting a JWT token (legacy auth mode)
55+
if (rawToken) {
56+
this._oidcUserId = null;
57+
}
58+
}
59+
60+
/**
61+
* Set the user ID for OIDC mode (no JWT token).
62+
*/
63+
public setOidcUserId(userId: number | null): void {
64+
this._oidcUserId = userId;
65+
// Clear JWT token when in OIDC mode
66+
if (userId) {
67+
this._rawAccessToken = null;
68+
this._accessTokenSubject.next(null);
69+
}
3070
}
3171

3272
private parseToken(rawToken: string | null): AuthToken | null {

client/src/app/site/services/auth.service.ts

Lines changed: 41 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { EventEmitter, Injectable } from '@angular/core';
1+
import { EventEmitter, Injectable, Injector } from '@angular/core';
22
import { Router } from '@angular/router';
33
import { CookieService } from 'ngx-cookie-service';
44
import { BehaviorSubject, Observable } from 'rxjs';
@@ -23,6 +23,13 @@ export class AuthService {
2323
return this._authTokenSubject.getValue();
2424
}
2525

26+
/**
27+
* Returns the current user ID regardless of auth method (JWT or OIDC).
28+
*/
29+
public get userId(): number | null {
30+
return this.authToken?.userId ?? this.authTokenService.oidcUserId;
31+
}
32+
2633
/**
2734
* "Pings" every time when a user logs out.
2835
*/
@@ -60,7 +67,17 @@ export class AuthService {
6067
this.sharedWorker.listenTo(`auth`).subscribe(msg => {
6168
switch (msg?.action) {
6269
case `new-user`:
63-
this.authTokenService.setRawAccessToken(msg.content?.token);
70+
if (msg.content?.token) {
71+
// Legacy auth mode: JWT token in header
72+
this.authTokenService.setRawAccessToken(msg.content.token);
73+
} else if (msg.content?.user) {
74+
// OIDC mode: user ID from response body, no JWT
75+
this.authTokenService.setOidcUserId(msg.content.user);
76+
} else {
77+
// Anonymous/logged out
78+
this.authTokenService.setRawAccessToken(null);
79+
this.authTokenService.setOidcUserId(null);
80+
}
6481
this.updateUser(msg.content?.user);
6582
break;
6683
case `new-token`:
@@ -136,6 +153,21 @@ export class AuthService {
136153

137154
public async logout(): Promise<void> {
138155
this.lifecycleService.shutdown();
156+
157+
// Check for OIDC session FIRST - in OIDC mode, skip backend logout
158+
// (auth service disabled, session managed by Traefik/Keycloak)
159+
if (AuthTokenService.isOidcSession()) {
160+
this.authTokenService.setRawAccessToken(null);
161+
this.authTokenService.setOidcUserId(null);
162+
this._logoutEvent.emit();
163+
this.sharedWorker.sendMessage(`auth`, { action: `update` });
164+
this.DS.deleteCollections(...this.DS.getCollections());
165+
await this.DS.clear();
166+
location.replace(`/oauth2/logout`);
167+
return;
168+
}
169+
170+
// Legacy/SAML mode: call backend logout
139171
const response = await this.authAdapter.logout();
140172
if (response?.success) {
141173
this.authTokenService.setRawAccessToken(null);
@@ -145,6 +177,7 @@ export class AuthService {
145177
this.DS.deleteCollections(...this.DS.getCollections());
146178
await this.DS.clear();
147179
this.lifecycleService.bootup();
180+
148181
// In case SAML is enabled, we need to redirect the user to the IDP
149182
// to complete the logout-flow. Maybe there is a better way to check
150183
// for activated SAML than checking if the response is a URL.
@@ -158,7 +191,7 @@ export class AuthService {
158191
}
159192

160193
public isAuthenticated(): boolean {
161-
return !!this.authTokenService.accessToken || this.cookie.check(`anonymous-auth`);
194+
return this.authTokenService.isAuthenticated || this.cookie.check(`anonymous-auth`);
162195
}
163196

164197
/**
@@ -167,19 +200,21 @@ export class AuthService {
167200
* @returns true, if the request was successful (=online)
168201
*/
169202
public async doWhoAmIRequest(): Promise<boolean> {
170-
console.log(`auth: Do WhoAmI`);
171203
let online: boolean;
172204
try {
173-
await this.authAdapter.whoAmI();
205+
const response = await this.authAdapter.whoAmI();
174206
online = true;
207+
// In OIDC mode, user_id comes in response body (no JWT token)
208+
if (response?.user_id && !this.authTokenService.accessToken) {
209+
this.authTokenService.setOidcUserId(response.user_id);
210+
}
175211
} catch (e) {
176212
if (e instanceof ProcessError && e.status >= 400 && e.status < 500) {
177213
online = true;
178214
} else {
179215
online = false;
180216
}
181217
}
182-
console.log(`auth: WhoAmI done, online:`, online, `authenticated:`, !!this.authTokenService.accessToken);
183218
return online;
184219
}
185220
}

0 commit comments

Comments
 (0)