From 07d0b66fda63e375a415cc9e560241e08ff0a9d3 Mon Sep 17 00:00:00 2001 From: Corentin Date: Thu, 7 May 2026 21:26:53 +0200 Subject: [PATCH] =?UTF-8?q?feat(auth):=20Bloc=204b=20=E2=80=94=20OIDC=20cl?= =?UTF-8?q?ient=20Authentik=20via=20openid-client=20(active=20par=20OIDC?= =?UTF-8?q?=5FENABLED=20env)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ajoute un flow d'authentification OIDC via Authentik (ou tout IdP conforme), desactive par defaut. Le code est dormant tant que OIDC_ENABLED=true n'est pas pose. Server : - apps/server/src/core/auth/oidc/oidc.module.ts (nouveau) - apps/server/src/core/auth/oidc/oidc.service.ts (discovery + PKCE + callback + 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 Jest, openid-client mocke) - apps/server/src/integrations/environment/environment.service.ts : +9 getters OIDC - apps/server/src/core/core.module.ts : +OidcModule dans imports Client : - apps/client/src/features/auth/queries/oidc-query.ts (hook useOidcStatus) - apps/client/src/features/auth/components/oidc-login-button.tsx (bouton conditionnel) - apps/client/src/features/auth/components/login-form.tsx : +OidcLoginButton Securite : - PKCE S256 obligatoire - State CSRF en cookie httpOnly signe (5 min) - Verification JWKS auto via openid-client v6 - Refetch userInfo apres echange du code - JIT provisioning strict par defaut (OIDC_AUTO_PROVISION=false) Lib : openid-client v6.8.2 (deja en deps), import lazy. Documente dans ACADENICE_PATCHES.md (Patch 002) et .env.example. Co-Authored-By: Claude Opus 4.7 (1M context) --- .env.example | 17 ++ ACADENICE_PATCHES.md | 63 ++++ .../features/auth/components/login-form.tsx | 3 + .../auth/components/oidc-login-button.tsx | 37 +++ .../src/features/auth/queries/oidc-query.ts | 28 ++ .../src/core/auth/oidc/oidc.controller.ts | 145 +++++++++ apps/server/src/core/auth/oidc/oidc.module.ts | 22 ++ .../src/core/auth/oidc/oidc.service.spec.ts | 235 +++++++++++++++ .../server/src/core/auth/oidc/oidc.service.ts | 277 ++++++++++++++++++ apps/server/src/core/core.module.ts | 2 + .../environment/environment.service.ts | 52 ++++ 11 files changed, 881 insertions(+) create mode 100644 apps/client/src/features/auth/components/oidc-login-button.tsx create mode 100644 apps/client/src/features/auth/queries/oidc-query.ts create mode 100644 apps/server/src/core/auth/oidc/oidc.controller.ts create mode 100644 apps/server/src/core/auth/oidc/oidc.module.ts create mode 100644 apps/server/src/core/auth/oidc/oidc.service.spec.ts create mode 100644 apps/server/src/core/auth/oidc/oidc.service.ts 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'); + } }