This commit is contained in:
Bastian Wagner
2026-07-17 15:32:01 +02:00
parent fadb5dd049
commit 4a6e0f3b0f
13 changed files with 110 additions and 12 deletions

View File

@@ -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=

View File

@@ -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=

View File

@@ -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=<client-id-aus-admin-oidc-clients>
OIDC_CLIENT_SECRET=<client-secret-aus-admin-oidc-clients>
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
```

View File

@@ -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 }),

View File

@@ -143,8 +143,16 @@ export class AuthService {
): Promise<AuthLogoutResponse> {
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,
};
}

View File

@@ -13,6 +13,7 @@ export interface AuthTokenResponse extends AuthTokens {
export interface AuthLogoutResponse {
logoutUrl: string;
logoutState: string;
}
export interface JwtTokenPayload {

View File

@@ -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 {

View File

@@ -180,6 +180,7 @@ export class OidcService {
async createLogoutUrl(idTokenHint?: string): Promise<string> {
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',
};