diff --git a/listify-api/.env.docker.example b/listify-api/.env.docker.example index 9d56de9..48b3b41 100644 --- a/listify-api/.env.docker.example +++ b/listify-api/.env.docker.example @@ -23,7 +23,7 @@ OIDC_CLIENT_ID=listify OIDC_CLIENT_SECRET= OIDC_SCOPES=openid profile email groups OIDC_REDIRECT_URI=http://localhost:8080/auth/sso/callback -OIDC_POST_LOGOUT_REDIRECT_URI=http://localhost:8080/login +OIDC_POST_LOGOUT_REDIRECT_URI=http://localhost:8080/auth/sso/logout-callback MISTRAL_API_KEY= MISTRAL_AGENT_ID= diff --git a/listify-api/.env.example b/listify-api/.env.example index 64d760e..5406579 100644 --- a/listify-api/.env.example +++ b/listify-api/.env.example @@ -20,7 +20,7 @@ OIDC_CLIENT_ID=listify OIDC_CLIENT_SECRET= OIDC_SCOPES=openid profile email groups OIDC_REDIRECT_URI=http://localhost:4200/auth/sso/callback -OIDC_POST_LOGOUT_REDIRECT_URI=http://localhost:4200/login +OIDC_POST_LOGOUT_REDIRECT_URI=http://localhost:4200/auth/sso/logout-callback MCP_ACCESS_TOKEN= diff --git a/listify-api/README.md b/listify-api/README.md index 3cc327f..8a92a21 100644 --- a/listify-api/README.md +++ b/listify-api/README.md @@ -91,13 +91,19 @@ In Produktion muss hier die oeffentlich erreichbare Listify-URL stehen, z. B. `h 6. Post-Logout Redirect URI registrieren: ```text -http://localhost:4200/login +http://localhost:4200/auth/sso/logout-callback ``` Bei Docker/Reverse Proxy: ```text -http://localhost:8080/login +http://localhost:8080/auth/sso/logout-callback +``` + +Für das produktive Listify-Deployment muss im OIDC-Client exakt diese Logout Redirect URI hinterlegt sein: + +```text +https://listify.forgecore.work/auth/sso/logout-callback ``` ### Listify Environment @@ -108,7 +114,7 @@ OIDC_CLIENT_ID= OIDC_CLIENT_SECRET= OIDC_SCOPES=openid profile email groups OIDC_REDIRECT_URI=http://localhost:4200/auth/sso/callback -OIDC_POST_LOGOUT_REDIRECT_URI=http://localhost:4200/login +OIDC_POST_LOGOUT_REDIRECT_URI=http://localhost:4200/auth/sso/logout-callback CLIENT_URL=http://localhost:4200 ``` diff --git a/listify-api/src/auth/auth.service.spec.ts b/listify-api/src/auth/auth.service.spec.ts index 5a820d4..def3a90 100644 --- a/listify-api/src/auth/auth.service.spec.ts +++ b/listify-api/src/auth/auth.service.spec.ts @@ -28,7 +28,7 @@ class FakeOidcService { ); exchangeCallback = jest.fn(() => Promise.resolve(this.profile)); createLogoutUrl = jest.fn(() => - Promise.resolve('https://sso.example.test/logout'), + Promise.resolve('https://sso.example.test/logout?state=logout-state'), ); } @@ -183,7 +183,10 @@ describe('AuthService', () => { idTokenHint: loginResponse.idToken, }); - expect(logoutResponse.logoutUrl).toBe('https://sso.example.test/logout'); + expect(logoutResponse.logoutUrl).toBe( + 'https://sso.example.test/logout?state=logout-state', + ); + expect(logoutResponse.logoutState).toBe('logout-state'); expect(oidcService.createLogoutUrl).toHaveBeenCalledWith('id-token'); await expect( authService.refresh({ refreshToken: loginResponse.refreshToken }), diff --git a/listify-api/src/auth/auth.service.ts b/listify-api/src/auth/auth.service.ts index 5493083..2e370cf 100644 --- a/listify-api/src/auth/auth.service.ts +++ b/listify-api/src/auth/auth.service.ts @@ -143,8 +143,16 @@ export class AuthService { ): Promise { await this.revokeRefreshToken(body.refreshToken); + const logoutUrl = await this.oidcService.createLogoutUrl(body.idTokenHint); + const logoutState = new URL(logoutUrl).searchParams.get('state'); + + if (!logoutState) { + throw new BadRequestException('OIDC logout state is missing.'); + } + return { - logoutUrl: await this.oidcService.createLogoutUrl(body.idTokenHint), + logoutUrl, + logoutState, }; } diff --git a/listify-api/src/auth/auth.types.ts b/listify-api/src/auth/auth.types.ts index 61bb1fd..8071668 100644 --- a/listify-api/src/auth/auth.types.ts +++ b/listify-api/src/auth/auth.types.ts @@ -13,6 +13,7 @@ export interface AuthTokenResponse extends AuthTokens { export interface AuthLogoutResponse { logoutUrl: string; + logoutState: string; } export interface JwtTokenPayload { diff --git a/listify-api/src/auth/oidc.service.spec.ts b/listify-api/src/auth/oidc.service.spec.ts index f9eaddc..0a35682 100644 --- a/listify-api/src/auth/oidc.service.spec.ts +++ b/listify-api/src/auth/oidc.service.spec.ts @@ -11,7 +11,8 @@ describe('OidcService', () => { const issuer = 'https://id.example.test'; const clientId = 'listify'; const redirectUri = 'http://localhost:4200/auth/sso/callback'; - const postLogoutRedirectUri = 'http://localhost:4200/login'; + const postLogoutRedirectUri = + 'http://localhost:4200/auth/sso/logout-callback'; const idToken = 'id-token'; const accessToken = 'access-token'; const idTokenHint = 'id-token-hint'; @@ -154,6 +155,7 @@ describe('OidcService', () => { expect(logoutUrl.searchParams.get('post_logout_redirect_uri')).toBe( postLogoutRedirectUri, ); + expect(logoutUrl.searchParams.get('state')).toMatch(/^[A-Za-z0-9_-]{43}$/); }); function mockDiscovery(): void { diff --git a/listify-api/src/auth/oidc.service.ts b/listify-api/src/auth/oidc.service.ts index 83d7c99..517156b 100644 --- a/listify-api/src/auth/oidc.service.ts +++ b/listify-api/src/auth/oidc.service.ts @@ -180,6 +180,7 @@ export class OidcService { async createLogoutUrl(idTokenHint?: string): Promise { const config = this.getConfig(); const discovery = await this.getDiscovery(config); + const state = this.createOpaqueToken(); const logoutUrl = new URL( discovery.end_session_endpoint ?? `${config.issuer.replace(/\/$/, '')}/oidc/session/end`, @@ -196,6 +197,8 @@ export class OidcService { ); } + logoutUrl.searchParams.set('state', state); + return logoutUrl.toString(); } @@ -337,6 +340,10 @@ export class OidcService { ); } + const postLogoutRedirectUri = + process.env.OIDC_POST_LOGOUT_REDIRECT_URI?.trim() || + new URL('/auth/sso/logout-callback', redirectUri).toString(); + return { issuer, discoveryUrl: `${issuer}/.well-known/openid-configuration`, @@ -344,7 +351,7 @@ export class OidcService { redirectUri, clientSecret: process.env.OIDC_CLIENT_SECRET, scopes: process.env.OIDC_SCOPES ?? 'openid profile email groups', - postLogoutRedirectUri: process.env.OIDC_POST_LOGOUT_REDIRECT_URI, + postLogoutRedirectUri, accessTokenAudience: process.env.OIDC_ACCESS_TOKEN_AUDIENCE ?? clientId, groupsClaim: process.env.OIDC_GROUPS_CLAIM ?? 'groups', }; diff --git a/listify-client/src/app/app.routes.ts b/listify-client/src/app/app.routes.ts index 72c7965..e44d5bd 100644 --- a/listify-client/src/app/app.routes.ts +++ b/listify-client/src/app/app.routes.ts @@ -2,6 +2,7 @@ import { Routes } from '@angular/router'; import { authGuard } from './auth/auth.guard'; import { unauthGuard } from './auth/unauth.guard'; import { LoginComponent } from './auth/login/login.component'; +import { LogoutCallbackComponent } from './auth/logout-callback/logout-callback.component'; import { SsoCallbackComponent } from './auth/sso-callback/sso-callback.component'; import { ListDetailComponent } from './lists/list-detail/list-detail.component'; import { ListsComponent } from './lists/lists.component'; @@ -12,6 +13,7 @@ export const routes: Routes = [ { path: '', pathMatch: 'full', redirectTo: 'dashboard' }, { path: 'login', component: LoginComponent, canActivate: [unauthGuard] }, { path: 'auth/sso/callback', component: SsoCallbackComponent }, + { path: 'auth/sso/logout-callback', component: LogoutCallbackComponent }, { path: 'dashboard', loadComponent: () => diff --git a/listify-client/src/app/auth/auth.models.ts b/listify-client/src/app/auth/auth.models.ts index 50cd6c0..d8da8cd 100644 --- a/listify-client/src/app/auth/auth.models.ts +++ b/listify-client/src/app/auth/auth.models.ts @@ -26,6 +26,7 @@ export interface AuthTokenResponse { export interface AuthLogoutResponse { logoutUrl: string; + logoutState: string; } export interface RegisterResponse { diff --git a/listify-client/src/app/auth/auth.service.ts b/listify-client/src/app/auth/auth.service.ts index 0817d00..7603ef7 100644 --- a/listify-client/src/app/auth/auth.service.ts +++ b/listify-client/src/app/auth/auth.service.ts @@ -16,6 +16,7 @@ const ACCESS_TOKEN_KEY = 'listify.accessToken'; const REFRESH_TOKEN_KEY = 'listify.refreshToken'; const ID_TOKEN_KEY = 'listify.idToken'; const USER_KEY = 'listify.user'; +const LOGOUT_STATE_KEY = 'listify.logoutState'; @Injectable({ providedIn: 'root' }) export class AuthService { @@ -128,6 +129,7 @@ export class AuthService { this.http.post(`${this.apiUrl}/logout`, payload).subscribe({ next: (response) => { + this.sessionStorage?.setItem(LOGOUT_STATE_KEY, response.logoutState); this.clearSession(); window.location.href = response.logoutUrl; }, @@ -138,6 +140,15 @@ export class AuthService { }); } + completeProviderLogout(state: string | null): boolean { + const expectedState = this.sessionStorage?.getItem(LOGOUT_STATE_KEY) ?? null; + + this.sessionStorage?.removeItem(LOGOUT_STATE_KEY); + this.clearSession(); + + return Boolean(state && expectedState && state === expectedState); + } + private clearSession(): void { this.storage?.removeItem(ACCESS_TOKEN_KEY); this.storage?.removeItem(REFRESH_TOKEN_KEY); @@ -151,8 +162,6 @@ export class AuthService { this.storage?.setItem(REFRESH_TOKEN_KEY, response.refreshToken); if (response.idToken) { this.storage?.setItem(ID_TOKEN_KEY, response.idToken); - } else { - this.storage?.removeItem(ID_TOKEN_KEY); } this.storeUser(response.user); } @@ -190,4 +199,8 @@ export class AuthService { private get storage(): Storage | null { return typeof window === 'undefined' ? null : window.localStorage; } + + private get sessionStorage(): Storage | null { + return typeof window === 'undefined' ? null : window.sessionStorage; + } } diff --git a/listify-client/src/app/auth/logout-callback/logout-callback.component.html b/listify-client/src/app/auth/logout-callback/logout-callback.component.html new file mode 100644 index 0000000..fae06b3 --- /dev/null +++ b/listify-client/src/app/auth/logout-callback/logout-callback.component.html @@ -0,0 +1,34 @@ +
+ + + + Ausgeloggt + + @if (stateIsValid) { + Sie wurden sicher von Listify und Ihrem SSO-Konto abgemeldet. + } @else { + Die Rückleitung vom SSO-Anbieter konnte nicht bestätigt werden. + } + + + + +
+ {{ stateIsValid ? 'check_circle' : 'error' }} +

+ @if (stateIsValid) { + Ihre lokale Sitzung wurde beendet. Sie können dieses Fenster schließen oder sich erneut anmelden. + } @else { + Ihre lokale Sitzung wurde vorsorglich beendet. Starten Sie eine neue Anmeldung, wenn Sie Listify weiter nutzen möchten. + } +

+ + + Erneut anmelden + +
+
+
+
diff --git a/listify-client/src/app/auth/logout-callback/logout-callback.component.ts b/listify-client/src/app/auth/logout-callback/logout-callback.component.ts new file mode 100644 index 0000000..11f271f --- /dev/null +++ b/listify-client/src/app/auth/logout-callback/logout-callback.component.ts @@ -0,0 +1,21 @@ +import { Component, inject } from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { MatCardModule } from '@angular/material/card'; +import { MatIconModule } from '@angular/material/icon'; +import { ActivatedRoute, RouterLink } from '@angular/router'; +import { AuthService } from '../auth.service'; + +@Component({ + selector: 'app-logout-callback', + imports: [MatButtonModule, MatCardModule, MatIconModule, RouterLink], + templateUrl: './logout-callback.component.html', + styleUrl: '../auth-page.scss', +}) +export class LogoutCallbackComponent { + private readonly route = inject(ActivatedRoute); + private readonly auth = inject(AuthService); + + protected readonly stateIsValid = this.auth.completeProviderLogout( + this.route.snapshot.queryParamMap.get('state'), + ); +}