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:
parent
efa26440a0
commit
07d0b66fda
11 changed files with 881 additions and 0 deletions
17
.env.example
17
.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=
|
||||
|
|
|
|||
|
|
@ -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 + `<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)
|
||||
|
||||
- Logo SVG / favicon DocAdenice (actuellement reutilise `/icons/favicon-32x32.png` upstream)
|
||||
|
|
|
|||
|
|
@ -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")}
|
||||
</Title>
|
||||
|
||||
<OidcLoginButton />
|
||||
|
||||
<SsoLogin />
|
||||
|
||||
{!data?.enforceSso && (
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
28
apps/client/src/features/auth/queries/oidc-query.ts
Normal file
28
apps/client/src/features/auth/queries/oidc-query.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
145
apps/server/src/core/auth/oidc/oidc.controller.ts
Normal file
145
apps/server/src/core/auth/oidc/oidc.controller.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
22
apps/server/src/core/auth/oidc/oidc.module.ts
Normal file
22
apps/server/src/core/auth/oidc/oidc.module.ts
Normal 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 {}
|
||||
235
apps/server/src/core/auth/oidc/oidc.service.spec.ts
Normal file
235
apps/server/src/core/auth/oidc/oidc.service.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
277
apps/server/src/core/auth/oidc/oidc.service.ts
Normal file
277
apps/server/src/core/auth/oidc/oidc.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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<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');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue