mirror of
https://github.com/hedgedoc/hedgedoc.git
synced 2026-06-23 04:10:17 +00:00
wip: feat(scim): add SCIM 2.0 user provisioning/deprovisioning
Signed-off-by: Erik Michelson <github@erik.michelson.eu>
This commit is contained in:
@@ -71,6 +71,7 @@
|
||||
"raw-body": "3.0.2",
|
||||
"reflect-metadata": "0.2.2",
|
||||
"rxjs": "7.8.2",
|
||||
"scimmy": "^1.3.5",
|
||||
"uuid": "11.1.0",
|
||||
"ws": "8.18.3",
|
||||
"yjs": "13.6.28",
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { AuthModule } from '../../auth/auth.module';
|
||||
import { UsersModule } from '../../users/users.module';
|
||||
import { ScimDiscoveryController } from './scim-discovery.controller';
|
||||
import { ScimService } from './scim.service';
|
||||
import { ScimUsersController } from './users/scim-users.controller';
|
||||
|
||||
@Module({
|
||||
imports: [UsersModule, AuthModule],
|
||||
controllers: [ScimUsersController, ScimDiscoveryController],
|
||||
providers: [ScimService],
|
||||
})
|
||||
export class ScimApiModule {}
|
||||
@@ -0,0 +1,62 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
Inject,
|
||||
Injectable,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { timingSafeEqual } from 'crypto';
|
||||
import type { FastifyRequest } from 'fastify';
|
||||
|
||||
import scimConfig, { ScimConfig } from '../../config/scim.config';
|
||||
import { ConsoleLoggerService } from '../../logger/console-logger.service';
|
||||
|
||||
@Injectable()
|
||||
export class ScimAuthGuard implements CanActivate {
|
||||
constructor(
|
||||
private readonly logger: ConsoleLoggerService,
|
||||
|
||||
@Inject(scimConfig.KEY)
|
||||
private readonly scimConfiguration: ScimConfig,
|
||||
) {
|
||||
this.logger.setContext(ScimAuthGuard.name);
|
||||
}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
if (!this.scimConfiguration.bearerToken) {
|
||||
this.logger.debug('SCIM is disabled because no bearer token is configured.');
|
||||
throw new UnauthorizedException('SCIM is not enabled');
|
||||
}
|
||||
|
||||
const request = context.switchToHttp().getRequest<FastifyRequest>();
|
||||
const authHeader = request.headers.authorization;
|
||||
if (!authHeader?.startsWith('Bearer ')) {
|
||||
throw new UnauthorizedException('Missing or invalid Authorization header');
|
||||
}
|
||||
|
||||
const token = authHeader.slice(7);
|
||||
if (!this.timingSafeCompare(token, this.scimConfiguration.bearerToken)) {
|
||||
throw new UnauthorizedException('Invalid SCIM bearer token');
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private timingSafeCompare(a: string, b: string): boolean {
|
||||
try {
|
||||
const bufA = Buffer.from(a, 'utf8');
|
||||
const bufB = Buffer.from(b, 'utf8');
|
||||
if (bufA.length !== bufB.length) {
|
||||
return false;
|
||||
}
|
||||
return timingSafeEqual(bufA, bufB);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { Controller, Get, Res, UseGuards } from '@nestjs/common';
|
||||
import type { FastifyReply } from 'fastify';
|
||||
|
||||
import { ScimAuthGuard } from './scim-auth.guard';
|
||||
import { ScimService } from './scim.service';
|
||||
|
||||
const SCIM_CONTENT_TYPE = 'application/scim+json';
|
||||
|
||||
@UseGuards(ScimAuthGuard)
|
||||
@Controller()
|
||||
export class ScimDiscoveryController {
|
||||
constructor(private readonly scimService: ScimService) {}
|
||||
|
||||
@Get('ServiceProviderConfig')
|
||||
getServiceProviderConfig(@Res() reply: FastifyReply): void {
|
||||
const result = this.scimService.getServiceProviderConfig();
|
||||
void reply.header('Content-Type', SCIM_CONTENT_TYPE).send(result);
|
||||
}
|
||||
|
||||
@Get('ResourceTypes')
|
||||
getResourceTypes(@Res() reply: FastifyReply): void {
|
||||
const result = this.scimService.getResourceTypes();
|
||||
void reply.header('Content-Type', SCIM_CONTENT_TYPE).send(result);
|
||||
}
|
||||
|
||||
@Get('Schemas')
|
||||
getSchemas(@Res() reply: FastifyReply): void {
|
||||
const result = this.scimService.getSchemas();
|
||||
void reply.header('Content-Type', SCIM_CONTENT_TYPE).send(result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { AuthProviderType } from '@hedgedoc/commons';
|
||||
import { FieldNameUser, TableUser, User } from '@hedgedoc/database';
|
||||
import { Inject, Injectable, OnModuleInit } from '@nestjs/common';
|
||||
import { Knex } from 'knex';
|
||||
import { InjectConnection } from 'nest-knexjs';
|
||||
import SCIMMY from 'scimmy';
|
||||
|
||||
import { IdentityService } from '../../auth/identity.service';
|
||||
import scimConfig, { ScimConfig } from '../../config/scim.config';
|
||||
import { ConsoleLoggerService } from '../../logger/console-logger.service';
|
||||
import { UsersService } from '../../users/users.service';
|
||||
|
||||
@Injectable()
|
||||
export class ScimService implements OnModuleInit {
|
||||
constructor(
|
||||
private readonly logger: ConsoleLoggerService,
|
||||
private readonly usersService: UsersService,
|
||||
private readonly identityService: IdentityService,
|
||||
|
||||
@Inject(scimConfig.KEY)
|
||||
private readonly scimConfiguration: ScimConfig,
|
||||
|
||||
@InjectConnection()
|
||||
private readonly knex: Knex,
|
||||
) {
|
||||
this.logger.setContext(ScimService.name);
|
||||
}
|
||||
|
||||
onModuleInit(): void {
|
||||
SCIMMY.Resources.declare(SCIMMY.Resources.User)
|
||||
.ingress(
|
||||
this.handleIngress.bind(this) as SCIMMY.Types.Resource.IngressHandler<
|
||||
InstanceType<typeof SCIMMY.Resources.User>,
|
||||
SCIMMY.Schemas.User
|
||||
>,
|
||||
)
|
||||
.egress(
|
||||
this.handleEgress.bind(this) as SCIMMY.Types.Resource.EgressHandler<
|
||||
InstanceType<typeof SCIMMY.Resources.User>,
|
||||
SCIMMY.Schemas.User
|
||||
>,
|
||||
)
|
||||
.degress(this.handleDegress.bind(this));
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles SCIM user creation and update (ingress)
|
||||
*/
|
||||
private async handleIngress(
|
||||
resource: InstanceType<typeof SCIMMY.Resources.User>,
|
||||
instance: SCIMMY.Schemas.User,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const data = instance as unknown as Record<string, unknown>;
|
||||
if (resource.id) {
|
||||
return await this.updateUser(resource.id, data);
|
||||
} else {
|
||||
return await this.createUser(data);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof SCIMMY.Types.Error) {
|
||||
throw error;
|
||||
}
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
if (errorMessage.includes('not found') || errorMessage.includes('does not exist')) {
|
||||
throw new SCIMMY.Types.Error(404, '', `Resource ${resource.id} not found`);
|
||||
}
|
||||
if (errorMessage.includes('already') || errorMessage.includes('taken')) {
|
||||
throw new SCIMMY.Types.Error(409, 'uniqueness', errorMessage);
|
||||
}
|
||||
throw new SCIMMY.Types.Error(500, '', errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles SCIM user retrieval (egress)
|
||||
*/
|
||||
private async handleEgress(
|
||||
resource: InstanceType<typeof SCIMMY.Resources.User>,
|
||||
): Promise<Record<string, unknown> | Record<string, unknown>[]> {
|
||||
try {
|
||||
if (resource.id) {
|
||||
const user = await this.usersService.getUserById(Number(resource.id));
|
||||
return this.userToScimResource(user);
|
||||
} else {
|
||||
return await this.listUsers();
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof SCIMMY.Types.Error) {
|
||||
throw error;
|
||||
}
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
if (errorMessage.includes('not found') || errorMessage.includes('does not exist')) {
|
||||
throw new SCIMMY.Types.Error(404, '', `Resource ${resource.id} not found`);
|
||||
}
|
||||
throw new SCIMMY.Types.Error(500, '', errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles SCIM user deletion (degress)
|
||||
*/
|
||||
private async handleDegress(resource: InstanceType<typeof SCIMMY.Resources.User>): Promise<void> {
|
||||
try {
|
||||
await this.usersService.deleteUser(Number(resource.id));
|
||||
} catch (error) {
|
||||
if (error instanceof SCIMMY.Types.Error) {
|
||||
throw error;
|
||||
}
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
if (errorMessage.includes('not found') || errorMessage.includes('does not exist')) {
|
||||
throw new SCIMMY.Types.Error(404, '', `Resource ${resource.id} not found`);
|
||||
}
|
||||
throw new SCIMMY.Types.Error(500, '', errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
private async createUser(instance: Record<string, unknown>): Promise<Record<string, unknown>> {
|
||||
const { username, displayName, email, photoUrl } = this.extractUserData(instance);
|
||||
|
||||
if (!username) {
|
||||
throw new SCIMMY.Types.Error(400, 'invalidValue', 'userName is required');
|
||||
}
|
||||
|
||||
const userId = await this.identityService.createUserWithIdentity(
|
||||
AuthProviderType.OIDC,
|
||||
this.scimConfiguration.providerIdentifier,
|
||||
username,
|
||||
username,
|
||||
displayName ?? username,
|
||||
email,
|
||||
photoUrl,
|
||||
);
|
||||
const user = await this.usersService.getUserById(userId);
|
||||
return this.userToScimResource(user);
|
||||
}
|
||||
|
||||
private async updateUser(
|
||||
id: string,
|
||||
instance: Record<string, unknown>,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const userId = Number(id);
|
||||
const { displayName, email, photoUrl } = this.extractUserData(instance);
|
||||
|
||||
const active = instance.active;
|
||||
if (active === false) {
|
||||
await this.usersService.deleteUser(userId);
|
||||
throw new SCIMMY.Types.Error(404, '', `Resource ${id} not found`);
|
||||
}
|
||||
|
||||
await this.usersService.updateUser(
|
||||
userId,
|
||||
displayName ?? undefined,
|
||||
email ?? undefined,
|
||||
photoUrl ?? undefined,
|
||||
);
|
||||
const user = await this.usersService.getUserById(userId);
|
||||
return this.userToScimResource(user);
|
||||
}
|
||||
|
||||
private async listUsers(): Promise<Record<string, unknown>[]> {
|
||||
const users = await this.knex(TableUser).select().whereNotNull(FieldNameUser.username);
|
||||
return users.map((user: User) => this.userToScimResource(user));
|
||||
}
|
||||
|
||||
private extractUserData(instance: Record<string, unknown>): {
|
||||
username: string | null;
|
||||
displayName: string | null;
|
||||
email: string | null;
|
||||
photoUrl: string | null;
|
||||
} {
|
||||
const userName = instance.userName as string | undefined;
|
||||
|
||||
let displayName: string | null = null;
|
||||
const name = instance.name as Record<string, string> | undefined;
|
||||
if (instance.displayName) {
|
||||
displayName = instance.displayName as string;
|
||||
} else if (name) {
|
||||
const parts = [name.givenName, name.familyName].filter(Boolean);
|
||||
displayName = parts.length > 0 ? parts.join(' ') : null;
|
||||
}
|
||||
|
||||
let email: string | null = null;
|
||||
const emails = instance.emails as Array<Record<string, unknown>> | undefined;
|
||||
if (emails && emails.length > 0) {
|
||||
const primaryEmail = emails.find((e) => e.primary === true) ?? emails[0];
|
||||
email = (primaryEmail.value as string) ?? null;
|
||||
}
|
||||
|
||||
let photoUrl: string | null = null;
|
||||
const photos = instance.photos as Array<Record<string, unknown>> | undefined;
|
||||
if (photos && photos.length > 0) {
|
||||
const primaryPhoto = photos.find((p) => p.primary === true) ?? photos[0];
|
||||
photoUrl = (primaryPhoto.value as string) ?? null;
|
||||
}
|
||||
|
||||
return { username: userName ?? null, displayName, email, photoUrl };
|
||||
}
|
||||
|
||||
private userToScimResource(user: User): Record<string, unknown> {
|
||||
const id = String(user[FieldNameUser.id]);
|
||||
const username = user[FieldNameUser.username] ?? id;
|
||||
const displayName = user[FieldNameUser.displayName];
|
||||
const email = user[FieldNameUser.email];
|
||||
const photoUrl = user[FieldNameUser.photoUrl];
|
||||
|
||||
const resource: Record<string, unknown> = {
|
||||
id,
|
||||
userName: username,
|
||||
displayName: displayName ?? username,
|
||||
active: true,
|
||||
meta: {
|
||||
resourceType: 'User',
|
||||
created: user[FieldNameUser.createdAt],
|
||||
lastModified: user[FieldNameUser.createdAt],
|
||||
},
|
||||
};
|
||||
|
||||
if (email) {
|
||||
resource.emails = [{ value: email, primary: true }];
|
||||
}
|
||||
|
||||
if (photoUrl) {
|
||||
resource.photos = [{ value: photoUrl, type: 'photo' }];
|
||||
}
|
||||
|
||||
const nameParts = (displayName ?? username).split(' ');
|
||||
resource.name = {
|
||||
formatted: displayName ?? username,
|
||||
givenName: nameParts[0],
|
||||
familyName: nameParts.length > 1 ? nameParts.slice(1).join(' ') : nameParts[0],
|
||||
};
|
||||
|
||||
return resource;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a SCIM read request (GET single or list)
|
||||
*/
|
||||
async readUser(id?: string, query?: Record<string, string>): Promise<unknown> {
|
||||
const config: Record<string, unknown> = {};
|
||||
if (query?.filter) {
|
||||
config.filter = query.filter;
|
||||
}
|
||||
if (query?.sortBy) {
|
||||
config.sortBy = query.sortBy;
|
||||
}
|
||||
if (query?.sortOrder) {
|
||||
config.sortOrder = query.sortOrder;
|
||||
}
|
||||
if (query?.startIndex) {
|
||||
config.startIndex = Number(query.startIndex);
|
||||
}
|
||||
if (query?.count) {
|
||||
config.count = Number(query.count);
|
||||
}
|
||||
if (query?.attributes) {
|
||||
config.attributes = query.attributes;
|
||||
}
|
||||
if (query?.excludedAttributes) {
|
||||
config.excludedAttributes = query.excludedAttributes;
|
||||
}
|
||||
|
||||
const resource = new SCIMMY.Resources.User(id, config);
|
||||
return await resource.read();
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a SCIM write request (POST or PUT)
|
||||
*/
|
||||
async writeUser(body: Record<string, unknown>, id?: string): Promise<unknown> {
|
||||
const resource = new SCIMMY.Resources.User(id);
|
||||
return await resource.write(body);
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a SCIM patch request
|
||||
*/
|
||||
async patchUser(id: string, body: Record<string, unknown>): Promise<unknown> {
|
||||
const resource = new SCIMMY.Resources.User(id);
|
||||
return await resource.patch(body as Parameters<typeof resource.patch>[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a SCIM delete request
|
||||
*/
|
||||
async deleteUser(id: string): Promise<void> {
|
||||
const resource = new SCIMMY.Resources.User(id);
|
||||
await resource.dispose();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get SCIM ServiceProviderConfig
|
||||
*/
|
||||
getServiceProviderConfig(): Record<string, unknown> {
|
||||
return {
|
||||
schemas: ['urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig'],
|
||||
documentationUri: 'https://docs.hedgedoc.org',
|
||||
patch: { supported: true },
|
||||
bulk: { supported: false, maxOperations: 0, maxPayloadSize: 0 },
|
||||
filter: { supported: true, maxResults: 200 },
|
||||
changePassword: { supported: false },
|
||||
sort: { supported: true },
|
||||
etag: { supported: false },
|
||||
authenticationSchemes: [
|
||||
{
|
||||
type: 'oauthbearertoken',
|
||||
name: 'OAuth Bearer Token',
|
||||
description: 'Authentication scheme using the OAuth Bearer Token standard',
|
||||
specUri: 'https://www.rfc-editor.org/info/rfc6750',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get SCIM ResourceTypes
|
||||
*/
|
||||
getResourceTypes(): unknown[] {
|
||||
const declared = SCIMMY.Resources.declared() as Record<string, typeof SCIMMY.Types.Resource>;
|
||||
return Object.values(declared).map((Resource) => Resource.describe());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get SCIM Schemas
|
||||
*/
|
||||
getSchemas(): unknown[] {
|
||||
const declared = SCIMMY.Schemas.declared();
|
||||
return (declared as unknown as SCIMMY.Types.SchemaDefinition[]).map((schema) =>
|
||||
schema.describe(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Put,
|
||||
Query,
|
||||
Res,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import type { FastifyReply } from 'fastify';
|
||||
|
||||
import { ScimAuthGuard } from '../scim-auth.guard';
|
||||
import { ScimService } from '../scim.service';
|
||||
|
||||
const SCIM_CONTENT_TYPE = 'application/scim+json';
|
||||
|
||||
@UseGuards(ScimAuthGuard)
|
||||
@Controller('Users')
|
||||
export class ScimUsersController {
|
||||
constructor(private readonly scimService: ScimService) {}
|
||||
|
||||
@Get()
|
||||
async listUsers(
|
||||
@Query() query: Record<string, string>,
|
||||
@Res() reply: FastifyReply,
|
||||
): Promise<void> {
|
||||
const result = await this.scimService.readUser(undefined, query);
|
||||
void reply.header('Content-Type', SCIM_CONTENT_TYPE).send(result);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
async getUser(
|
||||
@Param('id') id: string,
|
||||
@Query() query: Record<string, string>,
|
||||
@Res() reply: FastifyReply,
|
||||
): Promise<void> {
|
||||
const result = await this.scimService.readUser(id, query);
|
||||
void reply.header('Content-Type', SCIM_CONTENT_TYPE).send(result);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
async createUser(
|
||||
@Body() body: Record<string, unknown>,
|
||||
@Res() reply: FastifyReply,
|
||||
): Promise<void> {
|
||||
const result = await this.scimService.writeUser(body);
|
||||
void reply.status(HttpStatus.CREATED).header('Content-Type', SCIM_CONTENT_TYPE).send(result);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
async replaceUser(
|
||||
@Param('id') id: string,
|
||||
@Body() body: Record<string, unknown>,
|
||||
@Res() reply: FastifyReply,
|
||||
): Promise<void> {
|
||||
const result = await this.scimService.writeUser(body, id);
|
||||
void reply.header('Content-Type', SCIM_CONTENT_TYPE).send(result);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
async patchUser(
|
||||
@Param('id') id: string,
|
||||
@Body() body: Record<string, unknown>,
|
||||
@Res() reply: FastifyReply,
|
||||
): Promise<void> {
|
||||
const result = await this.scimService.patchUser(id, body);
|
||||
void reply.header('Content-Type', SCIM_CONTENT_TYPE).send(result);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
async deleteUser(@Param('id') id: string, @Res() reply: FastifyReply): Promise<void> {
|
||||
await this.scimService.deleteUser(id);
|
||||
void reply.status(HttpStatus.NO_CONTENT).send();
|
||||
}
|
||||
}
|
||||
@@ -67,6 +67,18 @@ export async function setupApp(
|
||||
},
|
||||
);
|
||||
|
||||
// Register content-type parser for application/scim+json (SCIM 2.0)
|
||||
app
|
||||
.getHttpAdapter()
|
||||
.getInstance()
|
||||
.addContentTypeParser(
|
||||
'application/scim+json',
|
||||
{ parseAs: 'string' },
|
||||
(_req: unknown, body: unknown, done: (err: Error | null, body: unknown) => void) => {
|
||||
done(null, typeof body === 'string' ? JSON.parse(body) : body);
|
||||
},
|
||||
);
|
||||
|
||||
await runMigrations(app as INestApplication, logger);
|
||||
|
||||
// Setup session handling
|
||||
|
||||
@@ -17,6 +17,7 @@ import { ApiTokenModule } from './api-token/api-token.module';
|
||||
import { CsrfGuard } from './api/private/csrf/csrf.guard';
|
||||
import { PrivateApiModule } from './api/private/private-api.module';
|
||||
import { PublicApiModule } from './api/public/public-api.module';
|
||||
import { ScimApiModule } from './api/scim/scim-api.module';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
import appConfig, { AppConfig } from './config/app.config';
|
||||
import authConfig from './config/auth.config';
|
||||
@@ -27,6 +28,7 @@ import externalConfig from './config/external-services.config';
|
||||
import { Loglevel } from './config/loglevel.enum';
|
||||
import mediaConfig from './config/media.config';
|
||||
import noteConfig from './config/note.config';
|
||||
import scimConfig from './config/scim.config';
|
||||
import securityConfig from './config/security.config';
|
||||
import { eventModuleConfig } from './events';
|
||||
import { ExploreModule } from './explore/explore.module';
|
||||
@@ -48,6 +50,7 @@ import { isDevMode } from './utils/dev-mode';
|
||||
|
||||
export const PUBLIC_API_PREFIX = '/api/v2';
|
||||
export const PRIVATE_API_PREFIX = '/api/private';
|
||||
export const SCIM_API_PREFIX = '/api/scim/v2';
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
@@ -58,6 +61,10 @@ const routes: Routes = [
|
||||
path: PRIVATE_API_PREFIX,
|
||||
module: PrivateApiModule,
|
||||
},
|
||||
{
|
||||
path: SCIM_API_PREFIX,
|
||||
module: ScimApiModule,
|
||||
},
|
||||
{
|
||||
path: '/media',
|
||||
module: MediaRedirectModule,
|
||||
@@ -103,6 +110,7 @@ const routes: Routes = [
|
||||
customizationConfig,
|
||||
externalConfig,
|
||||
securityConfig,
|
||||
scimConfig,
|
||||
],
|
||||
isGlobal: true,
|
||||
}),
|
||||
@@ -113,6 +121,7 @@ const routes: Routes = [
|
||||
RevisionsModule,
|
||||
PublicApiModule,
|
||||
PrivateApiModule,
|
||||
ScimApiModule,
|
||||
MonitoringModule,
|
||||
PermissionsModule,
|
||||
GroupsModule,
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { registerAs } from '@nestjs/config';
|
||||
import z from 'zod';
|
||||
|
||||
import { buildErrorMessage, extractDescriptionFromZodIssue } from './zod-error-message';
|
||||
import { printConfigErrorAndExit } from './utils';
|
||||
|
||||
const scimConfigSchema = z.object({
|
||||
bearerToken: z.string().min(1).optional().describe('HD_SCIM_BEARER_TOKEN'),
|
||||
providerIdentifier: z.string().min(1).default('scim').describe('HD_SCIM_PROVIDER_IDENTIFIER'),
|
||||
});
|
||||
|
||||
export type ScimConfig = z.infer<typeof scimConfigSchema>;
|
||||
|
||||
export default registerAs('scimConfig', () => {
|
||||
const scimConfig = scimConfigSchema.safeParse({
|
||||
bearerToken: process.env.HD_SCIM_BEARER_TOKEN,
|
||||
providerIdentifier: process.env.HD_SCIM_PROVIDER_IDENTIFIER,
|
||||
});
|
||||
|
||||
if (scimConfig.error) {
|
||||
const errorMessages = scimConfig.error.errors.map((issue) =>
|
||||
extractDescriptionFromZodIssue(issue, 'HD_SCIM'),
|
||||
);
|
||||
const errorMessage = buildErrorMessage(errorMessages);
|
||||
return printConfigErrorAndExit(errorMessage);
|
||||
}
|
||||
return scimConfig.data;
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
# SCIM
|
||||
|
||||
HedgeDoc supports [SCIM 2.0](https://datatracker.ietf.org/doc/html/rfc7644) (System for Cross-domain Identity Management) for automated user provisioning and deprovisioning. This allows identity providers like Microsoft Entra ID (formerly Azure AD), Okta, or other SCIM-compatible systems to automatically manage user accounts in HedgeDoc.
|
||||
|
||||
## Configuration
|
||||
|
||||
SCIM support is disabled by default. To enable it, configure the following environment variables:
|
||||
|
||||
| environment variable | default | example | description |
|
||||
|-------------------------------|---------|---------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `HD_SCIM_BEARER_TOKEN` | - | `your-secret-bearer-token-here` | The bearer token used to authenticate SCIM requests. **Required** to enable SCIM. If not set, SCIM endpoints will return 401 Unauthorized. |
|
||||
| `HD_SCIM_PROVIDER_IDENTIFIER` | `scim` | `entra-id`, `okta`, `custom-idp` | Identifier for the SCIM provider. Used to distinguish identities created via SCIM from other authentication methods. |
|
||||
|
||||
## Security considerations
|
||||
|
||||
- **Bearer token**: Use a strong, randomly generated token (e.g., 32+ characters). Keep this token secret and rotate it periodically.
|
||||
- **HTTPS only**: SCIM endpoints should only be exposed over HTTPS. Configure your reverse proxy accordingly.
|
||||
- **Network access**: Consider restricting access to SCIM endpoints at the firewall/reverse proxy level to only allow connections from your identity provider's IP ranges.
|
||||
|
||||
## API endpoints
|
||||
|
||||
When SCIM is enabled, the following endpoints are available at `/api/scim/v2`:
|
||||
|
||||
### User management
|
||||
|
||||
- `GET /api/scim/v2/Users` - List all users
|
||||
- `GET /api/scim/v2/Users/:id` - Get a specific user
|
||||
- `POST /api/scim/v2/Users` - Create a new user
|
||||
- `PUT /api/scim/v2/Users/:id` - Replace a user (full update)
|
||||
- `PATCH /api/scim/v2/Users/:id` - Partially update a user
|
||||
- `DELETE /api/scim/v2/Users/:id` - Delete a user
|
||||
|
||||
### Discovery endpoints
|
||||
|
||||
- `GET /api/scim/v2/ServiceProviderConfig` - Get SCIM service provider configuration
|
||||
- `GET /api/scim/v2/ResourceTypes` - Get supported resource types
|
||||
- `GET /api/scim/v2/Schemas` - Get SCIM schemas
|
||||
|
||||
## User mapping
|
||||
|
||||
SCIM user attributes are mapped to HedgeDoc user fields as follows:
|
||||
|
||||
| SCIM attribute | HedgeDoc field | Notes |
|
||||
|------------------------|----------------|----------------------------------------------------------|
|
||||
| `id` | User ID | Internal HedgeDoc user ID (numeric, converted to string) |
|
||||
| `userName` | `username` | Required. Used as the unique username in HedgeDoc |
|
||||
| `displayName` | `displayName` | Display name shown in the UI |
|
||||
| `emails[0].value` | `email` | Primary email address |
|
||||
| `photos[0].value` | `photoUrl` | Profile picture URL |
|
||||
| `active` | - | When set to `false`, the user is deleted (deprovisioned) |
|
||||
|
||||
## Example: Microsoft Entra ID
|
||||
|
||||
1. In the Azure portal, go to **Enterprise applications** → **New application** → **Create your own application**
|
||||
2. Select **Integrate any other application you don't find in the gallery (Non-gallery)**
|
||||
3. After creating the app, go to **Provisioning** → **Get started**
|
||||
4. Set **Provisioning Mode** to **Automatic**
|
||||
5. Under **Admin Credentials**:
|
||||
- **Tenant URL**: `https://your-hedgedoc-instance.example.com/api/scim/v2`
|
||||
- **Secret Token**: The value of your `HD_SCIM_BEARER_TOKEN`
|
||||
6. Click **Test Connection** to verify
|
||||
7. Configure attribute mappings as needed (the default mappings should work)
|
||||
8. Enable provisioning and assign users/groups to the application
|
||||
|
||||
## Example: Okta
|
||||
|
||||
1. In the Okta admin console, go to **Applications** → **Create App Integration**
|
||||
2. Select **SWA - Secure Web Authentication** as the sign-in method
|
||||
3. After creating the app, go to the **Provisioning** tab
|
||||
4. Click **Configure API Integration**
|
||||
5. Enable **API Integration** and enter:
|
||||
- **SCIM Base URL**: `https://your-hedgedoc-instance.example.com/api/scim/v2`
|
||||
- **Authorization**: Use **HTTP Header** with `Bearer your-secret-bearer-token-here`
|
||||
6. Test the connection
|
||||
7. Enable the desired provisioning features (Create Users, Update User Attributes, Deactivate Users)
|
||||
8. Assign users to the application
|
||||
|
||||
## Limitations
|
||||
|
||||
- **Groups**: SCIM group management is not yet supported. Users can be assigned to HedgeDoc groups manually after provisioning.
|
||||
- **Password sync**: Passwords cannot be set or synced via SCIM. Users provisioned via SCIM should authenticate using the identity provider (e.g., via OIDC/SAML).
|
||||
- **Bulk operations**: Bulk SCIM operations are not supported.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### SCIM endpoints return 401 Unauthorized
|
||||
|
||||
- Verify that `HD_SCIM_BEARER_TOKEN` is set in your environment
|
||||
- Check that the `Authorization` header in requests uses the format: `Bearer <your-token>`
|
||||
- Ensure the token matches exactly (no extra spaces or characters)
|
||||
|
||||
### Users are not being created
|
||||
|
||||
- Check the HedgeDoc backend logs for error messages
|
||||
- Verify that the `userName` attribute is being sent in SCIM requests (it's required)
|
||||
- Ensure usernames conform to HedgeDoc's username requirements (lowercase alphanumeric, hyphens, underscores, periods; 3-64 characters)
|
||||
|
||||
### Cannot distinguish SCIM users from other users
|
||||
|
||||
- Set `HD_SCIM_PROVIDER_IDENTIFIER` to a unique value for your identity provider
|
||||
- This creates identities with that provider identifier, allowing you to identify which users were provisioned via SCIM
|
||||
@@ -46,6 +46,7 @@ nav:
|
||||
- LDAP: references/config/auth/ldap.md
|
||||
- 'OpenID Connect (OIDC)': references/config/auth/oidc.md
|
||||
- Security: references/config/security.md
|
||||
- SCIM: references/config/scim.md
|
||||
- Customization: references/config/customization.md
|
||||
- Media Backends:
|
||||
- Azure: references/config/media/azure.md
|
||||
|
||||
@@ -2921,6 +2921,7 @@ __metadata:
|
||||
raw-body: "npm:3.0.2"
|
||||
reflect-metadata: "npm:0.2.2"
|
||||
rxjs: "npm:7.8.2"
|
||||
scimmy: "npm:^1.3.5"
|
||||
source-map-support: "npm:0.5.21"
|
||||
supertest: "npm:6.3.4"
|
||||
ts-jest: "npm:29.4.6"
|
||||
@@ -15944,6 +15945,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"scimmy@npm:^1.3.5":
|
||||
version: 1.3.5
|
||||
resolution: "scimmy@npm:1.3.5"
|
||||
checksum: 10c0/c82ebb10f2856f757ec7820170400d1a0b2f22acc84ce534c0a23b2dc758688fb02b7b320392c96a3f81c84607417a8b4e871b358cf3998f5dcb82fb4aaba39e
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"screenfull@npm:^5.1.0":
|
||||
version: 5.2.0
|
||||
resolution: "screenfull@npm:5.2.0"
|
||||
|
||||
Reference in New Issue
Block a user