Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
218 changes: 128 additions & 90 deletions client/package-lock.json

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"test-silently": "npm run test -- --watch=false --browsers=ChromiumHeadless",
"test-coverage": "npm run test -- --watch=false --code-coverage --browsers=ChromiumHeadless --source-map",
"test-live": "npm run test -- --watch=true --browsers=Chromium",
"test:oidc": "npx playwright test --config=tests/playwright.oidc.config.ts",
"lint": "ng lint",
"lint-write": "ng lint --fix",
"extract": "ngx-translate-extract -i ./src -o ./src/assets/i18n/template-en.pot --clean --sort --format pot",
Expand Down Expand Up @@ -70,7 +71,7 @@
"@tsparticles/shape-text": "^3.9.1",
"@tsparticles/slim": "^3.9.1",
"chart.js": "^4.5.1",
"cm-chess": "^3.6.3",
"cm-chess": "^3.6.1",
"cm-chessboard": "^8.11.5",
"date-fns": "^4.1.0",
"exceljs": "^4.4.0",
Expand Down
9,426 changes: 9,426 additions & 0 deletions client/pnpm-lock.yaml

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions client/src/app/gateways/auth-adapter.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ interface AuthServiceResponse {
message: string;
success: boolean;
token?: string;
user_id?: number;
}

@Injectable({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import { AuthTokenService } from '../../site/services/auth-token.service';

@Injectable()
export class AuthTokenInterceptor implements HttpInterceptor {
private isRedirecting = false;

public constructor(private authTokenService: AuthTokenService) {}

public intercept(request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
Expand All @@ -33,7 +35,12 @@ export class AuthTokenInterceptor implements HttpInterceptor {
},
error: (error: unknown) => {
if (error instanceof HttpErrorResponse) {
// Here you can cache failed responses and try again
// When OIDC is enabled (via Traefik middleware) and we get 401, the token has expired.
// Redirect to /oauth2/logout to clear Traefik's session cookie and force a fresh login.
if ((error.status === 401) && AuthTokenService.isOidcSession() && !this.isRedirecting) {
this.isRedirecting = true;
location.replace('/oauth2/logout');
}
}
}
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,20 @@ <h1>
}

@if (!loading) {
@if (!samlEnabled) {
@if (oidcEnabled) {
<!-- OIDC via Traefik: User should already be authenticated -->
<div class="login-container">
<p>{{ 'You are being redirected...' | translate }}</p>
<os-spinner [height]="40" [showText]="false" [width]="40"></os-spinner>
</div>
} @else if (!samlEnabled) {
<ng-container *ngTemplateOutlet="loginform; context: { showExtra: true }"></ng-container>
} @else {
<div class="login-container">
<button
class="login-button"
color="primary"
data-testid="sso-login-button"
mat-raised-button
osAutofocus
type="button"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { ORGANIZATION_ID, OrganizationService } from 'src/app/site/pages/organiz
import { OrganizationSettingsService } from 'src/app/site/pages/organization/services/organization-settings.service';
import { ViewOrganization } from 'src/app/site/pages/organization/view-models/view-organization';
import { AuthService } from 'src/app/site/services/auth.service';
import { AuthTokenService } from 'src/app/site/services/auth-token.service';
import { AutoupdateService } from 'src/app/site/services/autoupdate';
import { ModelRequestBuilderService } from 'src/app/site/services/model-request-builder';
import { OpenSlidesRouterService } from 'src/app/site/services/openslides-router.service';
Expand Down Expand Up @@ -79,12 +80,16 @@ export class LoginMaskComponent extends BaseMeetingComponent implements OnInit,

public samlEnabled = true;

public oidcEnabled = false;

public guestsEnabled = false;

public isWaitingOnLogin = false;

public loading = true;

private settingsLoaded = { saml: false, oidc: false };

public orgaPublicAccessEnabled = true;

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

// check if global saml auth is enabled
// check if global saml/oidc auth is enabled
this.subscriptions.push(
this.orgaSettings.getSafe(`saml_enabled`).subscribe(enabled => {
this.samlEnabled = enabled;
this.loading = false;
this.settingsLoaded.saml = true;
this.updateLoadingState();
}),
this.orgaSettings.get(`saml_login_button_text`).subscribe(text => {
this.samlLoginButtonText = text;
})
);

// Detect OIDC via Traefik session cookie instead of organization settings
// The Traefik OIDC plugin sets cookies like TraefikOidcAuth.Session.*
this.oidcEnabled = AuthTokenService.isOidcSession();
this.settingsLoaded.oidc = true;
this.updateLoadingState();
// When OIDC is enabled via Traefik middleware, the user is already
// authenticated when reaching the client. If they somehow land on
// the login page and are authenticated, redirect them to the home page.
if (this.oidcEnabled && this.authService.isAuthenticated()) {
this.osRouter.navigateAfterLogin(this.currentMeetingId);
}

this.checkForUnsecureConnection();
}

/**
* Update loading state - only set loading to false when both settings are loaded
*/
private updateLoadingState(): void {
if (this.settingsLoaded.saml && this.settingsLoaded.oidc) {
this.loading = false;
}
}

/**
* Clear the subscription on destroy.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,11 +58,11 @@ export function getActiveMeetingSubscriptionConfig(id: Id, settingsKeys: string[
}, // TODO: Remove and count unread messages by chat_group_ids/chat_message_ids
{
idField: `poll_ids`,
fieldset: [`title`, `state`, `entitled_group_ids`, `sequential_number`],
fieldset: [`title`, `state`, `entitled_group_ids`],
follow: [
{
idField: `content_object_id`,
fieldset: [`title`, `sequential_number`],
fieldset: [`title`],
follow: [{ idField: `agenda_item_id`, fieldset: [`item_number`, `content_object_id`] }]
}
]
Expand Down
40 changes: 40 additions & 0 deletions client/src/app/site/services/auth-token.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,54 @@ export class AuthTokenService {
return this._accessTokenSubject.getValue();
}

/**
* In OIDC mode, the user ID comes from the response body, not from a JWT.
* This property tracks the OIDC user ID separately.
*/
public get oidcUserId(): number | null {
return this._oidcUserId;
}

/**
* Returns true if the user is authenticated (either via JWT or OIDC).
*/
public get isAuthenticated(): boolean {
return !!this.accessToken || !!this._oidcUserId;
}

/**
* Check if the current session is an OIDC session by looking for the
* Traefik OIDC plugin session cookie.
*/
public static isOidcSession(): boolean {
return document.cookie.includes(`TraefikOidcAuth.Session`);
}

// Use undefined as the state of not being initialized
private _accessTokenSubject = new BehaviorSubject<AuthToken | null>(null);
private _rawAccessToken: string | null = null;
private _oidcUserId: number | null = null;

public setRawAccessToken(rawToken: string | null): void {
this._rawAccessToken = rawToken;
const token = this.parseToken(rawToken);
this._accessTokenSubject.next(token);
// Clear OIDC user ID when setting a JWT token (legacy auth mode)
if (rawToken) {
this._oidcUserId = null;
}
}

/**
* Set the user ID for OIDC mode (no JWT token).
*/
public setOidcUserId(userId: number | null): void {
this._oidcUserId = userId;
// Clear JWT token when in OIDC mode
if (userId) {
this._rawAccessToken = null;
this._accessTokenSubject.next(null);
}
}

private parseToken(rawToken: string | null): AuthToken | null {
Expand Down
47 changes: 41 additions & 6 deletions client/src/app/site/services/auth.service.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { EventEmitter, Injectable } from '@angular/core';
import { EventEmitter, Injectable, Injector } from '@angular/core';
import { Router } from '@angular/router';
import { CookieService } from 'ngx-cookie-service';
import { BehaviorSubject, Observable } from 'rxjs';
Expand All @@ -23,6 +23,13 @@ export class AuthService {
return this._authTokenSubject.getValue();
}

/**
* Returns the current user ID regardless of auth method (JWT or OIDC).
*/
public get userId(): number | null {
return this.authToken?.userId ?? this.authTokenService.oidcUserId;
}

/**
* "Pings" every time when a user logs out.
*/
Expand Down Expand Up @@ -60,7 +67,17 @@ export class AuthService {
this.sharedWorker.listenTo(`auth`).subscribe(msg => {
switch (msg?.action) {
case `new-user`:
this.authTokenService.setRawAccessToken(msg.content?.token);
if (msg.content?.token) {
// Legacy auth mode: JWT token in header
this.authTokenService.setRawAccessToken(msg.content.token);
} else if (msg.content?.user) {
// OIDC mode: user ID from response body, no JWT
this.authTokenService.setOidcUserId(msg.content.user);
} else {
// Anonymous/logged out
this.authTokenService.setRawAccessToken(null);
this.authTokenService.setOidcUserId(null);
}
this.updateUser(msg.content?.user);
break;
case `new-token`:
Expand Down Expand Up @@ -136,6 +153,21 @@ export class AuthService {

public async logout(): Promise<void> {
this.lifecycleService.shutdown();

// Check for OIDC session FIRST - in OIDC mode, skip backend logout
// (auth service disabled, session managed by Traefik/Keycloak)
if (AuthTokenService.isOidcSession()) {
this.authTokenService.setRawAccessToken(null);
this.authTokenService.setOidcUserId(null);
this._logoutEvent.emit();
this.sharedWorker.sendMessage(`auth`, { action: `update` });
this.DS.deleteCollections(...this.DS.getCollections());
await this.DS.clear();
location.replace(`/oauth2/logout`);
return;
}

// Legacy/SAML mode: call backend logout
const response = await this.authAdapter.logout();
if (response?.success) {
this.authTokenService.setRawAccessToken(null);
Expand All @@ -145,6 +177,7 @@ export class AuthService {
this.DS.deleteCollections(...this.DS.getCollections());
await this.DS.clear();
this.lifecycleService.bootup();

// In case SAML is enabled, we need to redirect the user to the IDP
// to complete the logout-flow. Maybe there is a better way to check
// for activated SAML than checking if the response is a URL.
Expand All @@ -158,7 +191,7 @@ export class AuthService {
}

public isAuthenticated(): boolean {
return !!this.authTokenService.accessToken || this.cookie.check(`anonymous-auth`);
return this.authTokenService.isAuthenticated || this.cookie.check(`anonymous-auth`);
}

/**
Expand All @@ -167,19 +200,21 @@ export class AuthService {
* @returns true, if the request was successful (=online)
*/
public async doWhoAmIRequest(): Promise<boolean> {
console.log(`auth: Do WhoAmI`);
let online: boolean;
try {
await this.authAdapter.whoAmI();
const response = await this.authAdapter.whoAmI();
online = true;
// In OIDC mode, user_id comes in response body (no JWT token)
if (response?.user_id && !this.authTokenService.accessToken) {
this.authTokenService.setOidcUserId(response.user_id);
}
} catch (e) {
if (e instanceof ProcessError && e.status >= 400 && e.status < 500) {
online = true;
} else {
online = false;
}
}
console.log(`auth: WhoAmI done, online:`, online, `authenticated:`, !!this.authTokenService.accessToken);
return online;
}
}
10 changes: 6 additions & 4 deletions client/src/app/site/services/operator.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,15 +44,16 @@ const UNKOWN_USER_ID = -1; // this is an invalid id **and** not equal to 0, null
})
export class OperatorService {
public get operatorId(): number | null {
return this.isAnonymous || !this.authService.authToken ? null : this.authService.authToken.userId;
return this.isAnonymous ? null : this.authService.userId;
}

public get isAnonymousLoggedIn(): boolean {
return this.authService.isAuthenticated() && this.isAnonymous;
}

public get isAnonymous(): boolean {
return !this.authService.authToken;
// In OIDC mode, userId comes from response body, not from JWT token
return !this.authService.isAuthenticated();
}

public get isAuthenticated(): boolean {
Expand Down Expand Up @@ -286,14 +287,15 @@ export class OperatorService {
if (token === undefined) {
return;
}
const id = token ? token.userId : null;
// In OIDC mode, userId comes from authService.userId (not from token)
const id = this.authService.userId;
if (id !== this._lastUserId) {
console.debug(`operator: user changed from `, this._lastUserId, `to`, id);
this._lastUserId = id;
this.resetOperatorData();
this.operatorStateChange(true);
}
if (token) {
if (id) {
this.checkReadyState();
}
});
Expand Down
Loading