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 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=
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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 && (
|
||||||
|
|
|
||||||
|
|
@ -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 { 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 {
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue