From bc44ca7549f3c1111fd7ab99b3e4990f6baf2a15 Mon Sep 17 00:00:00 2001 From: Erik Michelson Date: Wed, 11 Feb 2026 01:43:11 +0100 Subject: [PATCH] wip: feat(scim): add SCIM 2.0 user provisioning/deprovisioning Signed-off-by: Erik Michelson --- backend/package.json | 1 + backend/src/api/scim/scim-api.module.ts | 19 + backend/src/api/scim/scim-auth.guard.ts | 62 ++++ .../src/api/scim/scim-discovery.controller.ts | 36 ++ backend/src/api/scim/scim.service.ts | 338 ++++++++++++++++++ .../api/scim/users/scim-users.controller.ts | 88 +++++ backend/src/app-init.ts | 12 + backend/src/app.module.ts | 9 + backend/src/config/scim.config.ts | 33 ++ docs/content/references/config/scim.md | 101 ++++++ docs/mkdocs.yml | 1 + yarn.lock | 8 + 12 files changed, 708 insertions(+) create mode 100644 backend/src/api/scim/scim-api.module.ts create mode 100644 backend/src/api/scim/scim-auth.guard.ts create mode 100644 backend/src/api/scim/scim-discovery.controller.ts create mode 100644 backend/src/api/scim/scim.service.ts create mode 100644 backend/src/api/scim/users/scim-users.controller.ts create mode 100644 backend/src/config/scim.config.ts create mode 100644 docs/content/references/config/scim.md diff --git a/backend/package.json b/backend/package.json index 2e3248f4e..4cd55a284 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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", diff --git a/backend/src/api/scim/scim-api.module.ts b/backend/src/api/scim/scim-api.module.ts new file mode 100644 index 000000000..59a55edbe --- /dev/null +++ b/backend/src/api/scim/scim-api.module.ts @@ -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 {} diff --git a/backend/src/api/scim/scim-auth.guard.ts b/backend/src/api/scim/scim-auth.guard.ts new file mode 100644 index 000000000..af6b49483 --- /dev/null +++ b/backend/src/api/scim/scim-auth.guard.ts @@ -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(); + 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; + } + } +} diff --git a/backend/src/api/scim/scim-discovery.controller.ts b/backend/src/api/scim/scim-discovery.controller.ts new file mode 100644 index 000000000..0192a505b --- /dev/null +++ b/backend/src/api/scim/scim-discovery.controller.ts @@ -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); + } +} diff --git a/backend/src/api/scim/scim.service.ts b/backend/src/api/scim/scim.service.ts new file mode 100644 index 000000000..a95163604 --- /dev/null +++ b/backend/src/api/scim/scim.service.ts @@ -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, + SCIMMY.Schemas.User + >, + ) + .egress( + this.handleEgress.bind(this) as SCIMMY.Types.Resource.EgressHandler< + InstanceType, + SCIMMY.Schemas.User + >, + ) + .degress(this.handleDegress.bind(this)); + } + + /** + * Handles SCIM user creation and update (ingress) + */ + private async handleIngress( + resource: InstanceType, + instance: SCIMMY.Schemas.User, + ): Promise> { + try { + const data = instance as unknown as Record; + 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, + ): Promise | Record[]> { + 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): Promise { + 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): Promise> { + 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, + ): Promise> { + 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[]> { + const users = await this.knex(TableUser).select().whereNotNull(FieldNameUser.username); + return users.map((user: User) => this.userToScimResource(user)); + } + + private extractUserData(instance: Record): { + 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 | 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> | 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> | 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 { + 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 = { + 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): Promise { + const config: Record = {}; + 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, id?: string): Promise { + const resource = new SCIMMY.Resources.User(id); + return await resource.write(body); + } + + /** + * Process a SCIM patch request + */ + async patchUser(id: string, body: Record): Promise { + const resource = new SCIMMY.Resources.User(id); + return await resource.patch(body as Parameters[0]); + } + + /** + * Process a SCIM delete request + */ + async deleteUser(id: string): Promise { + const resource = new SCIMMY.Resources.User(id); + await resource.dispose(); + } + + /** + * Get SCIM ServiceProviderConfig + */ + getServiceProviderConfig(): Record { + 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; + 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(), + ); + } +} diff --git a/backend/src/api/scim/users/scim-users.controller.ts b/backend/src/api/scim/users/scim-users.controller.ts new file mode 100644 index 000000000..a89c5c0f2 --- /dev/null +++ b/backend/src/api/scim/users/scim-users.controller.ts @@ -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, + @Res() reply: FastifyReply, + ): Promise { + 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, + @Res() reply: FastifyReply, + ): Promise { + 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, + @Res() reply: FastifyReply, + ): Promise { + 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, + @Res() reply: FastifyReply, + ): Promise { + 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, + @Res() reply: FastifyReply, + ): Promise { + 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 { + await this.scimService.deleteUser(id); + void reply.status(HttpStatus.NO_CONTENT).send(); + } +} diff --git a/backend/src/app-init.ts b/backend/src/app-init.ts index e6225d51f..2ef1276f3 100644 --- a/backend/src/app-init.ts +++ b/backend/src/app-init.ts @@ -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 diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index d0225dc40..b62f00347 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -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, diff --git a/backend/src/config/scim.config.ts b/backend/src/config/scim.config.ts new file mode 100644 index 000000000..d3c7452fc --- /dev/null +++ b/backend/src/config/scim.config.ts @@ -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; + +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; +}); diff --git a/docs/content/references/config/scim.md b/docs/content/references/config/scim.md new file mode 100644 index 000000000..b8e4a6a28 --- /dev/null +++ b/docs/content/references/config/scim.md @@ -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 ` +- 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 diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index a12deb8f0..163bb5576 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -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 diff --git a/yarn.lock b/yarn.lock index 73b487ea9..b89a30c6c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"