feat(auth): Bloc 4b — OIDC client Authentik via openid-client (active par OIDC_ENABLED env)

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) <noreply@anthropic.com>
This commit is contained in:
Corentin JOGUET 2026-05-07 21:26:53 +02:00
parent efa26440a0
commit 07d0b66fda
11 changed files with 881 additions and 0 deletions

View file

@ -56,3 +56,20 @@ DEBUG_DB=false
# Log http requests # Log http requests
LOG_HTTP=false 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=

View file

@ -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/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 | | `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 + `<OidcLoginButton />` au-dessus de `<SsoLogin />` |
| `.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) ### TODO rebrand complet (futur)
- Logo SVG / favicon DocAdenice (actuellement reutilise `/icons/favicon-32x32.png` upstream) - Logo SVG / favicon DocAdenice (actuellement reutilise `/icons/favicon-32x32.png` upstream)

View file

@ -22,6 +22,7 @@ import { useWorkspacePublicDataQuery } from "@/features/workspace/queries/worksp
import { Error404 } from "@/components/ui/error-404.tsx"; import { Error404 } from "@/components/ui/error-404.tsx";
import React from "react"; import React from "react";
import { AuthLayout } from "./auth-layout.tsx"; import { AuthLayout } from "./auth-layout.tsx";
import { OidcLoginButton } from "./oidc-login-button.tsx";
const formSchema = z.object({ const formSchema = z.object({
email: z email: z
@ -70,6 +71,8 @@ export function LoginForm() {
{t("Login")} {t("Login")}
</Title> </Title>
<OidcLoginButton />
<SsoLogin /> <SsoLogin />
{!data?.enforceSso && ( {!data?.enforceSso && (

View file

@ -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 (
<>
<Button
onClick={() => {
window.location.href = "/api/auth/oidc/login";
}}
leftSection={<IconShieldLock size={16} />}
variant="default"
fullWidth
>
{t("Sign in with {{provider}}", { provider: providerName })}
</Button>
<Divider my="xs" label={t("OR")} labelPosition="center" />
</>
);
}

View file

@ -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<IOidcStatus> {
// 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<IOidcStatus>("/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<IOidcStatus, Error> {
return useQuery({
queryKey: ["oidc-status"],
queryFn: fetchOidcStatus,
staleTime: 5 * 60 * 1000,
retry: 1,
});
}

View file

@ -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;
}
}

View file

@ -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 {}

View file

@ -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<Partial<EnvironmentService>>;
let session: jest.Mocked<Partial<SessionService>>;
let userRepo: jest.Mocked<Partial<UserRepo>>;
let workspaceRepo: jest.Mocked<Partial<WorkspaceRepo>>;
let signup: jest.Mocked<Partial<SignupService>>;
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);
});
});
});

View file

@ -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<void> {
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<void> {
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<string, string> = {
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<OidcCallbackResult> {
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;
}
}

View file

@ -22,6 +22,7 @@ import { NotificationModule } from './notification/notification.module';
import { WatcherModule } from './watcher/watcher.module'; import { WatcherModule } from './watcher/watcher.module';
import { FavoriteModule } from './favorite/favorite.module'; import { FavoriteModule } from './favorite/favorite.module';
import { SessionModule } from './session/session.module'; import { SessionModule } from './session/session.module';
import { OidcModule } from './auth/oidc/oidc.module';
import { ClsMiddleware } from 'nestjs-cls'; import { ClsMiddleware } from 'nestjs-cls';
@Module({ @Module({
@ -42,6 +43,7 @@ import { ClsMiddleware } from 'nestjs-cls';
NotificationModule, NotificationModule,
WatcherModule, WatcherModule,
SessionModule, SessionModule,
OidcModule,
], ],
}) })
export class CoreModule implements NestModule { export class CoreModule implements NestModule {

View file

@ -325,4 +325,56 @@ export class EnvironmentService {
.toLowerCase(); .toLowerCase();
return disabled === 'true'; 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<string>('OIDC_ENABLED', 'false').toLowerCase() ===
'true'
);
}
getOidcIssuer(): string {
return this.configService.get<string>('OIDC_ISSUER');
}
getOidcClientId(): string {
return this.configService.get<string>('OIDC_CLIENT_ID');
}
getOidcClientSecret(): string {
return this.configService.get<string>('OIDC_CLIENT_SECRET');
}
getOidcRedirectUri(): string {
return (
this.configService.get<string>('OIDC_REDIRECT_URI') ||
`${this.getAppUrl()}/api/auth/oidc/callback`
);
}
getOidcScopes(): string {
return this.configService.get<string>(
'OIDC_SCOPES',
'openid email profile groups',
);
}
getOidcProviderName(): string {
return this.configService.get<string>('OIDC_PROVIDER_NAME', 'SSO');
}
isOidcAutoProvision(): boolean {
return (
this.configService
.get<string>('OIDC_AUTO_PROVISION', 'false')
.toLowerCase() === 'true'
);
}
getOidcDefaultWorkspaceId(): string | undefined {
return this.configService.get<string>('OIDC_DEFAULT_WORKSPACE_ID');
}
} }