diff --git a/backend/package.json b/backend/package.json index 933516004..5acad3926 100644 --- a/backend/package.json +++ b/backend/package.json @@ -54,6 +54,7 @@ "diff": "8.0.3", "file-type": "16.5.4", "htmlparser2": "9.1.0", + "jose": "5.10.0", "keyv": "5.6.0", "knex": "3.1.0", "ldapauth-fork": "6.1.0", diff --git a/backend/src/api/private/auth/oidc/oidc.controller.ts b/backend/src/api/private/auth/oidc/oidc.controller.ts index 1a82dd079..ad2b6669a 100644 --- a/backend/src/api/private/auth/oidc/oidc.controller.ts +++ b/backend/src/api/private/auth/oidc/oidc.controller.ts @@ -6,10 +6,14 @@ import { AuthProviderType } from '@hedgedoc/commons'; import { FieldNameIdentity } from '@hedgedoc/database'; import { + BadRequestException, + Body, Controller, Get, + HttpCode, InternalServerErrorException, Param, + Post, Redirect, Req, UnauthorizedException, @@ -19,8 +23,10 @@ import { ApiTags } from '@nestjs/swagger'; import { IdentityService } from '../../../../auth/identity.service'; import { OidcService } from '../../../../auth/oidc/oidc.service'; +import { BackchannelLogoutDto } from '../../../../dtos/backchannel-logout.dto'; import { ConsoleLoggerService } from '../../../../logger/console-logger.service'; import { UsersService } from '../../../../users/users.service'; +import { CsrfExempt } from '../../../utils/decorators/csrf-exempt.decorator'; import { OpenApi } from '../../../utils/decorators/openapi.decorator'; import { RequestWithSession } from '../../../utils/request.type'; @@ -106,4 +112,31 @@ export class OidcController { throw new InternalServerErrorException(); } } + + @Post(':oidcIdentifier/backchannel-logout') + @HttpCode(200) + @CsrfExempt() + @OpenApi(200, 400) + async backchannelLogout( + @Param('oidcIdentifier') oidcIdentifier: string, + @Body() body: BackchannelLogoutDto, + ): Promise { + try { + await this.oidcService.processBackchannelLogout(oidcIdentifier, body.logout_token); + this.logger.debug( + `Backchannel logout successful for provider ${oidcIdentifier}`, + 'backchannelLogout', + ); + } catch (error) { + if (error instanceof HttpException) { + throw error; + } + this.logger.error( + `Error during backchannel logout: ${String(error)}`, + undefined, + 'backchannelLogout', + ); + throw new BadRequestException('Invalid logout token'); + } + } } diff --git a/backend/src/auth/oidc/oidc-service.spec.ts b/backend/src/auth/oidc/oidc-service.spec.ts new file mode 100644 index 000000000..e1fa16e8d --- /dev/null +++ b/backend/src/auth/oidc/oidc-service.spec.ts @@ -0,0 +1,318 @@ +/* + * SPDX-FileCopyrightText: 2026 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import { AuthProviderType } from '@hedgedoc/commons'; +import { FieldNameIdentity, Identity } from '@hedgedoc/database'; +import { BadRequestException, NotFoundException } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { Mock } from 'ts-mockery'; +import * as jose from 'jose'; +import type { JWTPayload } from 'jose'; + +import appConfiguration from '../../config/app.config'; +import authConfiguration from '../../config/auth.config'; +import { ConsoleLoggerService } from '../../logger/console-logger.service'; +import { SessionService } from '../../sessions/session.service'; +import { IdentityService } from '../identity.service'; +import { OidcService } from './oidc.service'; + +jest.mock('jose', () => ({ + createRemoteJWKSet: jest.fn(), + jwtVerify: jest.fn(), +})); + +describe('OidcService', () => { + let oidcService: OidcService; + let identityService: IdentityService; + let sessionService: SessionService; + + const mockOidcConfig = { + identifier: 'test-oidc', + providerName: 'Test OIDC', + issuer: 'https://oidc.example.com', + clientId: 'test-client-id', + clientSecret: 'test-client-secret', + scope: 'openid profile email', + }; + + const mockAppConfig = { + baseUrl: 'http://localhost:3000', + }; + + const mockAuthConfig = { + oidc: [], + }; + + const mockIssuer = { + metadata: { + issuer: 'https://oidc.example.com', + jwks_uri: 'https://oidc.example.com/.well-known/jwks.json', + }, + }; + + const mockClient = { + metadata: { + client_id: 'test-client-id', + }, + }; + + beforeEach(async () => { + const testModule: TestingModule = await Test.createTestingModule({ + providers: [ + OidcService, + { + provide: IdentityService, + useValue: Mock.of({ + getIdentityFromUserIdAndProviderType: jest.fn(), + }), + }, + { + provide: SessionService, + useValue: Mock.of({ + terminateSessionByOidcSid: jest.fn(), + terminateAllSessionsOfUser: jest.fn(), + }), + }, + { + provide: ConsoleLoggerService, + useValue: Mock.of({ + setContext: jest.fn(), + debug: jest.fn(), + error: jest.fn(), + }), + }, + { + provide: authConfiguration.KEY, + useValue: mockAuthConfig, + }, + { + provide: appConfiguration.KEY, + useValue: mockAppConfig, + }, + ], + }).compile(); + + oidcService = testModule.get(OidcService); + identityService = testModule.get(IdentityService); + sessionService = testModule.get(SessionService); + + // Manually set up the client config to bypass the initialization + (oidcService as any).clientConfigs.set(mockOidcConfig.identifier, { + client: mockClient, + issuer: mockIssuer, + redirectUri: `http://localhost:3000/api/private/auth/oidc/${mockOidcConfig.identifier}/callback`, + config: mockOidcConfig, + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('processBackchannelLogout', () => { + it('throws NotFoundException for unknown OIDC identifier', async () => { + await expect( + oidcService.processBackchannelLogout('unknown-oidc', 'fake-token'), + ).rejects.toThrow(NotFoundException); + }); + + it('throws BadRequestException if logout token is missing required claims', async () => { + const mockJwtVerify = jose.jwtVerify as jest.MockedFunction; + const mockPayload: JWTPayload = { + iss: 'https://oidc.example.com', + aud: 'test-client-id', + iat: Date.now() / 1000, + // Missing jti and events + }; + mockJwtVerify.mockResolvedValue({ + payload: mockPayload, + protectedHeader: { alg: 'RS256' }, + key: new Uint8Array(), + }); + + await expect( + oidcService.processBackchannelLogout(mockOidcConfig.identifier, 'valid-jwt-token'), + ).rejects.toThrow(BadRequestException); + }); + + it('throws BadRequestException if logout token contains nonce claim', async () => { + const mockJwtVerify = jose.jwtVerify as jest.MockedFunction; + const mockPayload: JWTPayload = { + iss: 'https://oidc.example.com', + aud: 'test-client-id', + iat: Date.now() / 1000, + jti: 'unique-token-id', + events: { + 'http://schemas.openid.net/event/backchannel-logout': {}, + }, + sub: 'user-123', + nonce: 'should-not-be-present', + }; + mockJwtVerify.mockResolvedValue({ + payload: mockPayload, + protectedHeader: { alg: 'RS256' }, + key: new Uint8Array(), + }); + + await expect( + oidcService.processBackchannelLogout(mockOidcConfig.identifier, 'invalid-token-with-nonce'), + ).rejects.toThrow(BadRequestException); + }); + + it('throws BadRequestException if logout token missing both sub and sid', async () => { + const mockJwtVerify = jose.jwtVerify as jest.MockedFunction; + const mockPayload: JWTPayload = { + iss: 'https://oidc.example.com', + aud: 'test-client-id', + iat: Date.now() / 1000, + jti: 'unique-token-id', + events: { + 'http://schemas.openid.net/event/backchannel-logout': {}, + }, + // Missing both sub and sid + }; + mockJwtVerify.mockResolvedValue({ + payload: mockPayload, + protectedHeader: { alg: 'RS256' }, + key: new Uint8Array(), + }); + + await expect( + oidcService.processBackchannelLogout(mockOidcConfig.identifier, 'invalid-token'), + ).rejects.toThrow(BadRequestException); + }); + + it('throws BadRequestException if logout token missing backchannel-logout event', async () => { + const mockJwtVerify = jose.jwtVerify as jest.MockedFunction; + const mockPayload: JWTPayload = { + iss: 'https://oidc.example.com', + aud: 'test-client-id', + iat: Date.now() / 1000, + jti: 'unique-token-id', + events: { + // Wrong event type + 'http://schemas.openid.net/event/some-other-event': {}, + }, + sub: 'user-123', + }; + mockJwtVerify.mockResolvedValue({ + payload: mockPayload, + protectedHeader: { alg: 'RS256' }, + key: new Uint8Array(), + }); + + await expect( + oidcService.processBackchannelLogout(mockOidcConfig.identifier, 'invalid-event-token'), + ).rejects.toThrow(BadRequestException); + }); + + it('terminates session by sid when sid is provided', async () => { + const mockJwtVerify = jose.jwtVerify as jest.MockedFunction; + const mockPayload: JWTPayload = { + iss: 'https://oidc.example.com', + aud: 'test-client-id', + iat: Date.now() / 1000, + jti: 'unique-token-id', + events: { + 'http://schemas.openid.net/event/backchannel-logout': {}, + }, + sid: 'session-123', + }; + mockJwtVerify.mockResolvedValue({ + payload: mockPayload, + protectedHeader: { alg: 'RS256' }, + key: new Uint8Array(), + }); + + const mockTerminateByOidcSid = + sessionService.terminateSessionByOidcSid as jest.MockedFunction< + typeof sessionService.terminateSessionByOidcSid + >; + mockTerminateByOidcSid.mockResolvedValue(true); + + await oidcService.processBackchannelLogout(mockOidcConfig.identifier, 'valid-sid-token'); + + expect(mockTerminateByOidcSid).toHaveBeenCalledWith('session-123'); + }); + + it('terminates all user sessions when only sub is provided', async () => { + const mockJwtVerify = jose.jwtVerify as jest.MockedFunction; + const mockPayload: JWTPayload = { + iss: 'https://oidc.example.com', + aud: 'test-client-id', + iat: Date.now() / 1000, + jti: 'unique-token-id', + events: { + 'http://schemas.openid.net/event/backchannel-logout': {}, + }, + sub: 'user-123', + }; + mockJwtVerify.mockResolvedValue({ + payload: mockPayload, + protectedHeader: { alg: 'RS256' }, + key: new Uint8Array(), + }); + + const mockIdentity: Identity = { + [FieldNameIdentity.userId]: 42, + [FieldNameIdentity.providerUserId]: 'user-123', + [FieldNameIdentity.providerType]: AuthProviderType.OIDC, + [FieldNameIdentity.providerIdentifier]: mockOidcConfig.identifier, + [FieldNameIdentity.passwordHash]: null, + [FieldNameIdentity.createdAt]: new Date().toISOString(), + [FieldNameIdentity.updatedAt]: new Date().toISOString(), + }; + + const mockGetIdentity = + identityService.getIdentityFromUserIdAndProviderType as jest.MockedFunction< + typeof identityService.getIdentityFromUserIdAndProviderType + >; + mockGetIdentity.mockResolvedValue(mockIdentity); + + const mockTerminateAll = sessionService.terminateAllSessionsOfUser as jest.MockedFunction< + typeof sessionService.terminateAllSessionsOfUser + >; + mockTerminateAll.mockResolvedValue(3); + + await oidcService.processBackchannelLogout(mockOidcConfig.identifier, 'valid-sub-token'); + + expect(mockGetIdentity).toHaveBeenCalledWith( + 'user-123', + AuthProviderType.OIDC, + mockOidcConfig.identifier, + ); + expect(mockTerminateAll).toHaveBeenCalledWith(42); + }); + + it('does not throw an error if no sessions are found', async () => { + const mockJwtVerify = jose.jwtVerify as jest.MockedFunction; + const mockPayload: JWTPayload = { + iss: 'https://oidc.example.com', + aud: 'test-client-id', + iat: Date.now() / 1000, + jti: 'unique-token-id', + events: { + 'http://schemas.openid.net/event/backchannel-logout': {}, + }, + sid: 'non-existent-session', + }; + mockJwtVerify.mockResolvedValue({ + payload: mockPayload, + protectedHeader: { alg: 'RS256' }, + key: new Uint8Array(), + }); + + const mockTerminateByOidcSid = + sessionService.terminateSessionByOidcSid as jest.MockedFunction< + typeof sessionService.terminateSessionByOidcSid + >; + mockTerminateByOidcSid.mockResolvedValue(false); + + await expect( + oidcService.processBackchannelLogout(mockOidcConfig.identifier, 'valid-token-no-session'), + ).resolves.not.toThrow(); + }); + }); +}); diff --git a/backend/src/auth/oidc/oidc.service.ts b/backend/src/auth/oidc/oidc.service.ts index 6b0b48afa..c1654c3c5 100644 --- a/backend/src/auth/oidc/oidc.service.ts +++ b/backend/src/auth/oidc/oidc.service.ts @@ -4,8 +4,9 @@ * SPDX-License-Identifier: AGPL-3.0-only */ import { AuthProviderType } from '@hedgedoc/commons'; -import { Identity } from '@hedgedoc/database'; +import { FieldNameIdentity, Identity } from '@hedgedoc/database'; import { + BadRequestException, ForbiddenException, Inject, Injectable, @@ -14,6 +15,7 @@ import { } from '@nestjs/common'; import { Cron } from '@nestjs/schedule'; import { Client, generators, Issuer, UserinfoResponse } from 'openid-client'; +import { createRemoteJWKSet, jwtVerify } from 'jose'; import { RequestWithSession } from '../../api/utils/request.type'; import appConfiguration, { AppConfig } from '../../config/app.config'; @@ -22,12 +24,14 @@ import { PendingUserInfoDto } from '../../dtos/pending-user-info.dto'; import { NotInDBError } from '../../errors/errors'; import { ConsoleLoggerService } from '../../logger/console-logger.service'; import { IdentityService } from '../identity.service'; +import { SessionService } from '../../sessions/session.service'; interface OidcClientConfigEntry { client: Client; issuer: Issuer; redirectUri: string; config: OidcConfig; + jwks?: ReturnType; } @Injectable() @@ -36,6 +40,7 @@ export class OidcService { constructor( private identityService: IdentityService, + private sessionService: SessionService, private logger: ConsoleLoggerService, @Inject(authConfiguration.KEY) private authConfig: AuthConfig, @@ -308,4 +313,110 @@ export class OidcService { ): string | T { return response[field] ? String(response[field]) : defaultValue; } + + /** + * Validates and processes an OIDC backchannel logout token + * + * @param oidcIdentifier The identifier of the OIDC configuration + * @param logoutToken The logout token as a JWT string + * @returns Promise that resolves when logout is processed + * @throws BadRequestException if the logout token is invalid + */ + async processBackchannelLogout(oidcIdentifier: string, logoutToken: string): Promise { + const clientConfig = this.clientConfigs.get(oidcIdentifier); + if (!clientConfig) { + throw new NotFoundException('OIDC configuration not found or initialized'); + } + + const oidcIssuer = clientConfig.issuer; + const oidcClient = clientConfig.client; + const jwksUri = oidcIssuer.metadata.jwks_uri; + if (!jwksUri) { + this.logger.error( + `OIDC provider ${oidcIdentifier} does not provide jwks_uri. This is required for backchannel-logout.`, + undefined, + 'processBackchannelLogout', + ); + throw new InternalServerErrorException('OIDC provider configuration incomplete'); + } + + try { + if (!clientConfig.jwks) { + clientConfig.jwks = createRemoteJWKSet(new URL(jwksUri)); + } + const keySet = clientConfig.jwks; + const { payload } = await jwtVerify(logoutToken, keySet, { + issuer: oidcIssuer.metadata.issuer, + audience: oidcClient.metadata.client_id, + }); + + // Validation against spec "Logout Token Validation" + // https://openid.net/specs/openid-connect-backchannel-1_0.html#Validation + if (!payload.iss || !payload.aud || !payload.iat || !payload.jti) { + throw new BadRequestException('Logout token missing required claims'); + } + if ('nonce' in payload) { + throw new BadRequestException('Logout token must not contain nonce claim'); + } + if (!payload.sub && !payload.sid) { + throw new BadRequestException('Logout token must contain sub or sid claim'); + } + const events = payload.events as Record | undefined; + if (!events || !('http://schemas.openid.net/event/backchannel-logout' in events)) { + throw new BadRequestException('Logout token does not contain backchannel-logout event'); + } + + const sid = payload.sid as string | undefined; + const sub = payload.sub as string | undefined; + + let terminatedCount = 0; + + // Terminate session by specific session id + if (sid) { + this.logger.debug(`Terminating session with sid: ${sid}`, 'processBackchannelLogout'); + const success = await this.sessionService.terminateSessionByOidcSid(sid); + if (success) { + terminatedCount = 1; + } + + // Terminate all sessions for the user + } else if (sub) { + this.logger.debug(`Terminating all sessions for user: ${sub}`, 'processBackchannelLogout'); + try { + const identity = await this.identityService.getIdentityFromUserIdAndProviderType( + sub, + AuthProviderType.OIDC, + oidcIdentifier, + ); + terminatedCount = await this.sessionService.terminateAllSessionsOfUser( + identity[FieldNameIdentity.userId], + ); + } catch (error) { + if (error instanceof NotInDBError) { + this.logger.debug( + `User with sub ${sub} not found in database`, + 'processBackchannelLogout', + ); + } else { + throw error; + } + } + } + + this.logger.debug( + `Backchannel logout: Terminated ${terminatedCount} session(s)`, + 'processBackchannelLogout', + ); + } catch (error) { + if (error instanceof BadRequestException) { + throw error; + } + this.logger.error( + `Failed to validate logout token: ${String(error)}`, + undefined, + 'processBackchannelLogout', + ); + throw new BadRequestException('Invalid logout token'); + } + } } diff --git a/backend/src/dtos/backchannel-logout.dto.ts b/backend/src/dtos/backchannel-logout.dto.ts new file mode 100644 index 000000000..02ae61675 --- /dev/null +++ b/backend/src/dtos/backchannel-logout.dto.ts @@ -0,0 +1,10 @@ +/* + * SPDX-FileCopyrightText: 2026 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { BackchannelLogoutSchema } from '@hedgedoc/commons'; +import { createZodDto } from 'nestjs-zod'; + +export class BackchannelLogoutDto extends createZodDto(BackchannelLogoutSchema) {} diff --git a/backend/src/sessions/knex-session-store.ts b/backend/src/sessions/knex-session-store.ts index 7b28652f5..fc2666e93 100644 --- a/backend/src/sessions/knex-session-store.ts +++ b/backend/src/sessions/knex-session-store.ts @@ -18,6 +18,10 @@ export interface SessionStoreOptions { knex: Knex; } +export interface FastifySessionWithId extends FastifySession { + id: string; +} + /** * Fastify session store implementation using Knex for database operations */ @@ -54,6 +58,7 @@ export class KnexSessionStore implements SessionStore { ): void { const nowDbTime = dateTimeToDB(getCurrentDateTime()); this.knex(TableSession) + .select() .where(FieldNameSession.id, sessionId) .andWhere(FieldNameSession.expiresAt, '>', nowDbTime) .first() @@ -64,6 +69,44 @@ export class KnexSessionStore implements SessionStore { .catch((error: Error) => callback(error)); } + /** + * Retrieves all sessions for a given user ID + * @param userId The ID of the user to retrieve sessions for + * @returns A promise that resolves to an array of sessions for the user + */ + async getAllByUser(userId: number): Promise { + const nowDbTime = dateTimeToDB(getCurrentDateTime()); + const entries = await this.knex(TableSession) + .select() + .where(FieldNameSession.userId, userId) + .andWhere(FieldNameSession.expiresAt, '>', nowDbTime); + return entries.map((entry) => ({ + ...this.convertDatabaseEntryToSession(entry), + id: entry[FieldNameSession.id], + })); + } + + /** + * Retrieves a session based on the OIDC session id (sid) + * @param oidcSid The OIDC session id (sid) to retrieve the session for + * @returns The session if found, otherwise null + */ + async getByOidcSid(oidcSid: string): Promise { + const nowDbTime = dateTimeToDB(getCurrentDateTime()); + const entry = await this.knex(TableSession) + .select() + .where(FieldNameSession.oidcSid, oidcSid) + .andWhere(FieldNameSession.expiresAt, '>', nowDbTime) + .first(); + if (entry === undefined) { + return null; + } + return { + ...this.convertDatabaseEntryToSession(entry), + id: entry[FieldNameSession.id], + }; + } + /** * Set a session by its session ID * @param sessionId The session ID to set the session for @@ -92,6 +135,8 @@ export class KnexSessionStore implements SessionStore { .catch((error: Error) => callback(error)); } + private convertDatabaseEntryToSession(dbEntry: Session): FastifySession; + private convertDatabaseEntryToSession(dbEntry: Session | null): FastifySession | null; private convertDatabaseEntryToSession(dbEntry: Session | null): FastifySession | null { if (dbEntry === null) { return null; diff --git a/backend/src/sessions/session.service.ts b/backend/src/sessions/session.service.ts index a6713cbec..37b02c091 100644 --- a/backend/src/sessions/session.service.ts +++ b/backend/src/sessions/session.service.ts @@ -78,6 +78,38 @@ export class SessionService { return this.extractVerifiedSessionIdFromCookieContent(sessionCookie); } + /** + * Terminates the session with the given OIDC session id (sid) + * + * @param sid The OIDC session id to terminate + * @returns A promise that resolves to true if the session was terminated successfully, false otherwise + */ + async terminateSessionByOidcSid(sid: string): Promise { + const session = await this.sessionStore.getByOidcSid(sid); + if (session === null) { + return false; + } + const sessionId = session.id; + await promisify(this.sessionStore.destroy.bind(this.sessionStore))(sessionId); + return true; + } + + /** + * Terminates all sessions initiated by a specific user id + * @param userId The user id of the user whose sessions should be terminated + * @returns The number of terminated sessions + */ + async terminateAllSessionsOfUser(userId: number): Promise { + const sessions = await this.sessionStore.getAllByUser(userId); + const sessionIds = sessions.map((session) => session.id); + await Promise.all( + sessionIds.map((sessionId) => + promisify(this.sessionStore.destroy.bind(this.sessionStore))(sessionId), + ), + ); + return sessionIds.length; + } + /** * Parses the given session cookie content and extracts the session id * diff --git a/backend/test/private-api/private-api.csrf.e2e-spec.ts b/backend/test/private-api/private-api.csrf.e2e-spec.ts index a71732ff3..7621061db 100644 --- a/backend/test/private-api/private-api.csrf.e2e-spec.ts +++ b/backend/test/private-api/private-api.csrf.e2e-spec.ts @@ -68,5 +68,14 @@ describe('CSRF Protection', () => { it('allows GET requests without CSRF token', async () => { await agentUser1.get(`${PRIVATE_API_PREFIX}/me`).expect(200); }); + + // The OIDC-backchannel logout route is not CSRF-protected, so we expect 404 (provider not found) + // instead of 403 (CSRF rejection). This proves CSRF protection was bypassed. + it('allows CSRF-exempt endpoints without CSRF token', async () => { + await agentUser1WithoutCsrf + .post(`${PRIVATE_API_PREFIX}/auth/oidc/test-provider/backchannel-logout`) + .send({ logout_token: 'test-token' }) + .expect(404); + }); }); }); diff --git a/commons/src/dtos/auth/backchannel-logout.dto.ts b/commons/src/dtos/auth/backchannel-logout.dto.ts new file mode 100644 index 000000000..f579c46a4 --- /dev/null +++ b/commons/src/dtos/auth/backchannel-logout.dto.ts @@ -0,0 +1,18 @@ +/* + * SPDX-FileCopyrightText: 2026 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { z } from 'zod' + +// Note that this schema needs to match the OIDC backchannel logout spec +// https://openid.net/specs/openid-connect-backchannel-1_0.html#BCRequest + +export const BackchannelLogoutSchema = z + .object({ + logout_token: z.string().describe('The JWT logout token from the OIDC provider'), + }) + .describe('Request body for OIDC back-channel logout endpoint') + +export type BackchannelLogoutInterface = z.infer diff --git a/commons/src/dtos/auth/index.ts b/commons/src/dtos/auth/index.ts index 69eafb32c..ad860a6c5 100644 --- a/commons/src/dtos/auth/index.ts +++ b/commons/src/dtos/auth/index.ts @@ -10,6 +10,7 @@ export * from './ldap-login.dto.js' export * from './ldap-login-response.dto.js' export * from './login.dto.js' export * from './logout-response.dto.js' +export * from './backchannel-logout.dto.js' export * from './pending-user-confirmation.dto.js' export * from './auth-provider-type.enum.js' export * from './register.dto.js' diff --git a/docs/content/references/config/auth/oidc.md b/docs/content/references/config/auth/oidc.md index a4a38adb8..68e2f5a31 100644 --- a/docs/content/references/config/auth/oidc.md +++ b/docs/content/references/config/auth/oidc.md @@ -24,8 +24,26 @@ As redirect URL you should configure `https://hedgedoc.example.com/api/private/auth/oidc/$NAME/callback` where `$NAME` is the identifier of the OIDC server. Remember to update the domain to your one. -You can also configure servers that only support plain OAuth2 but -no OIDC (e.g., GitHub or Discord). In this case, you need the following additional variables: +## Back-Channel Logout + +HedgeDoc supports +[OpenID Connect Back-Channel Logout 1.0](https://openid.net/specs/openid-connect-backchannel-1_0.html) +which allows OIDC providers to directly notify HedgeDoc when a user logs out at the provider side. +This ensures that user sessions are terminated immediately when they log out from the identity +provider (Single Sign-Out). + +To enable back-channel logout, you need to register the back-channel logout URI at your +OIDC provider: +`https://hedgedoc.example.com/api/private/auth/oidc/$NAME/backchannel-logout` + +Replace `$NAME` with the identifier of the OIDC server. Update your domain as well. + +No configuration is needed on the HedgeDoc side. + +## OAuth2 fallback for non-OIDC-compliant servers + +You can also configure servers that only support plain OAuth2 but no OIDC (e.g., GitHub or Discord). +In this case, you need the following additional variables: | environment variable | default | example | description | |--------------------------------------------|----------------------|--------------------------------------------|------------------------------------------------------------------------------------------| diff --git a/yarn.lock b/yarn.lock index b20359969..be97ddb47 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2902,6 +2902,7 @@ __metadata: file-type: "npm:16.5.4" htmlparser2: "npm:9.1.0" jest: "npm:29.7.0" + jose: "npm:5.10.0" keyv: "npm:5.6.0" knex: "npm:3.1.0" knex-mock-client: "npm:3.0.2" @@ -12212,6 +12213,13 @@ __metadata: languageName: node linkType: hard +"jose@npm:5.10.0": + version: 5.10.0 + resolution: "jose@npm:5.10.0" + checksum: 10c0/e20d9fc58d7e402f2e5f04e824b8897d5579aae60e64cb88ebdea1395311c24537bf4892f7de413fab1acf11e922797fb1b42269bc8fc65089a3749265ccb7b0 + languageName: node + linkType: hard + "jose@npm:^4.15.9": version: 4.15.9 resolution: "jose@npm:4.15.9"