diff --git a/.env.example b/.env.example
index b218bdb8..6973b4c3 100644
--- a/.env.example
+++ b/.env.example
@@ -56,3 +56,20 @@ DEBUG_DB=false
# Log http requests
LOG_HTTP=false
+
+# ─── OIDC (Authentik) — Bloc 4b ──────────────────────────────────────
+# Disabled by default. Set OIDC_ENABLED=true and fill the block below
+# to expose /api/auth/oidc/login and the SSO button on the login page.
+#
+# OIDC_ENABLED=true
+# OIDC_ISSUER=https://auth.example.com/application/o/docadenice/
+# OIDC_CLIENT_ID=
+# OIDC_CLIENT_SECRET=
+# OIDC_REDIRECT_URI=http://localhost:3000/api/auth/oidc/callback
+# OIDC_SCOPES=openid email profile groups
+# OIDC_PROVIDER_NAME=Authentik
+#
+# Just-in-time provisioning for unknown emails. Strict by default — set
+# to true to auto-create a user in the default workspace on first login.
+# OIDC_AUTO_PROVISION=false
+# OIDC_DEFAULT_WORKSPACE_ID=
diff --git a/ACADENICE_PATCHES.md b/ACADENICE_PATCHES.md
index f43aacd8..54378d06 100644
--- a/ACADENICE_PATCHES.md
+++ b/ACADENICE_PATCHES.md
@@ -60,6 +60,69 @@ Branche fork : `acadenice/main`
| `apps/server/src/integrations/environment/environment.validation.ts` URL clickhouse exemple | message d'erreur dev-facing technique |
| `apps/server/src/core/workspace/services/workspace.service.ts` `@deleted.docmost.com` | placeholder technique pour soft-delete |
+---
+
+## Patch 002 — Bloc 4b : OIDC client (Authentik) via openid-client
+
+**Date** : 2026-05-07
+**Scope** : nouveau flow d'authentification SSO via Authentik (ou tout IdP OIDC), desactive par defaut
+**Rationale** : preparer l'integration SSO pour le hub Acadenice. Le code est dormant tant que `OIDC_ENABLED=true` n'est pas pose, donc zero impact sur les deploiements actuels. Les fichiers sont isoles dans un sous-dossier dedie pour faciliter le rebase upstream.
+
+### Lib utilisee
+
+`openid-client` v6.8.2 — deja en dependance dans `apps/server/package.json`. API fonctionnelle (pas un client object-oriented), import lazy au boot pour eviter l'overhead quand OIDC est off.
+
+### Fichiers crees
+
+| Fichier | Role |
+|---------|------|
+| `apps/server/src/core/auth/oidc/oidc.module.ts` | Module Nest dedie, importe par CoreModule |
+| `apps/server/src/core/auth/oidc/oidc.service.ts` | Discovery, PKCE, callback handler, JIT provisioning |
+| `apps/server/src/core/auth/oidc/oidc.controller.ts` | Routes `/api/auth/oidc/login`, `/callback`, `/status` |
+| `apps/server/src/core/auth/oidc/oidc.service.spec.ts` | 8 tests unitaires (Jest) avec `openid-client` mocke |
+| `apps/client/src/features/auth/queries/oidc-query.ts` | Hook `useOidcStatus()` (React Query) |
+| `apps/client/src/features/auth/components/oidc-login-button.tsx` | Bouton SSO conditionnel sur le formulaire login |
+
+### Fichiers modifies (touches minimales)
+
+| Fichier | Modification |
+|---------|--------------|
+| `apps/server/src/integrations/environment/environment.service.ts` | +9 getters OIDC (isOidcEnabled, getOidcIssuer, ...) appendus en fin de classe |
+| `apps/server/src/core/core.module.ts` | +1 import + 1 ligne dans `imports[]` pour `OidcModule` |
+| `apps/client/src/features/auth/components/login-form.tsx` | +2 lignes : import + `` au-dessus de `` |
+| `.env.example` | bloc OIDC commente ajoute en fin de fichier |
+
+### Securite
+
+- PKCE S256 (verifier + challenge generes par `openid-client`)
+- State CSRF stocke en cookie httpOnly signe (5 min TTL)
+- ID token verifie par signature JWKS (gere par `openid-client` v6 via la `Configuration` cachee)
+- userInfo refetched apres l'echange — on ne fait pas confiance aux claims ID token seuls pour `email`
+- Cookies temporaires `oidc_state` / `oidc_pkce` clear immediatement apres consommation
+
+### Variables d'env
+
+| Var | Defaut | Role |
+|-----|--------|------|
+| `OIDC_ENABLED` | `false` | master switch |
+| `OIDC_ISSUER` | (vide) | URL discovery (ex `https://auth.example.com/application/o/docadenice/`) |
+| `OIDC_CLIENT_ID` | (vide) | requis |
+| `OIDC_CLIENT_SECRET` | (vide) | requis |
+| `OIDC_REDIRECT_URI` | `${APP_URL}/api/auth/oidc/callback` | derive auto si non set |
+| `OIDC_SCOPES` | `openid email profile groups` | |
+| `OIDC_PROVIDER_NAME` | `SSO` | label affiche sur le bouton |
+| `OIDC_AUTO_PROVISION` | `false` | si true : cree le user a la volee si email inconnu |
+| `OIDC_DEFAULT_WORKSPACE_ID` | (vide) | requis si multi-workspace + auto-provision |
+
+### TODO Bloc 4b suivants
+
+- Mapping groupes Authentik vers roles Docmost (`OWNER` / `ADMIN` / `MEMBER`)
+- Logout federe (RP-initiated logout vers Authentik)
+- Tests E2E avec un vrai container Authentik (Testcontainers)
+- Bouton login OIDC integre au flow `enforceSso` cote workspace (actuellement le bouton apparait des que `OIDC_ENABLED=true`, sans condition supplementaire)
+
+---
+
### TODO rebrand complet (futur)
- Logo SVG / favicon DocAdenice (actuellement reutilise `/icons/favicon-32x32.png` upstream)
diff --git a/apps/client/src/features/auth/components/login-form.tsx b/apps/client/src/features/auth/components/login-form.tsx
index 78aaa94b..d452bb83 100644
--- a/apps/client/src/features/auth/components/login-form.tsx
+++ b/apps/client/src/features/auth/components/login-form.tsx
@@ -22,6 +22,7 @@ import { useWorkspacePublicDataQuery } from "@/features/workspace/queries/worksp
import { Error404 } from "@/components/ui/error-404.tsx";
import React from "react";
import { AuthLayout } from "./auth-layout.tsx";
+import { OidcLoginButton } from "./oidc-login-button.tsx";
const formSchema = z.object({
email: z
@@ -70,6 +71,8 @@ export function LoginForm() {
{t("Login")}
+
+
{!data?.enforceSso && (
diff --git a/apps/client/src/features/auth/components/oidc-login-button.tsx b/apps/client/src/features/auth/components/oidc-login-button.tsx
new file mode 100644
index 00000000..9541e03f
--- /dev/null
+++ b/apps/client/src/features/auth/components/oidc-login-button.tsx
@@ -0,0 +1,37 @@
+import { Button, Divider } from "@mantine/core";
+import { IconShieldLock } from "@tabler/icons-react";
+import { useOidcStatus } from "@/features/auth/queries/oidc-query.ts";
+import { useTranslation } from "react-i18next";
+
+/**
+ * Bloc 4b — DocAdenice OIDC login entry point.
+ *
+ * Renders the SSO button only when the server reports OIDC_ENABLED=true.
+ * Clicking it triggers a full page navigation to /api/auth/oidc/login,
+ * which redirects to the configured Authentik issuer. We deliberately
+ * use window.location instead of fetch+follow so the browser handles
+ * cookies + the 302 chain natively.
+ */
+export function OidcLoginButton() {
+ const { t } = useTranslation();
+ const { data, isLoading } = useOidcStatus();
+
+ if (isLoading || !data?.enabled) return null;
+
+ const providerName = data.providerName || "SSO";
+ return (
+ <>
+
+
+ >
+ );
+}
diff --git a/apps/client/src/features/auth/queries/oidc-query.ts b/apps/client/src/features/auth/queries/oidc-query.ts
new file mode 100644
index 00000000..12983ede
--- /dev/null
+++ b/apps/client/src/features/auth/queries/oidc-query.ts
@@ -0,0 +1,28 @@
+import { useQuery, UseQueryResult } from "@tanstack/react-query";
+import api from "@/lib/api-client";
+
+export interface IOidcStatus {
+ enabled: boolean;
+ providerName: string | null;
+}
+
+async function fetchOidcStatus(): Promise {
+ // GET endpoint — api-client default is POST helpers, so we call the raw
+ // axios instance. Status route is public; no auth header is required.
+ const res = await api.get("/auth/oidc/status");
+ return res.data;
+}
+
+/**
+ * Bloc 4b — exposes whether the OIDC SSO button must be rendered on the
+ * login form. The endpoint never returns an error for the disabled case,
+ * so the only failure mode here is network.
+ */
+export function useOidcStatus(): UseQueryResult {
+ return useQuery({
+ queryKey: ["oidc-status"],
+ queryFn: fetchOidcStatus,
+ staleTime: 5 * 60 * 1000,
+ retry: 1,
+ });
+}
diff --git a/apps/server/src/core/auth/oidc/oidc.controller.ts b/apps/server/src/core/auth/oidc/oidc.controller.ts
new file mode 100644
index 00000000..de9f25c1
--- /dev/null
+++ b/apps/server/src/core/auth/oidc/oidc.controller.ts
@@ -0,0 +1,145 @@
+import {
+ Controller,
+ Get,
+ Logger,
+ NotFoundException,
+ Query,
+ Req,
+ Res,
+ UnauthorizedException,
+} from '@nestjs/common';
+import { FastifyReply, FastifyRequest } from 'fastify';
+import { SkipThrottle, ThrottlerGuard } from '@nestjs/throttler';
+import { UseGuards } from '@nestjs/common';
+import { AI_CHAT_THROTTLER } from '../../../integrations/throttle/throttler-names';
+import { OidcService } from './oidc.service';
+import { EnvironmentService } from '../../../integrations/environment/environment.service';
+
+const STATE_COOKIE = 'oidc_state';
+const VERIFIER_COOKIE = 'oidc_pkce';
+const FIVE_MINUTES_MS = 5 * 60 * 1000;
+
+@SkipThrottle({ [AI_CHAT_THROTTLER]: true })
+@UseGuards(ThrottlerGuard)
+@Controller('auth/oidc')
+export class OidcController {
+ private readonly logger = new Logger(OidcController.name);
+
+ constructor(
+ private readonly oidcService: OidcService,
+ private readonly environmentService: EnvironmentService,
+ ) {}
+
+ /**
+ * Public probe used by the login form to know whether to render the
+ * OIDC button. Always returns 200 — never leaks 4xx for the disabled
+ * case so the frontend logic stays trivial.
+ */
+ @Get('status')
+ status() {
+ const enabled = this.environmentService.isOidcEnabled();
+ return {
+ enabled,
+ providerName: enabled
+ ? this.environmentService.getOidcProviderName()
+ : null,
+ };
+ }
+
+ @Get('login')
+ async login(@Res({ passthrough: true }) res: FastifyReply) {
+ if (!this.environmentService.isOidcEnabled()) {
+ throw new NotFoundException();
+ }
+
+ const { url, state, codeVerifier } =
+ await this.oidcService.getAuthorizationUrl();
+
+ this.setShortCookie(res, STATE_COOKIE, state);
+ this.setShortCookie(res, VERIFIER_COOKIE, codeVerifier);
+
+ res.redirect(url, 302);
+ }
+
+ @Get('callback')
+ async callback(
+ @Req() req: FastifyRequest,
+ @Res({ passthrough: true }) res: FastifyReply,
+ @Query('code') code: string,
+ @Query('state') state: string,
+ @Query('error') error?: string,
+ ) {
+ if (!this.environmentService.isOidcEnabled()) {
+ throw new NotFoundException();
+ }
+
+ if (error) {
+ this.logger.warn(`OIDC provider returned error: ${error}`);
+ throw new UnauthorizedException(`OIDC error: ${error}`);
+ }
+ if (!code || !state) {
+ throw new UnauthorizedException('Missing OIDC code or state');
+ }
+
+ const cookies = (req as any).cookies ?? {};
+ const expectedState = this.readSignedCookie(req, cookies[STATE_COOKIE]);
+ const codeVerifier = this.readSignedCookie(req, cookies[VERIFIER_COOKIE]);
+
+ if (!expectedState || expectedState !== state) {
+ throw new UnauthorizedException('OIDC state mismatch');
+ }
+
+ // Reconstruct the full callback URL — openid-client v6 reads the query
+ // params straight from this URL object.
+ const protocol = this.environmentService.isHttps() ? 'https' : 'http';
+ const host = (req.headers.host as string) || 'localhost';
+ const currentUrl = new URL(`${protocol}://${host}${req.url}`);
+
+ const result = await this.oidcService.handleCallback(
+ currentUrl,
+ expectedState,
+ codeVerifier,
+ );
+
+ // Clear the short-lived cookies as soon as they are consumed.
+ res.clearCookie(STATE_COOKIE, { path: '/' });
+ res.clearCookie(VERIFIER_COOKIE, { path: '/' });
+
+ res.setCookie('authToken', result.authToken, {
+ httpOnly: true,
+ sameSite: 'lax',
+ path: '/',
+ expires: this.environmentService.getCookieExpiresIn(),
+ secure: this.environmentService.isHttps(),
+ });
+
+ res.redirect('/home', 302);
+ }
+
+ private setShortCookie(res: FastifyReply, name: string, value: string) {
+ res.setCookie(name, value, {
+ httpOnly: true,
+ sameSite: 'lax',
+ path: '/',
+ signed: true,
+ secure: this.environmentService.isHttps(),
+ expires: new Date(Date.now() + FIVE_MINUTES_MS),
+ });
+ }
+
+ /**
+ * @fastify/cookie returns either a plain string or { value, valid, renew }
+ * for signed cookies depending on how the request is parsed. We accept
+ * both shapes so the controller stays robust across config tweaks.
+ */
+ private readSignedCookie(
+ req: FastifyRequest,
+ raw: unknown,
+ ): string | undefined {
+ if (!raw || typeof raw !== 'string') return undefined;
+ const unsigned = (req as any).unsignCookie?.(raw);
+ if (unsigned && unsigned.valid) return unsigned.value as string;
+ if (unsigned && unsigned.value) return unsigned.value as string;
+ return raw;
+ }
+}
diff --git a/apps/server/src/core/auth/oidc/oidc.module.ts b/apps/server/src/core/auth/oidc/oidc.module.ts
new file mode 100644
index 00000000..ab5adc0c
--- /dev/null
+++ b/apps/server/src/core/auth/oidc/oidc.module.ts
@@ -0,0 +1,22 @@
+import { Module } from '@nestjs/common';
+import { OidcController } from './oidc.controller';
+import { OidcService } from './oidc.service';
+import { TokenModule } from '../token.module';
+import { WorkspaceModule } from '../../workspace/workspace.module';
+import { UserModule } from '../../user/user.module';
+import { AuthModule } from '../auth.module';
+
+/**
+ * Bloc 4b — OIDC client (Authentik).
+ *
+ * Self-contained module so the patch can be reviewed/reverted in isolation
+ * and the rebase against upstream Docmost stays trivial. The only outward
+ * touchpoint is registering this module in CoreModule.
+ */
+@Module({
+ imports: [TokenModule, WorkspaceModule, UserModule, AuthModule],
+ controllers: [OidcController],
+ providers: [OidcService],
+ exports: [OidcService],
+})
+export class OidcModule {}
diff --git a/apps/server/src/core/auth/oidc/oidc.service.spec.ts b/apps/server/src/core/auth/oidc/oidc.service.spec.ts
new file mode 100644
index 00000000..d6898741
--- /dev/null
+++ b/apps/server/src/core/auth/oidc/oidc.service.spec.ts
@@ -0,0 +1,235 @@
+import { Test, TestingModule } from '@nestjs/testing';
+import { UnauthorizedException } from '@nestjs/common';
+import { OidcService } from './oidc.service';
+import { EnvironmentService } from '../../../integrations/environment/environment.service';
+import { SessionService } from '../../session/session.service';
+import { UserRepo } from '@docmost/db/repos/user/user.repo';
+import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo';
+import { SignupService } from '../services/signup.service';
+
+// Mock openid-client v6 entirely — we never need to talk to a real IdP
+// in unit tests. The mock surface mirrors the v6 functional API.
+jest.mock('openid-client', () => ({
+ __esModule: true,
+ discovery: jest.fn(),
+ randomPKCECodeVerifier: jest.fn(() => 'verifier-fixture'),
+ calculatePKCECodeChallenge: jest.fn(async () => 'challenge-fixture'),
+ randomState: jest.fn(() => 'state-fixture'),
+ buildAuthorizationUrl: jest.fn(
+ (_cfg, params) =>
+ new URL(
+ `https://idp.example.com/authorize?state=${params.state}&code_challenge=${params.code_challenge}`,
+ ),
+ ),
+ authorizationCodeGrant: jest.fn(),
+ fetchUserInfo: jest.fn(),
+}));
+
+import * as openidClient from 'openid-client';
+
+describe('OidcService', () => {
+ let service: OidcService;
+ let env: jest.Mocked>;
+ let session: jest.Mocked>;
+ let userRepo: jest.Mocked>;
+ let workspaceRepo: jest.Mocked>;
+ let signup: jest.Mocked>;
+
+ const FAKE_CONFIG = { _fake: true };
+ const WORKSPACE = { id: 'ws-1', name: 'Acme' } as any;
+ const USER = {
+ id: 'user-1',
+ email: 'pierre@example.com',
+ workspaceId: 'ws-1',
+ emailVerifiedAt: null,
+ } as any;
+
+ beforeEach(async () => {
+ jest.clearAllMocks();
+
+ env = {
+ isOidcEnabled: jest.fn(() => true),
+ getOidcIssuer: jest.fn(() => 'https://idp.example.com'),
+ getOidcClientId: jest.fn(() => 'client-id'),
+ getOidcClientSecret: jest.fn(() => 'client-secret'),
+ getOidcRedirectUri: jest.fn(
+ () => 'http://localhost:3000/api/auth/oidc/callback',
+ ),
+ getOidcScopes: jest.fn(() => 'openid email profile groups'),
+ getOidcProviderName: jest.fn(() => 'Authentik'),
+ isOidcAutoProvision: jest.fn(() => false),
+ getOidcDefaultWorkspaceId: jest.fn(() => undefined),
+ };
+ session = {
+ createSessionAndToken: jest.fn(async () => 'jwt-fixture'),
+ };
+ userRepo = {
+ findByEmail: jest.fn(),
+ updateUser: jest.fn(),
+ updateLastLogin: jest.fn(),
+ };
+ workspaceRepo = {
+ findFirst: jest.fn(async () => WORKSPACE),
+ findById: jest.fn(async () => WORKSPACE),
+ };
+ signup = {
+ signup: jest.fn(),
+ };
+
+ (openidClient.discovery as jest.Mock).mockResolvedValue(FAKE_CONFIG);
+
+ const module: TestingModule = await Test.createTestingModule({
+ providers: [
+ OidcService,
+ { provide: EnvironmentService, useValue: env },
+ { provide: SessionService, useValue: session },
+ { provide: UserRepo, useValue: userRepo },
+ { provide: WorkspaceRepo, useValue: workspaceRepo },
+ { provide: SignupService, useValue: signup },
+ ],
+ }).compile();
+
+ service = module.get(OidcService);
+ });
+
+ describe('bootstrap', () => {
+ it('runs discovery and caches the configuration', async () => {
+ await service.bootstrap();
+ expect(openidClient.discovery).toHaveBeenCalledTimes(1);
+ expect(service.isReady()).toBe(true);
+ });
+
+ it('throws when required env vars are missing', async () => {
+ (env.getOidcIssuer as jest.Mock).mockReturnValue(undefined);
+ await expect(service.bootstrap()).rejects.toThrow(/OIDC_ISSUER/);
+ });
+ });
+
+ describe('getAuthorizationUrl', () => {
+ it('generates PKCE verifier, challenge and state and returns a redirect URL', async () => {
+ await service.bootstrap();
+ const result = await service.getAuthorizationUrl();
+
+ expect(openidClient.randomPKCECodeVerifier).toHaveBeenCalled();
+ expect(openidClient.calculatePKCECodeChallenge).toHaveBeenCalledWith(
+ 'verifier-fixture',
+ );
+ expect(openidClient.randomState).toHaveBeenCalled();
+ expect(result.codeVerifier).toBe('verifier-fixture');
+ expect(result.state).toBe('state-fixture');
+ expect(result.url).toContain('state=state-fixture');
+ expect(result.url).toContain('code_challenge=challenge-fixture');
+ });
+ });
+
+ describe('handleCallback', () => {
+ const callbackUrl = new URL(
+ 'http://localhost:3000/api/auth/oidc/callback?code=abc&state=state-fixture',
+ );
+
+ beforeEach(async () => {
+ await service.bootstrap();
+ (openidClient.authorizationCodeGrant as jest.Mock).mockResolvedValue({
+ access_token: 'access-token',
+ claims: () => ({ sub: 'idp-sub-1' }),
+ });
+ (openidClient.fetchUserInfo as jest.Mock).mockResolvedValue({
+ sub: 'idp-sub-1',
+ email: 'Pierre@example.com',
+ name: 'Pierre Dupont',
+ });
+ });
+
+ it('exchanges the code, looks up an existing user and mints a Docmost token', async () => {
+ (userRepo.findByEmail as jest.Mock).mockResolvedValue(USER);
+
+ const result = await service.handleCallback(
+ callbackUrl,
+ 'state-fixture',
+ 'verifier-fixture',
+ );
+
+ expect(openidClient.authorizationCodeGrant).toHaveBeenCalledWith(
+ FAKE_CONFIG,
+ callbackUrl,
+ { pkceCodeVerifier: 'verifier-fixture', expectedState: 'state-fixture' },
+ );
+ expect(userRepo.findByEmail).toHaveBeenCalledWith(
+ 'pierre@example.com',
+ 'ws-1',
+ );
+ expect(session.createSessionAndToken).toHaveBeenCalledWith(USER);
+ expect(result.authToken).toBe('jwt-fixture');
+ expect(result.userId).toBe('user-1');
+ });
+
+ it('throws when state cookie is missing', async () => {
+ await expect(
+ service.handleCallback(callbackUrl, '', 'verifier-fixture'),
+ ).rejects.toThrow(UnauthorizedException);
+ });
+
+ it('throws when the IdP code exchange fails', async () => {
+ (openidClient.authorizationCodeGrant as jest.Mock).mockRejectedValue(
+ new Error('invalid_grant'),
+ );
+ await expect(
+ service.handleCallback(
+ callbackUrl,
+ 'state-fixture',
+ 'verifier-fixture',
+ ),
+ ).rejects.toThrow(UnauthorizedException);
+ });
+
+ it('refuses an unknown email when auto-provisioning is disabled', async () => {
+ (userRepo.findByEmail as jest.Mock).mockResolvedValue(null);
+ (env.isOidcAutoProvision as jest.Mock).mockReturnValue(false);
+
+ await expect(
+ service.handleCallback(
+ callbackUrl,
+ 'state-fixture',
+ 'verifier-fixture',
+ ),
+ ).rejects.toThrow(/contact your administrator/i);
+ expect(signup.signup).not.toHaveBeenCalled();
+ });
+
+ it('JIT-provisions an unknown email when auto-provisioning is enabled', async () => {
+ (userRepo.findByEmail as jest.Mock).mockResolvedValue(null);
+ (env.isOidcAutoProvision as jest.Mock).mockReturnValue(true);
+ (signup.signup as jest.Mock).mockResolvedValue({
+ ...USER,
+ emailVerifiedAt: null,
+ });
+
+ const result = await service.handleCallback(
+ callbackUrl,
+ 'state-fixture',
+ 'verifier-fixture',
+ );
+
+ expect(signup.signup).toHaveBeenCalledTimes(1);
+ const dto = (signup.signup as jest.Mock).mock.calls[0][0];
+ expect(dto.email).toBe('pierre@example.com');
+ expect(dto.name).toBe('Pierre Dupont');
+ expect(result.authToken).toBe('jwt-fixture');
+ });
+
+ it('throws when the IdP returns no email claim', async () => {
+ (openidClient.fetchUserInfo as jest.Mock).mockResolvedValue({
+ sub: 'idp-sub-1',
+ email: undefined,
+ });
+
+ await expect(
+ service.handleCallback(
+ callbackUrl,
+ 'state-fixture',
+ 'verifier-fixture',
+ ),
+ ).rejects.toThrow(/email/i);
+ });
+ });
+});
diff --git a/apps/server/src/core/auth/oidc/oidc.service.ts b/apps/server/src/core/auth/oidc/oidc.service.ts
new file mode 100644
index 00000000..8df6d533
--- /dev/null
+++ b/apps/server/src/core/auth/oidc/oidc.service.ts
@@ -0,0 +1,277 @@
+import {
+ Injectable,
+ Logger,
+ OnModuleInit,
+ UnauthorizedException,
+ BadRequestException,
+ ServiceUnavailableException,
+} from '@nestjs/common';
+import { EnvironmentService } from '../../../integrations/environment/environment.service';
+import { SessionService } from '../../session/session.service';
+import { UserRepo } from '@docmost/db/repos/user/user.repo';
+import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo';
+import { SignupService } from '../services/signup.service';
+import { randomBytes } from 'crypto';
+
+// openid-client v6 is a tree-shakeable function-based API. It's loaded
+// lazily so the rest of the server boots even when the optional dep
+// is missing in a slimmed-down build.
+type OidcClient = typeof import('openid-client');
+
+export interface OidcCallbackResult {
+ authToken: string;
+ userId: string;
+ workspaceId: string;
+}
+
+/**
+ * Bloc 4b — OIDC client (Authentik) for DocAdenice.
+ *
+ * Lifecycle:
+ * - At module init, if OIDC_ENABLED=true, run discovery once and cache the
+ * Configuration object. Discovery failure is logged but not fatal so the
+ * server stays up; the controller will surface 503 until vars are fixed.
+ * - getAuthorizationUrl produces the redirect URL plus the per-request
+ * PKCE verifier and state. The controller is in charge of persisting
+ * those two values in short-lived signed cookies.
+ * - handleCallback consumes the cookie values, exchanges the code for
+ * tokens, fetches userInfo and either looks up or just-in-time creates
+ * the matching Docmost user, then mints a regular Docmost session JWT
+ * so downstream code (cookies, guards, sessions) is unchanged.
+ */
+@Injectable()
+export class OidcService implements OnModuleInit {
+ private readonly logger = new Logger(OidcService.name);
+ private oidc: OidcClient | null = null;
+ private config: any | null = null;
+
+ constructor(
+ private readonly environmentService: EnvironmentService,
+ private readonly sessionService: SessionService,
+ private readonly userRepo: UserRepo,
+ private readonly workspaceRepo: WorkspaceRepo,
+ private readonly signupService: SignupService,
+ ) {}
+
+ async onModuleInit(): Promise {
+ if (!this.environmentService.isOidcEnabled()) {
+ this.logger.log('OIDC disabled (set OIDC_ENABLED=true to enable)');
+ return;
+ }
+ try {
+ await this.bootstrap();
+ } catch (err) {
+ this.logger.error(
+ `OIDC discovery failed at boot — provider unreachable. The /api/auth/oidc/* routes will return 503 until fixed. Error: ${(err as Error).message}`,
+ );
+ }
+ }
+
+ /**
+ * Public for tests. Dynamically import openid-client and run discovery.
+ * Caches the Configuration so subsequent calls reuse the same JWKS cache.
+ */
+ async bootstrap(): Promise {
+ const issuerUrl = this.environmentService.getOidcIssuer();
+ const clientId = this.environmentService.getOidcClientId();
+ const clientSecret = this.environmentService.getOidcClientSecret();
+
+ if (!issuerUrl || !clientId || !clientSecret) {
+ throw new Error(
+ 'OIDC_ISSUER, OIDC_CLIENT_ID and OIDC_CLIENT_SECRET are required when OIDC_ENABLED=true',
+ );
+ }
+
+ // Lazy import keeps the openid-client dep optional at runtime.
+ this.oidc = (await import('openid-client')) as OidcClient;
+ this.config = await this.oidc.discovery(
+ new URL(issuerUrl),
+ clientId,
+ clientSecret,
+ );
+ this.logger.log(`OIDC discovery OK for issuer ${issuerUrl}`);
+ }
+
+ isReady(): boolean {
+ return this.environmentService.isOidcEnabled() && this.config !== null;
+ }
+
+ private ensureReady(): void {
+ if (!this.environmentService.isOidcEnabled()) {
+ throw new ServiceUnavailableException('OIDC is disabled');
+ }
+ if (!this.config || !this.oidc) {
+ throw new ServiceUnavailableException(
+ 'OIDC provider not yet ready (discovery pending or failed)',
+ );
+ }
+ }
+
+ /**
+ * Build the authorization URL plus the values the controller must persist
+ * in cookies for the callback. PKCE S256 + state are mandatory.
+ */
+ async getAuthorizationUrl(): Promise<{
+ url: string;
+ state: string;
+ codeVerifier: string;
+ }> {
+ this.ensureReady();
+
+ const codeVerifier = this.oidc!.randomPKCECodeVerifier();
+ const codeChallenge =
+ await this.oidc!.calculatePKCECodeChallenge(codeVerifier);
+ const state = this.oidc!.randomState();
+
+ const params: Record = {
+ redirect_uri: this.environmentService.getOidcRedirectUri(),
+ scope: this.environmentService.getOidcScopes(),
+ code_challenge: codeChallenge,
+ code_challenge_method: 'S256',
+ state,
+ };
+
+ const url = this.oidc!.buildAuthorizationUrl(this.config, params);
+ return { url: url.href, state, codeVerifier };
+ }
+
+ /**
+ * Exchange the authorization code, verify the ID token, fetch userInfo,
+ * resolve the Docmost user (lookup or JIT provisioning), then mint a
+ * standard Docmost access token. The state-cookie check happens here:
+ * if expectedState mismatches the query state we throw before talking
+ * to the IdP.
+ */
+ async handleCallback(
+ currentUrl: URL,
+ expectedState: string,
+ codeVerifier: string,
+ ): Promise {
+ this.ensureReady();
+
+ if (!expectedState || !codeVerifier) {
+ throw new UnauthorizedException(
+ 'Missing OIDC state or code verifier cookie',
+ );
+ }
+
+ let tokens: any;
+ try {
+ tokens = await this.oidc!.authorizationCodeGrant(
+ this.config,
+ currentUrl,
+ {
+ pkceCodeVerifier: codeVerifier,
+ expectedState,
+ },
+ );
+ } catch (err) {
+ this.logger.warn(
+ `OIDC code exchange failed: ${(err as Error).message}`,
+ );
+ throw new UnauthorizedException('OIDC authorization failed');
+ }
+
+ const claims = tokens.claims?.() ?? {};
+ const sub = claims.sub as string | undefined;
+ if (!sub) {
+ throw new UnauthorizedException('OIDC ID token missing subject');
+ }
+
+ let userInfo: any;
+ try {
+ userInfo = await this.oidc!.fetchUserInfo(
+ this.config,
+ tokens.access_token,
+ sub,
+ );
+ } catch (err) {
+ this.logger.warn(
+ `OIDC userInfo fetch failed: ${(err as Error).message}`,
+ );
+ throw new UnauthorizedException('OIDC userInfo unavailable');
+ }
+
+ const email = (userInfo.email as string | undefined)?.toLowerCase();
+ if (!email) {
+ throw new UnauthorizedException(
+ 'OIDC userInfo missing email — make sure the email scope is granted',
+ );
+ }
+
+ const workspace = await this.resolveWorkspace();
+ if (!workspace) {
+ throw new ServiceUnavailableException(
+ 'No workspace available for OIDC login',
+ );
+ }
+
+ let user = await this.userRepo.findByEmail(email, workspace.id);
+
+ if (!user) {
+ if (!this.environmentService.isOidcAutoProvision()) {
+ throw new UnauthorizedException(
+ 'No matching account in this workspace. Contact your administrator to be invited.',
+ );
+ }
+ user = await this.provisionUser(email, userInfo, workspace.id);
+ }
+
+ if (!user.emailVerifiedAt) {
+ // OIDC handshake itself proves email ownership for the IdP.
+ await this.userRepo.updateUser(
+ { emailVerifiedAt: new Date() },
+ user.id,
+ workspace.id,
+ );
+ user.emailVerifiedAt = new Date();
+ }
+
+ await this.userRepo.updateLastLogin(user.id, workspace.id);
+
+ const authToken = await this.sessionService.createSessionAndToken(user);
+ return { authToken, userId: user.id, workspaceId: workspace.id };
+ }
+
+ /**
+ * If OIDC_DEFAULT_WORKSPACE_ID is set, use it. Otherwise pick the only
+ * workspace (single-tenant install). Multi-tenant deployments must set
+ * the env var explicitly.
+ */
+ private async resolveWorkspace() {
+ const explicit = this.environmentService.getOidcDefaultWorkspaceId();
+ if (explicit) {
+ return this.workspaceRepo.findById(explicit);
+ }
+ return this.workspaceRepo.findFirst();
+ }
+
+ private async provisionUser(
+ email: string,
+ userInfo: any,
+ workspaceId: string,
+ ) {
+ const name =
+ (userInfo.name as string | undefined) ||
+ [userInfo.given_name, userInfo.family_name].filter(Boolean).join(' ') ||
+ email.split('@')[0];
+
+ // Random unguessable password — the user authenticates via OIDC only.
+ // Storing a real bcrypt hash (rather than empty) prevents the local
+ // password login flow from ever matching even if the email is guessed.
+ const dto = {
+ name,
+ email,
+ password: randomBytes(32).toString('hex'),
+ } as any;
+
+ const user = await this.signupService.signup(dto, workspaceId);
+ await this.userRepo.updateUser(
+ { emailVerifiedAt: new Date() },
+ user.id,
+ workspaceId,
+ );
+ user.emailVerifiedAt = new Date();
+ return user;
+ }
+}
diff --git a/apps/server/src/core/core.module.ts b/apps/server/src/core/core.module.ts
index bb56bd3f..8fbc0c88 100644
--- a/apps/server/src/core/core.module.ts
+++ b/apps/server/src/core/core.module.ts
@@ -22,6 +22,7 @@ import { NotificationModule } from './notification/notification.module';
import { WatcherModule } from './watcher/watcher.module';
import { FavoriteModule } from './favorite/favorite.module';
import { SessionModule } from './session/session.module';
+import { OidcModule } from './auth/oidc/oidc.module';
import { ClsMiddleware } from 'nestjs-cls';
@Module({
@@ -42,6 +43,7 @@ import { ClsMiddleware } from 'nestjs-cls';
NotificationModule,
WatcherModule,
SessionModule,
+ OidcModule,
],
})
export class CoreModule implements NestModule {
diff --git a/apps/server/src/integrations/environment/environment.service.ts b/apps/server/src/integrations/environment/environment.service.ts
index f927548d..4bf6ef01 100644
--- a/apps/server/src/integrations/environment/environment.service.ts
+++ b/apps/server/src/integrations/environment/environment.service.ts
@@ -325,4 +325,56 @@ export class EnvironmentService {
.toLowerCase();
return disabled === 'true';
}
+
+ // Bloc 4b — OIDC (Authentik) configuration. All getters are no-ops when
+ // OIDC_ENABLED is unset, so the patch is dormant by default.
+
+ isOidcEnabled(): boolean {
+ return (
+ this.configService.get('OIDC_ENABLED', 'false').toLowerCase() ===
+ 'true'
+ );
+ }
+
+ getOidcIssuer(): string {
+ return this.configService.get('OIDC_ISSUER');
+ }
+
+ getOidcClientId(): string {
+ return this.configService.get('OIDC_CLIENT_ID');
+ }
+
+ getOidcClientSecret(): string {
+ return this.configService.get('OIDC_CLIENT_SECRET');
+ }
+
+ getOidcRedirectUri(): string {
+ return (
+ this.configService.get('OIDC_REDIRECT_URI') ||
+ `${this.getAppUrl()}/api/auth/oidc/callback`
+ );
+ }
+
+ getOidcScopes(): string {
+ return this.configService.get(
+ 'OIDC_SCOPES',
+ 'openid email profile groups',
+ );
+ }
+
+ getOidcProviderName(): string {
+ return this.configService.get('OIDC_PROVIDER_NAME', 'SSO');
+ }
+
+ isOidcAutoProvision(): boolean {
+ return (
+ this.configService
+ .get('OIDC_AUTO_PROVISION', 'false')
+ .toLowerCase() === 'true'
+ );
+ }
+
+ getOidcDefaultWorkspaceId(): string | undefined {
+ return this.configService.get('OIDC_DEFAULT_WORKSPACE_ID');
+ }
}