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

Signed-off-by: Erik Michelson <github@erik.michelson.eu>
This commit is contained in:
Erik Michelson
2026-04-14 08:18:18 +02:00
parent 2d80f3b045
commit 475231b39a
12 changed files with 607 additions and 3 deletions
+1
View File
@@ -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');
}
}
}
+318
View File
@@ -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();
});
});
});
+112 -1
View File
@@ -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;
+32
View File
@@ -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>
+1
View File
@@ -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'
+20 -2
View File
@@ -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 |
|--------------------------------------------|----------------------|--------------------------------------------|------------------------------------------------------------------------------------------|
+8
View File
@@ -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"