logout
This commit is contained in:
@@ -23,7 +23,7 @@ OIDC_CLIENT_ID=listify
|
|||||||
OIDC_CLIENT_SECRET=
|
OIDC_CLIENT_SECRET=
|
||||||
OIDC_SCOPES=openid profile email groups
|
OIDC_SCOPES=openid profile email groups
|
||||||
OIDC_REDIRECT_URI=http://localhost:8080/auth/sso/callback
|
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_API_KEY=
|
||||||
MISTRAL_AGENT_ID=
|
MISTRAL_AGENT_ID=
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ OIDC_CLIENT_ID=listify
|
|||||||
OIDC_CLIENT_SECRET=
|
OIDC_CLIENT_SECRET=
|
||||||
OIDC_SCOPES=openid profile email groups
|
OIDC_SCOPES=openid profile email groups
|
||||||
OIDC_REDIRECT_URI=http://localhost:4200/auth/sso/callback
|
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=
|
MCP_ACCESS_TOKEN=
|
||||||
|
|
||||||
|
|||||||
@@ -91,13 +91,19 @@ In Produktion muss hier die oeffentlich erreichbare Listify-URL stehen, z. B. `h
|
|||||||
6. Post-Logout Redirect URI registrieren:
|
6. Post-Logout Redirect URI registrieren:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
http://localhost:4200/login
|
http://localhost:4200/auth/sso/logout-callback
|
||||||
```
|
```
|
||||||
|
|
||||||
Bei Docker/Reverse Proxy:
|
Bei Docker/Reverse Proxy:
|
||||||
|
|
||||||
```text
|
```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
|
### Listify Environment
|
||||||
@@ -108,7 +114,7 @@ OIDC_CLIENT_ID=<client-id-aus-admin-oidc-clients>
|
|||||||
OIDC_CLIENT_SECRET=<client-secret-aus-admin-oidc-clients>
|
OIDC_CLIENT_SECRET=<client-secret-aus-admin-oidc-clients>
|
||||||
OIDC_SCOPES=openid profile email groups
|
OIDC_SCOPES=openid profile email groups
|
||||||
OIDC_REDIRECT_URI=http://localhost:4200/auth/sso/callback
|
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
|
CLIENT_URL=http://localhost:4200
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ class FakeOidcService {
|
|||||||
);
|
);
|
||||||
exchangeCallback = jest.fn(() => Promise.resolve(this.profile));
|
exchangeCallback = jest.fn(() => Promise.resolve(this.profile));
|
||||||
createLogoutUrl = jest.fn(() =>
|
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,
|
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');
|
expect(oidcService.createLogoutUrl).toHaveBeenCalledWith('id-token');
|
||||||
await expect(
|
await expect(
|
||||||
authService.refresh({ refreshToken: loginResponse.refreshToken }),
|
authService.refresh({ refreshToken: loginResponse.refreshToken }),
|
||||||
|
|||||||
@@ -143,8 +143,16 @@ export class AuthService {
|
|||||||
): Promise<AuthLogoutResponse> {
|
): Promise<AuthLogoutResponse> {
|
||||||
await this.revokeRefreshToken(body.refreshToken);
|
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 {
|
return {
|
||||||
logoutUrl: await this.oidcService.createLogoutUrl(body.idTokenHint),
|
logoutUrl,
|
||||||
|
logoutState,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export interface AuthTokenResponse extends AuthTokens {
|
|||||||
|
|
||||||
export interface AuthLogoutResponse {
|
export interface AuthLogoutResponse {
|
||||||
logoutUrl: string;
|
logoutUrl: string;
|
||||||
|
logoutState: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface JwtTokenPayload {
|
export interface JwtTokenPayload {
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ describe('OidcService', () => {
|
|||||||
const issuer = 'https://id.example.test';
|
const issuer = 'https://id.example.test';
|
||||||
const clientId = 'listify';
|
const clientId = 'listify';
|
||||||
const redirectUri = 'http://localhost:4200/auth/sso/callback';
|
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 idToken = 'id-token';
|
||||||
const accessToken = 'access-token';
|
const accessToken = 'access-token';
|
||||||
const idTokenHint = 'id-token-hint';
|
const idTokenHint = 'id-token-hint';
|
||||||
@@ -154,6 +155,7 @@ describe('OidcService', () => {
|
|||||||
expect(logoutUrl.searchParams.get('post_logout_redirect_uri')).toBe(
|
expect(logoutUrl.searchParams.get('post_logout_redirect_uri')).toBe(
|
||||||
postLogoutRedirectUri,
|
postLogoutRedirectUri,
|
||||||
);
|
);
|
||||||
|
expect(logoutUrl.searchParams.get('state')).toMatch(/^[A-Za-z0-9_-]{43}$/);
|
||||||
});
|
});
|
||||||
|
|
||||||
function mockDiscovery(): void {
|
function mockDiscovery(): void {
|
||||||
|
|||||||
@@ -180,6 +180,7 @@ export class OidcService {
|
|||||||
async createLogoutUrl(idTokenHint?: string): Promise<string> {
|
async createLogoutUrl(idTokenHint?: string): Promise<string> {
|
||||||
const config = this.getConfig();
|
const config = this.getConfig();
|
||||||
const discovery = await this.getDiscovery(config);
|
const discovery = await this.getDiscovery(config);
|
||||||
|
const state = this.createOpaqueToken();
|
||||||
const logoutUrl = new URL(
|
const logoutUrl = new URL(
|
||||||
discovery.end_session_endpoint ??
|
discovery.end_session_endpoint ??
|
||||||
`${config.issuer.replace(/\/$/, '')}/oidc/session/end`,
|
`${config.issuer.replace(/\/$/, '')}/oidc/session/end`,
|
||||||
@@ -196,6 +197,8 @@ export class OidcService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logoutUrl.searchParams.set('state', state);
|
||||||
|
|
||||||
return logoutUrl.toString();
|
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 {
|
return {
|
||||||
issuer,
|
issuer,
|
||||||
discoveryUrl: `${issuer}/.well-known/openid-configuration`,
|
discoveryUrl: `${issuer}/.well-known/openid-configuration`,
|
||||||
@@ -344,7 +351,7 @@ export class OidcService {
|
|||||||
redirectUri,
|
redirectUri,
|
||||||
clientSecret: process.env.OIDC_CLIENT_SECRET,
|
clientSecret: process.env.OIDC_CLIENT_SECRET,
|
||||||
scopes: process.env.OIDC_SCOPES ?? 'openid profile email groups',
|
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,
|
accessTokenAudience: process.env.OIDC_ACCESS_TOKEN_AUDIENCE ?? clientId,
|
||||||
groupsClaim: process.env.OIDC_GROUPS_CLAIM ?? 'groups',
|
groupsClaim: process.env.OIDC_GROUPS_CLAIM ?? 'groups',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Routes } from '@angular/router';
|
|||||||
import { authGuard } from './auth/auth.guard';
|
import { authGuard } from './auth/auth.guard';
|
||||||
import { unauthGuard } from './auth/unauth.guard';
|
import { unauthGuard } from './auth/unauth.guard';
|
||||||
import { LoginComponent } from './auth/login/login.component';
|
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 { SsoCallbackComponent } from './auth/sso-callback/sso-callback.component';
|
||||||
import { ListDetailComponent } from './lists/list-detail/list-detail.component';
|
import { ListDetailComponent } from './lists/list-detail/list-detail.component';
|
||||||
import { ListsComponent } from './lists/lists.component';
|
import { ListsComponent } from './lists/lists.component';
|
||||||
@@ -12,6 +13,7 @@ export const routes: Routes = [
|
|||||||
{ path: '', pathMatch: 'full', redirectTo: 'dashboard' },
|
{ path: '', pathMatch: 'full', redirectTo: 'dashboard' },
|
||||||
{ path: 'login', component: LoginComponent, canActivate: [unauthGuard] },
|
{ path: 'login', component: LoginComponent, canActivate: [unauthGuard] },
|
||||||
{ path: 'auth/sso/callback', component: SsoCallbackComponent },
|
{ path: 'auth/sso/callback', component: SsoCallbackComponent },
|
||||||
|
{ path: 'auth/sso/logout-callback', component: LogoutCallbackComponent },
|
||||||
{
|
{
|
||||||
path: 'dashboard',
|
path: 'dashboard',
|
||||||
loadComponent: () =>
|
loadComponent: () =>
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ export interface AuthTokenResponse {
|
|||||||
|
|
||||||
export interface AuthLogoutResponse {
|
export interface AuthLogoutResponse {
|
||||||
logoutUrl: string;
|
logoutUrl: string;
|
||||||
|
logoutState: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RegisterResponse {
|
export interface RegisterResponse {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ const ACCESS_TOKEN_KEY = 'listify.accessToken';
|
|||||||
const REFRESH_TOKEN_KEY = 'listify.refreshToken';
|
const REFRESH_TOKEN_KEY = 'listify.refreshToken';
|
||||||
const ID_TOKEN_KEY = 'listify.idToken';
|
const ID_TOKEN_KEY = 'listify.idToken';
|
||||||
const USER_KEY = 'listify.user';
|
const USER_KEY = 'listify.user';
|
||||||
|
const LOGOUT_STATE_KEY = 'listify.logoutState';
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class AuthService {
|
export class AuthService {
|
||||||
@@ -128,6 +129,7 @@ export class AuthService {
|
|||||||
|
|
||||||
this.http.post<AuthLogoutResponse>(`${this.apiUrl}/logout`, payload).subscribe({
|
this.http.post<AuthLogoutResponse>(`${this.apiUrl}/logout`, payload).subscribe({
|
||||||
next: (response) => {
|
next: (response) => {
|
||||||
|
this.sessionStorage?.setItem(LOGOUT_STATE_KEY, response.logoutState);
|
||||||
this.clearSession();
|
this.clearSession();
|
||||||
window.location.href = response.logoutUrl;
|
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 {
|
private clearSession(): void {
|
||||||
this.storage?.removeItem(ACCESS_TOKEN_KEY);
|
this.storage?.removeItem(ACCESS_TOKEN_KEY);
|
||||||
this.storage?.removeItem(REFRESH_TOKEN_KEY);
|
this.storage?.removeItem(REFRESH_TOKEN_KEY);
|
||||||
@@ -151,8 +162,6 @@ export class AuthService {
|
|||||||
this.storage?.setItem(REFRESH_TOKEN_KEY, response.refreshToken);
|
this.storage?.setItem(REFRESH_TOKEN_KEY, response.refreshToken);
|
||||||
if (response.idToken) {
|
if (response.idToken) {
|
||||||
this.storage?.setItem(ID_TOKEN_KEY, response.idToken);
|
this.storage?.setItem(ID_TOKEN_KEY, response.idToken);
|
||||||
} else {
|
|
||||||
this.storage?.removeItem(ID_TOKEN_KEY);
|
|
||||||
}
|
}
|
||||||
this.storeUser(response.user);
|
this.storeUser(response.user);
|
||||||
}
|
}
|
||||||
@@ -190,4 +199,8 @@ export class AuthService {
|
|||||||
private get storage(): Storage | null {
|
private get storage(): Storage | null {
|
||||||
return typeof window === 'undefined' ? null : window.localStorage;
|
return typeof window === 'undefined' ? null : window.localStorage;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private get sessionStorage(): Storage | null {
|
||||||
|
return typeof window === 'undefined' ? null : window.sessionStorage;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<section class="auth-page">
|
||||||
|
<mat-card class="auth-card verify-card">
|
||||||
|
<div class="auth-logo">
|
||||||
|
<mat-icon>{{ stateIsValid ? 'logout' : 'warning' }}</mat-icon>
|
||||||
|
</div>
|
||||||
|
<mat-card-header>
|
||||||
|
<mat-card-title>Ausgeloggt</mat-card-title>
|
||||||
|
<mat-card-subtitle>
|
||||||
|
@if (stateIsValid) {
|
||||||
|
Sie wurden sicher von Listify und Ihrem SSO-Konto abgemeldet.
|
||||||
|
} @else {
|
||||||
|
Die Rückleitung vom SSO-Anbieter konnte nicht bestätigt werden.
|
||||||
|
}
|
||||||
|
</mat-card-subtitle>
|
||||||
|
</mat-card-header>
|
||||||
|
|
||||||
|
<mat-card-content>
|
||||||
|
<div class="verification-state" [class.success]="stateIsValid" [class.error]="!stateIsValid">
|
||||||
|
<mat-icon class="state-icon">{{ stateIsValid ? 'check_circle' : 'error' }}</mat-icon>
|
||||||
|
<p>
|
||||||
|
@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.
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
<a mat-flat-button color="primary" routerLink="/login">
|
||||||
|
<mat-icon aria-hidden="true">login</mat-icon>
|
||||||
|
Erneut anmelden
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
|
</section>
|
||||||
@@ -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'),
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user