mirror of
https://github.com/hedgedoc/hedgedoc.git
synced 2026-06-23 04:10:17 +00:00
feat(oidc): add backchannel logout
Docker / build-and-push (backend) (push) Has been cancelled
Docker / build-and-push (frontend) (push) Has been cancelled
Deploy HD2 docs to Netlify / Deploys to netlify (push) Has been cancelled
E2E Tests / backend-sqlite (push) Has been cancelled
E2E Tests / backend-mariadb (push) Has been cancelled
E2E Tests / backend-postgres (push) Has been cancelled
Lint and check format / Lint files and check formatting (push) Has been cancelled
REUSE Compliance Check / reuse (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
Static Analysis / Njsscan code scanning (push) Has been cancelled
Static Analysis / CodeQL analysis (javascript) (push) Has been cancelled
Run tests & build / Test and build with NodeJS 24 (push) Has been cancelled
Docker / build-and-push (backend) (push) Has been cancelled
Docker / build-and-push (frontend) (push) Has been cancelled
Deploy HD2 docs to Netlify / Deploys to netlify (push) Has been cancelled
E2E Tests / backend-sqlite (push) Has been cancelled
E2E Tests / backend-mariadb (push) Has been cancelled
E2E Tests / backend-postgres (push) Has been cancelled
Lint and check format / Lint files and check formatting (push) Has been cancelled
REUSE Compliance Check / reuse (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
Static Analysis / Njsscan code scanning (push) Has been cancelled
Static Analysis / CodeQL analysis (javascript) (push) Has been cancelled
Run tests & build / Test and build with NodeJS 24 (push) Has been cancelled
Signed-off-by: Erik Michelson <github@erik.michelson.eu>
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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<void> {
|
||||
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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<IdentityService>({
|
||||
getIdentityFromUserIdAndProviderType: jest.fn(),
|
||||
}),
|
||||
},
|
||||
{
|
||||
provide: SessionService,
|
||||
useValue: Mock.of<SessionService>({
|
||||
terminateSessionByOidcSid: jest.fn(),
|
||||
terminateAllSessionsOfUser: jest.fn(),
|
||||
}),
|
||||
},
|
||||
{
|
||||
provide: ConsoleLoggerService,
|
||||
useValue: Mock.of<ConsoleLoggerService>({
|
||||
setContext: jest.fn(),
|
||||
debug: jest.fn(),
|
||||
error: jest.fn(),
|
||||
}),
|
||||
},
|
||||
{
|
||||
provide: authConfiguration.KEY,
|
||||
useValue: mockAuthConfig,
|
||||
},
|
||||
{
|
||||
provide: appConfiguration.KEY,
|
||||
useValue: mockAppConfig,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
oidcService = testModule.get<OidcService>(OidcService);
|
||||
identityService = testModule.get<IdentityService>(IdentityService);
|
||||
sessionService = testModule.get<SessionService>(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<typeof jose.jwtVerify>;
|
||||
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<typeof jose.jwtVerify>;
|
||||
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<typeof jose.jwtVerify>;
|
||||
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<typeof jose.jwtVerify>;
|
||||
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<typeof jose.jwtVerify>;
|
||||
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<typeof jose.jwtVerify>;
|
||||
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<typeof jose.jwtVerify>;
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<typeof createRemoteJWKSet>;
|
||||
}
|
||||
|
||||
@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<void> {
|
||||
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<string, unknown> | 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {}
|
||||
@@ -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<FastifySessionWithId[]> {
|
||||
const nowDbTime = dateTimeToDB(getCurrentDateTime());
|
||||
const entries = await this.knex<Session>(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<FastifySessionWithId | null> {
|
||||
const nowDbTime = dateTimeToDB(getCurrentDateTime());
|
||||
const entry = await this.knex<Session>(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;
|
||||
|
||||
@@ -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<boolean> {
|
||||
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<number> {
|
||||
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
|
||||
*
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<typeof BackchannelLogoutSchema>
|
||||
@@ -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'
|
||||
|
||||
@@ -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 |
|
||||
|--------------------------------------------|----------------------|--------------------------------------------|------------------------------------------------------------------------------------------|
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user