wip: feat(scim): add SCIM 2.0 user provisioning/deprovisioning

Signed-off-by: Erik Michelson <github@erik.michelson.eu>
This commit is contained in:
Erik Michelson
2026-02-11 01:43:11 +01:00
parent 6d964c33d2
commit bc44ca7549
12 changed files with 708 additions and 0 deletions
+1
View File
@@ -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",
+19
View File
@@ -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 {}
+62
View File
@@ -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);
}
}
+338
View File
@@ -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();
}
}
+12
View File
@@ -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
+9
View File
@@ -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,
+33
View File
@@ -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;
});
+101
View File
@@ -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
+1
View File
@@ -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
+8
View File
@@ -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"