- This is a superuser role. It implicitly has all permissions, including any added in the future. Individual permission assignments are not applicable.
+ This is a superuser role. It implicitly has all permissions, including any added in the future. Individual permission assignments are not
+ applicable.
diff --git a/client/src/features/admin/RolesPage.vue b/client/src/features/admin/RolesPage.vue
index 6eb58a5c8..e189d046e 100644
--- a/client/src/features/admin/RolesPage.vue
+++ b/client/src/features/admin/RolesPage.vue
@@ -91,7 +91,9 @@ function onSaved() {
Standard
- All
+ All
{{ role.permissions.length }}
diff --git a/client/src/features/auth/LoginPage.vue b/client/src/features/auth/LoginPage.vue
index 760d1a305..cb9dd957b 100644
--- a/client/src/features/auth/LoginPage.vue
+++ b/client/src/features/auth/LoginPage.vue
@@ -1,13 +1,22 @@
@@ -62,10 +82,30 @@ async function handleSubmit() {
:disabled="loading"
class="w-full rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50 transition-colors"
>
- {{ loading ? 'Signing in…' : 'Sign in' }}
+ {{ loading ? 'Signing in...' : 'Sign in' }}
+
+
+
+
+ {{ oidcLoading ? 'Redirecting...' : `Sign in with ${oidcConfig.providerName || 'SSO'}` }}
+
+
+
Forgot password?
diff --git a/client/src/features/auth/OidcCallbackPage.vue b/client/src/features/auth/OidcCallbackPage.vue
new file mode 100644
index 000000000..588d61b73
--- /dev/null
+++ b/client/src/features/auth/OidcCallbackPage.vue
@@ -0,0 +1,62 @@
+
+
+
+
+
+
projectx
+
+
+
+
Completing sign in...
+
+
+
+
{{ error }}
+
Back to login
+
+
+
+
diff --git a/client/src/features/auth/composables/useAuth.ts b/client/src/features/auth/composables/useAuth.ts
index b4e239322..aabde46e0 100644
--- a/client/src/features/auth/composables/useAuth.ts
+++ b/client/src/features/auth/composables/useAuth.ts
@@ -64,8 +64,19 @@ export function useAuth() {
}
async function logout(): Promise {
- await fetch('/api/auth/logout', { method: 'POST', credentials: 'include' }).catch(() => {})
- clearAuth()
+ try {
+ const res = await fetch('/api/auth/logout', { method: 'POST', credentials: 'include' })
+ clearAuth()
+ if (res.ok) {
+ const data = await res.json().catch(() => ({}))
+ if (data?.logoutUrl) {
+ window.location.href = data.logoutUrl
+ return
+ }
+ }
+ } catch {
+ clearAuth()
+ }
router.push('/login')
}
diff --git a/client/src/features/auth/composables/useOidc.ts b/client/src/features/auth/composables/useOidc.ts
new file mode 100644
index 000000000..a12b1eed7
--- /dev/null
+++ b/client/src/features/auth/composables/useOidc.ts
@@ -0,0 +1,111 @@
+import type { OidcPublicConfig } from '@projectx/types'
+import { api } from '@/lib/api'
+
+async function generatePkce(): Promise<{ codeVerifier: string; codeChallenge: string }> {
+ const array = new Uint8Array(32)
+ crypto.getRandomValues(array)
+ const codeVerifier = btoa(String.fromCharCode(...array))
+ .replace(/\+/g, '-')
+ .replace(/\//g, '_')
+ .replace(/=/g, '')
+
+ const encoder = new TextEncoder()
+ const data = encoder.encode(codeVerifier)
+ const digest = await crypto.subtle.digest('SHA-256', data)
+ const codeChallenge = btoa(String.fromCharCode(...new Uint8Array(digest)))
+ .replace(/\+/g, '-')
+ .replace(/\//g, '_')
+ .replace(/=/g, '')
+
+ return { codeVerifier, codeChallenge }
+}
+
+function generateNonce(): string {
+ const array = new Uint8Array(16)
+ crypto.getRandomValues(array)
+ return btoa(String.fromCharCode(...array))
+ .replace(/\+/g, '-')
+ .replace(/\//g, '_')
+ .replace(/=/g, '')
+}
+
+export function useOidc() {
+ async function getPublicConfig(): Promise {
+ try {
+ const res = await fetch('/api/app-settings/oidc/public')
+ if (!res.ok) return null
+ return res.json()
+ } catch {
+ return null
+ }
+ }
+
+ async function initiateLogin(): Promise {
+ const config = await getPublicConfig()
+ if (!config?.enabled || !config.issuerUri) {
+ throw new Error('OIDC is not configured')
+ }
+
+ // Fetch discovery document on the client side
+ let discRes: Response
+ try {
+ discRes = await fetch(`${config.issuerUri.replace(/\/$/, '')}/.well-known/openid-configuration`)
+ } catch {
+ throw new Error('Cannot reach OIDC provider - check the Issuer URI in settings')
+ }
+ if (!discRes.ok) throw new Error(`OIDC provider returned HTTP ${discRes.status} - check the Issuer URI in settings`)
+ const disc = await discRes.json()
+
+ const { codeVerifier, codeChallenge } = await generatePkce()
+ const nonce = generateNonce()
+
+ // Get server-side state (CSRF protection)
+ const stateRes = await fetch('/api/auth/oidc/state', { method: 'POST', credentials: 'include' })
+ if (!stateRes.ok) throw new Error('Failed to generate state')
+ const { state } = await stateRes.json()
+
+ // Store PKCE data for the callback
+ sessionStorage.setItem(`oidc_pkce_${state}`, JSON.stringify({ codeVerifier, nonce, state }))
+
+ const redirectUri = `${window.location.origin}/oauth2-callback`
+
+ const params = new URLSearchParams({
+ response_type: 'code',
+ client_id: config.clientId,
+ redirect_uri: redirectUri,
+ scope: config.scopes,
+ code_challenge: codeChallenge,
+ code_challenge_method: 'S256',
+ state,
+ nonce,
+ })
+
+ window.location.href = `${disc.authorization_endpoint}?${params.toString()}`
+ }
+
+ async function exchangeCode(code: string, state: string) {
+ const pkceJson = sessionStorage.getItem(`oidc_pkce_${state}`)
+ if (!pkceJson) throw new Error('No PKCE data found for this state')
+
+ const { codeVerifier, nonce } = JSON.parse(pkceJson) as { codeVerifier: string; nonce: string; state: string }
+ sessionStorage.removeItem(`oidc_pkce_${state}`)
+
+ const redirectUri = `${window.location.origin}/oauth2-callback`
+
+ const res = await fetch('/api/auth/oidc/callback', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ credentials: 'include',
+ body: JSON.stringify({ code, codeVerifier, redirectUri, nonce, state }),
+ })
+
+ if (!res.ok) {
+ const err = await res.json().catch(() => ({}))
+ throw new Error((err as any).message ?? 'OIDC login failed')
+ }
+
+ return res.json()
+ }
+
+ return { getPublicConfig, initiateLogin, exchangeCode }
+}
diff --git a/client/src/features/settings/OidcSettings.vue b/client/src/features/settings/OidcSettings.vue
new file mode 100644
index 000000000..bd333a260
--- /dev/null
+++ b/client/src/features/settings/OidcSettings.vue
@@ -0,0 +1,248 @@
+
+
+
+
+
+
+
OIDC / SSO
+
Configure an OpenID Connect provider for single sign-on.
+
+
+
Loading...
+
+
+
+
diff --git a/client/src/features/settings/SettingsDrawer.vue b/client/src/features/settings/SettingsDrawer.vue
index 63be4a6f9..9e0346370 100644
--- a/client/src/features/settings/SettingsDrawer.vue
+++ b/client/src/features/settings/SettingsDrawer.vue
@@ -14,9 +14,11 @@ import {
Users,
ShieldCheck,
KeyRound,
+ LogIn,
} from 'lucide-vue-next'
import { useSettingsDrawer } from '@/composables/useSettingsDrawer'
import { usePermissions } from '@/features/auth/composables/usePermissions'
+import { useThemeStore, BACKGROUND_OPTIONS } from '@/stores/theme'
import LibrariesSettings from './LibrariesSettings.vue'
import ScannerSettings from './ScannerSettings.vue'
import AppearanceSettings from './AppearanceSettings.vue'
@@ -27,11 +29,14 @@ import AboutSettings from './AboutSettings.vue'
import UsersPage from '@/features/admin/UsersPage.vue'
import RolesPage from '@/features/admin/RolesPage.vue'
import PermissionsPage from '@/features/admin/PermissionsPage.vue'
+import OidcSettings from './OidcSettings.vue'
const { isOpen, close } = useSettingsDrawer()
const { isSuperuser, hasPermission, userPermissions } = usePermissions()
+const themeStore = useThemeStore()
+const bgClass = computed(() => BACKGROUND_OPTIONS.find((o) => o.id === themeStore.background)?.cssClass ?? '')
-type SectionId = 'libraries' | 'scanner' | 'appearance' | 'ebook' | 'pdf' | 'comics' | 'about' | 'users' | 'roles' | 'permissions'
+type SectionId = 'libraries' | 'scanner' | 'appearance' | 'ebook' | 'pdf' | 'comics' | 'about' | 'users' | 'roles' | 'permissions' | 'oidc'
const navGroups = computed(() => {
const groups = [
@@ -67,6 +72,9 @@ const navGroups = computed(() => {
adminItems.push({ id: 'roles', label: 'Roles', icon: ShieldCheck, component: RolesPage })
adminItems.push({ id: 'permissions', label: 'Permissions', icon: KeyRound, component: PermissionsPage })
}
+ if (su || perms.includes('manage_app_settings')) {
+ adminItems.push({ id: 'oidc', label: 'OIDC / SSO', icon: LogIn, component: OidcSettings })
+ }
if (adminItems.length) {
groups.push({ label: 'Administration', items: adminItems })
}
@@ -159,7 +167,7 @@ watch(isOpen, (v) => {
{{ activeLabel }}
-
@@ -203,7 +211,7 @@ watch(isOpen, (v) => {
-
+
diff --git a/client/src/router/index.ts b/client/src/router/index.ts
index 5316d45eb..a9032d828 100644
--- a/client/src/router/index.ts
+++ b/client/src/router/index.ts
@@ -33,6 +33,12 @@ const router = createRouter({
component: () => import('@/features/auth/ResetPasswordPage.vue'),
meta: { public: true },
},
+ {
+ path: '/oauth2-callback',
+ name: 'oidc-callback',
+ component: () => import('@/features/auth/OidcCallbackPage.vue'),
+ meta: { public: true },
+ },
],
})
diff --git a/packages/types/src/auth.ts b/packages/types/src/auth.ts
index a80636bb8..71145695c 100644
--- a/packages/types/src/auth.ts
+++ b/packages/types/src/auth.ts
@@ -22,10 +22,20 @@ export interface AuthUser {
active: boolean
isDefaultPassword: boolean
settings: Record
+ avatarUrl?: string | null
+ provisioningMethod: 'local' | 'oidc'
roles: Role[]
permissions: string[] // flat list; '*' means superuser
}
+export interface OidcPublicConfig {
+ enabled: boolean
+ providerName: string
+ issuerUri: string
+ clientId: string
+ scopes: string
+}
+
export interface AuthResponse {
accessToken: string
user: AuthUser
diff --git a/server/.env.example b/server/.env.example
index ed28e8623..49cef9999 100644
--- a/server/.env.example
+++ b/server/.env.example
@@ -13,6 +13,9 @@ JWT_REFRESH_EXPIRES_IN=7d
# Storage
BOOKS_PATH=/books
+# OIDC (optional — configure via admin settings UI; these are just for reference)
+# OIDC config is stored in the database (app_settings table, key: oidc_config)
+
# SMTP (optional — if unset, self-service password reset is disabled)
SMTP_HOST=
SMTP_PORT=587
diff --git a/server/package.json b/server/package.json
index bf49e1128..e835eec93 100644
--- a/server/package.json
+++ b/server/package.json
@@ -9,7 +9,7 @@
"build": "nest build",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"start": "nest start",
- "start:dev": "nest start --watch",
+ "start:dev": "sh -c 'export $(grep -v \"^#\" .env | xargs) 2>/dev/null; exec nest start --watch'",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"db:generate": "drizzle-kit generate --name",
@@ -26,6 +26,7 @@
"dependencies": {
"7z-wasm": "^1.2.0",
"@fastify/cookie": "^11.0.2",
+ "@fastify/formbody": "^8.0.2",
"@fastify/rate-limit": "^10.3.0",
"@fastify/static": "^9.0.0",
"@nestjs/common": "^11.0.1",
@@ -43,6 +44,7 @@
"drizzle-kit": "^0.31.8",
"drizzle-orm": "^0.45.1",
"fast-xml-parser": "^5.3.3",
+ "jose": "^6.1.3",
"node-unrar-js": "^2.0.2",
"nodemailer": "^7.0.12",
"passport": "^0.7.0",
diff --git a/server/src/common/filters/http-exception.filter.ts b/server/src/common/filters/http-exception.filter.ts
index d29f38f30..802c9760d 100644
--- a/server/src/common/filters/http-exception.filter.ts
+++ b/server/src/common/filters/http-exception.filter.ts
@@ -12,7 +12,8 @@ export class GlobalExceptionFilter implements ExceptionFilter {
const status = exception instanceof HttpException ? exception.getStatus() : HttpStatus.INTERNAL_SERVER_ERROR;
- const message = exception instanceof HttpException ? exception.getResponse() : 'Internal server error';
+ const raw = exception instanceof HttpException ? exception.getResponse() : 'Internal server error';
+ const message = typeof raw === 'string' ? raw : ((raw as any).message ?? 'An error occurred');
if (status === HttpStatus.INTERNAL_SERVER_ERROR) {
this.logger.error(exception);
diff --git a/server/src/common/guards/permission.guard.ts b/server/src/common/guards/permission.guard.ts
index f6e00ed24..1f8f15f6a 100644
--- a/server/src/common/guards/permission.guard.ts
+++ b/server/src/common/guards/permission.guard.ts
@@ -1,6 +1,7 @@
import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
+import { IS_PUBLIC_KEY } from '../decorators/public.decorator';
import { PERMISSION_KEY } from '../decorators/require-permission.decorator';
import { PermissionService } from '../services/permission.service';
import { RequestUser } from '../types/request-user';
@@ -13,6 +14,9 @@ export class PermissionGuard implements CanActivate {
) {}
canActivate(context: ExecutionContext): boolean {
+ const isPublic = this.reflector.getAllAndOverride(IS_PUBLIC_KEY, [context.getHandler(), context.getClass()]);
+ if (isPublic) return true;
+
const required = this.reflector.getAllAndOverride(PERMISSION_KEY, [context.getHandler(), context.getClass()]);
if (!required) return true;
diff --git a/server/src/common/types/request-user.ts b/server/src/common/types/request-user.ts
index 401f69831..b38ee20df 100644
--- a/server/src/common/types/request-user.ts
+++ b/server/src/common/types/request-user.ts
@@ -21,5 +21,7 @@ export interface RequestUser {
isDefaultPassword: boolean;
tokenVersion: number;
settings: Record;
+ avatarUrl: string | null;
+ provisioningMethod: string;
roles: RequestUserRole[];
}
diff --git a/server/src/db/migrations/0000_init_schema.sql b/server/src/db/migrations/0000_add_oidc_schema.sql
similarity index 89%
rename from server/src/db/migrations/0000_init_schema.sql
rename to server/src/db/migrations/0000_add_oidc_schema.sql
index 363301257..72a6e5a74 100644
--- a/server/src/db/migrations/0000_init_schema.sql
+++ b/server/src/db/migrations/0000_add_oidc_schema.sql
@@ -75,6 +75,10 @@ CREATE TABLE "users" (
"is_default_password" boolean DEFAULT false NOT NULL,
"token_version" integer DEFAULT 1 NOT NULL,
"settings" jsonb DEFAULT '{}'::jsonb NOT NULL,
+ "oidc_subject" text,
+ "oidc_issuer" text,
+ "avatar_url" text,
+ "provisioning_method" varchar(20) DEFAULT 'local' NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "users_username_unique" UNIQUE("username"),
@@ -211,6 +215,25 @@ CREATE TABLE "reading_progress" (
CONSTRAINT "reading_progress_book_file_id_user_id_pk" PRIMARY KEY("book_file_id","user_id")
);
--> statement-breakpoint
+CREATE TABLE "oidc_group_mappings" (
+ "id" serial PRIMARY KEY NOT NULL,
+ "oidc_group_claim" text NOT NULL,
+ "role_id" integer,
+ "created_at" timestamp DEFAULT now() NOT NULL,
+ CONSTRAINT "oidc_group_mappings_oidc_group_claim_unique" UNIQUE("oidc_group_claim")
+);
+--> statement-breakpoint
+CREATE TABLE "oidc_sessions" (
+ "id" serial PRIMARY KEY NOT NULL,
+ "user_id" integer NOT NULL,
+ "oidc_subject" text NOT NULL,
+ "oidc_issuer" text NOT NULL,
+ "oidc_session_id" text,
+ "id_token_hint" text,
+ "revoked" boolean DEFAULT false NOT NULL,
+ "created_at" timestamp DEFAULT now() NOT NULL
+);
+--> statement-breakpoint
ALTER TABLE "password_reset_tokens" ADD CONSTRAINT "password_reset_tokens_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "refresh_tokens" ADD CONSTRAINT "refresh_tokens_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "role_permissions" ADD CONSTRAINT "role_permissions_role_id_roles_id_fk" FOREIGN KEY ("role_id") REFERENCES "public"."roles"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
@@ -236,8 +259,13 @@ ALTER TABLE "bookmarks" ADD CONSTRAINT "bookmarks_user_id_users_id_fk" FOREIGN K
ALTER TABLE "bookmarks" ADD CONSTRAINT "bookmarks_book_id_books_id_fk" FOREIGN KEY ("book_id") REFERENCES "public"."books"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "reading_progress" ADD CONSTRAINT "reading_progress_book_file_id_book_files_id_fk" FOREIGN KEY ("book_file_id") REFERENCES "public"."book_files"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "reading_progress" ADD CONSTRAINT "reading_progress_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
+ALTER TABLE "oidc_group_mappings" ADD CONSTRAINT "oidc_group_mappings_role_id_roles_id_fk" FOREIGN KEY ("role_id") REFERENCES "public"."roles"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
+ALTER TABLE "oidc_sessions" ADD CONSTRAINT "oidc_sessions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "password_reset_tokens_user_id_idx" ON "password_reset_tokens" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "refresh_tokens_user_id_idx" ON "refresh_tokens" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "annotations_user_id_idx" ON "annotations" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "bookmarks_user_id_idx" ON "bookmarks" USING btree ("user_id");--> statement-breakpoint
-CREATE INDEX "reading_progress_user_id_idx" ON "reading_progress" USING btree ("user_id");
\ No newline at end of file
+CREATE INDEX "reading_progress_user_id_idx" ON "reading_progress" USING btree ("user_id");--> statement-breakpoint
+CREATE INDEX "oidc_sessions_user_id_idx" ON "oidc_sessions" USING btree ("user_id");--> statement-breakpoint
+CREATE INDEX "oidc_sessions_subject_issuer_idx" ON "oidc_sessions" USING btree ("oidc_subject","oidc_issuer");--> statement-breakpoint
+CREATE INDEX "oidc_sessions_sid_idx" ON "oidc_sessions" USING btree ("oidc_session_id");
\ No newline at end of file
diff --git a/server/src/db/migrations/meta/0000_snapshot.json b/server/src/db/migrations/meta/0000_snapshot.json
index 8c98bae5d..2b58ec4c9 100644
--- a/server/src/db/migrations/meta/0000_snapshot.json
+++ b/server/src/db/migrations/meta/0000_snapshot.json
@@ -1,5 +1,5 @@
{
- "id": "746052d2-4cc0-4eb3-83bc-4c488e8c6506",
+ "id": "8b1e1a78-207d-4b60-8237-1d13069c26e0",
"prevId": "00000000-0000-0000-0000-000000000000",
"version": "7",
"dialect": "postgresql",
@@ -589,6 +589,31 @@
"notNull": true,
"default": "'{}'::jsonb"
},
+ "oidc_subject": {
+ "name": "oidc_subject",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "oidc_issuer": {
+ "name": "oidc_issuer",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "avatar_url": {
+ "name": "avatar_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "provisioning_method": {
+ "name": "provisioning_method",
+ "type": "varchar(20)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'local'"
+ },
"created_at": {
"name": "created_at",
"type": "timestamp",
@@ -1653,6 +1678,195 @@
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
+ },
+ "public.oidc_group_mappings": {
+ "name": "oidc_group_mappings",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "oidc_group_claim": {
+ "name": "oidc_group_claim",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "role_id": {
+ "name": "role_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "oidc_group_mappings_role_id_roles_id_fk": {
+ "name": "oidc_group_mappings_role_id_roles_id_fk",
+ "tableFrom": "oidc_group_mappings",
+ "tableTo": "roles",
+ "columnsFrom": [
+ "role_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "oidc_group_mappings_oidc_group_claim_unique": {
+ "name": "oidc_group_mappings_oidc_group_claim_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "oidc_group_claim"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.oidc_sessions": {
+ "name": "oidc_sessions",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "oidc_subject": {
+ "name": "oidc_subject",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "oidc_issuer": {
+ "name": "oidc_issuer",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "oidc_session_id": {
+ "name": "oidc_session_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "id_token_hint": {
+ "name": "id_token_hint",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "revoked": {
+ "name": "revoked",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "oidc_sessions_user_id_idx": {
+ "name": "oidc_sessions_user_id_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "oidc_sessions_subject_issuer_idx": {
+ "name": "oidc_sessions_subject_issuer_idx",
+ "columns": [
+ {
+ "expression": "oidc_subject",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "oidc_issuer",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "oidc_sessions_sid_idx": {
+ "name": "oidc_sessions_sid_idx",
+ "columns": [
+ {
+ "expression": "oidc_session_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "oidc_sessions_user_id_users_id_fk": {
+ "name": "oidc_sessions_user_id_users_id_fk",
+ "tableFrom": "oidc_sessions",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
}
},
"enums": {
diff --git a/server/src/db/migrations/meta/_journal.json b/server/src/db/migrations/meta/_journal.json
index 79ccb8926..e15a976b0 100644
--- a/server/src/db/migrations/meta/_journal.json
+++ b/server/src/db/migrations/meta/_journal.json
@@ -5,8 +5,8 @@
{
"idx": 0,
"version": "7",
- "when": 1768298523076,
- "tag": "0000_init_schema",
+ "when": 1768360764570,
+ "tag": "0000_add_oidc_schema",
"breakpoints": true
}
]
diff --git a/server/src/db/schema/auth.ts b/server/src/db/schema/auth.ts
index 9e5fc4a93..0c3fe09b0 100644
--- a/server/src/db/schema/auth.ts
+++ b/server/src/db/schema/auth.ts
@@ -12,6 +12,10 @@ export const users = pgTable('users', {
isDefaultPassword: boolean('is_default_password').notNull().default(false),
tokenVersion: integer('token_version').notNull().default(1),
settings: jsonb('settings').notNull().default({}),
+ oidcSubject: text('oidc_subject'),
+ oidcIssuer: text('oidc_issuer'),
+ avatarUrl: text('avatar_url'),
+ provisioningMethod: varchar('provisioning_method', { length: 20 }).notNull().default('local'),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at')
.notNull()
diff --git a/server/src/db/schema/index.ts b/server/src/db/schema/index.ts
index 1cb8bbb28..f9ba0d511 100644
--- a/server/src/db/schema/index.ts
+++ b/server/src/db/schema/index.ts
@@ -4,3 +4,4 @@ export * from './books';
export * from './metadata';
export * from './scanner';
export * from './reader';
+export * from './oidc';
diff --git a/server/src/db/schema/oidc.ts b/server/src/db/schema/oidc.ts
new file mode 100644
index 000000000..9aefb0a4d
--- /dev/null
+++ b/server/src/db/schema/oidc.ts
@@ -0,0 +1,31 @@
+import { boolean, index, integer, pgTable, serial, text, timestamp } from 'drizzle-orm/pg-core';
+
+import { roles, users } from './auth';
+
+export const oidcSessions = pgTable(
+ 'oidc_sessions',
+ {
+ id: serial('id').primaryKey(),
+ userId: integer('user_id')
+ .notNull()
+ .references(() => users.id, { onDelete: 'cascade' }),
+ oidcSubject: text('oidc_subject').notNull(),
+ oidcIssuer: text('oidc_issuer').notNull(),
+ oidcSessionId: text('oidc_session_id'),
+ idTokenHint: text('id_token_hint'),
+ revoked: boolean('revoked').notNull().default(false),
+ createdAt: timestamp('created_at').notNull().defaultNow(),
+ },
+ (t) => [
+ index('oidc_sessions_user_id_idx').on(t.userId),
+ index('oidc_sessions_subject_issuer_idx').on(t.oidcSubject, t.oidcIssuer),
+ index('oidc_sessions_sid_idx').on(t.oidcSessionId),
+ ],
+);
+
+export const oidcGroupMappings = pgTable('oidc_group_mappings', {
+ id: serial('id').primaryKey(),
+ oidcGroupClaim: text('oidc_group_claim').notNull().unique(),
+ roleId: integer('role_id').references(() => roles.id, { onDelete: 'set null' }),
+ createdAt: timestamp('created_at').notNull().defaultNow(),
+});
diff --git a/server/src/db/schema/reader.ts b/server/src/db/schema/reader.ts
index 0cbad2961..be1e15d4e 100644
--- a/server/src/db/schema/reader.ts
+++ b/server/src/db/schema/reader.ts
@@ -1,14 +1,4 @@
-import {
- index,
- integer,
- pgTable,
- primaryKey,
- real,
- serial,
- text,
- timestamp,
- varchar,
-} from 'drizzle-orm/pg-core';
+import { index, integer, pgTable, primaryKey, real, serial, text, timestamp, varchar } from 'drizzle-orm/pg-core';
import { bookFiles, books } from './books';
import { users } from './auth';
@@ -29,10 +19,7 @@ export const readingProgress = pgTable(
pageNumber: integer('page_number'),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
},
- (t) => [
- primaryKey({ columns: [t.bookFileId, t.userId] }),
- index('reading_progress_user_id_idx').on(t.userId),
- ],
+ (t) => [primaryKey({ columns: [t.bookFileId, t.userId] }), index('reading_progress_user_id_idx').on(t.userId)],
);
export type ReadingProgress = typeof readingProgress.$inferSelect;
diff --git a/server/src/modules/app-settings/app-settings.controller.ts b/server/src/modules/app-settings/app-settings.controller.ts
index 3dc31e139..72a04fa2d 100644
--- a/server/src/modules/app-settings/app-settings.controller.ts
+++ b/server/src/modules/app-settings/app-settings.controller.ts
@@ -1,8 +1,10 @@
-import { Body, Controller, Get, HttpCode, HttpStatus, Param, Patch } from '@nestjs/common';
+import { Body, Controller, Get, HttpCode, HttpStatus, Param, Patch, Post, Put, Query } from '@nestjs/common';
+import { Public } from '../../common/decorators/public.decorator';
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
import { AppSettingsService } from './app-settings.service';
import { UpdateAppSettingDto } from './dto/update-app-setting.dto';
+import { UpdateOidcConfigDto } from './dto/update-oidc-config.dto';
@Controller('app-settings')
@RequirePermission('manage_app_settings')
@@ -19,4 +21,46 @@ export class AppSettingsController {
update(@Param('key') key: string, @Body() dto: UpdateAppSettingDto) {
return this.appSettingsService.update(key, dto.value);
}
+
+ @Public()
+ @Get('oidc/public')
+ async getOidcPublicConfig() {
+ const config = await this.appSettingsService.getOidcConfig();
+ return {
+ enabled: config.enabled,
+ providerName: config.providerName,
+ issuerUri: config.issuerUri,
+ clientId: config.clientId,
+ scopes: config.scopes,
+ };
+ }
+
+ @Get('oidc')
+ async getOidcConfig() {
+ const config = await this.appSettingsService.getOidcConfig();
+ return { ...config, clientSecret: config.clientSecret ? '***' : '' };
+ }
+
+ @Put('oidc')
+ updateOidcConfig(@Body() dto: UpdateOidcConfigDto) {
+ return this.appSettingsService.updateOidcConfig(dto);
+ }
+
+ @Post('oidc/test')
+ @HttpCode(HttpStatus.OK)
+ async testOidcConnection(@Query('issuerUri') issuerUri?: string) {
+ const uri = issuerUri || (await this.appSettingsService.getOidcConfig()).issuerUri;
+ if (!uri) {
+ return { success: false, error: 'Issuer URI is not configured' };
+ }
+ try {
+ const url = `${uri.replace(/\/$/, '')}/.well-known/openid-configuration`;
+ const res = await fetch(url);
+ if (!res.ok) return { success: false, error: `Provider returned HTTP ${res.status}` };
+ const doc = await res.json();
+ return { success: true, issuer: doc.issuer, authorizationEndpoint: doc.authorization_endpoint };
+ } catch (err) {
+ return { success: false, error: String(err) };
+ }
+ }
}
diff --git a/server/src/modules/app-settings/app-settings.module.ts b/server/src/modules/app-settings/app-settings.module.ts
index e212d5f03..7ea9996c8 100644
--- a/server/src/modules/app-settings/app-settings.module.ts
+++ b/server/src/modules/app-settings/app-settings.module.ts
@@ -6,5 +6,6 @@ import { AppSettingsService } from './app-settings.service';
@Module({
controllers: [AppSettingsController],
providers: [AppSettingsService],
+ exports: [AppSettingsService],
})
export class AppSettingsModule {}
diff --git a/server/src/modules/app-settings/app-settings.service.ts b/server/src/modules/app-settings/app-settings.service.ts
index b98f4652d..14748288c 100644
--- a/server/src/modules/app-settings/app-settings.service.ts
+++ b/server/src/modules/app-settings/app-settings.service.ts
@@ -7,6 +7,37 @@ import * as schema from '../../db/schema';
type Db = NodePgDatabase;
+export interface OidcFullConfig {
+ enabled: boolean;
+ providerName: string;
+ issuerUri: string;
+ clientId: string;
+ clientSecret: string;
+ scopes: string;
+ claimMapping: {
+ username: string;
+ name: string;
+ email: string;
+ groups: string;
+ };
+ autoProvision: {
+ enabled: boolean;
+ allowLocalLinking: boolean;
+ defaultRoleId: number | null;
+ };
+}
+
+const DEFAULT_OIDC_CONFIG: OidcFullConfig = {
+ enabled: false,
+ providerName: '',
+ issuerUri: '',
+ clientId: '',
+ clientSecret: '',
+ scopes: 'openid profile email',
+ claimMapping: { username: 'preferred_username', name: 'name', email: 'email', groups: 'groups' },
+ autoProvision: { enabled: false, allowLocalLinking: true, defaultRoleId: null },
+};
+
@Injectable()
export class AppSettingsService {
constructor(@Inject(DB) private readonly db: Db) {}
@@ -21,4 +52,32 @@ export class AppSettingsService {
if (!setting) throw new NotFoundException(`Setting '${key}' not found`);
return setting;
}
+
+ async getOidcConfig(): Promise {
+ const row = await this.db.query.appSettings.findFirst({ where: eq(schema.appSettings.key, 'oidc_config') });
+ if (!row) return { ...DEFAULT_OIDC_CONFIG };
+ try {
+ return JSON.parse(row.value) as OidcFullConfig;
+ } catch {
+ return { ...DEFAULT_OIDC_CONFIG };
+ }
+ }
+
+ async updateOidcConfig(config: Partial): Promise {
+ const current = await this.getOidcConfig();
+ const merged: OidcFullConfig = {
+ ...current,
+ ...config,
+ claimMapping: { ...current.claimMapping, ...(config.claimMapping ?? {}) },
+ autoProvision: { ...current.autoProvision, ...(config.autoProvision ?? {}) },
+ };
+ const value = JSON.stringify(merged);
+
+ await this.db
+ .insert(schema.appSettings)
+ .values({ key: 'oidc_config', value })
+ .onConflictDoUpdate({ target: schema.appSettings.key, set: { value } });
+
+ return merged;
+ }
}
diff --git a/server/src/modules/app-settings/dto/update-oidc-config.dto.ts b/server/src/modules/app-settings/dto/update-oidc-config.dto.ts
new file mode 100644
index 000000000..0d6dc2e0c
--- /dev/null
+++ b/server/src/modules/app-settings/dto/update-oidc-config.dto.ts
@@ -0,0 +1,65 @@
+import { Type } from 'class-transformer';
+import { IsBoolean, IsInt, IsOptional, IsPositive, IsString, ValidateNested } from 'class-validator';
+
+class ClaimMappingDto {
+ @IsString()
+ username: string;
+
+ @IsString()
+ name: string;
+
+ @IsString()
+ email: string;
+
+ @IsString()
+ groups: string;
+}
+
+class AutoProvisionDto {
+ @IsBoolean()
+ enabled: boolean;
+
+ @IsBoolean()
+ allowLocalLinking: boolean;
+
+ @IsOptional()
+ @IsInt()
+ @IsPositive()
+ defaultRoleId: number | null;
+}
+
+export class UpdateOidcConfigDto {
+ @IsOptional()
+ @IsBoolean()
+ enabled?: boolean;
+
+ @IsOptional()
+ @IsString()
+ providerName?: string;
+
+ @IsOptional()
+ @IsString()
+ issuerUri?: string;
+
+ @IsOptional()
+ @IsString()
+ clientId?: string;
+
+ @IsOptional()
+ @IsString()
+ clientSecret?: string;
+
+ @IsOptional()
+ @IsString()
+ scopes?: string;
+
+ @IsOptional()
+ @ValidateNested()
+ @Type(() => ClaimMappingDto)
+ claimMapping?: ClaimMappingDto;
+
+ @IsOptional()
+ @ValidateNested()
+ @Type(() => AutoProvisionDto)
+ autoProvision?: AutoProvisionDto;
+}
diff --git a/server/src/modules/auth/auth.controller.ts b/server/src/modules/auth/auth.controller.ts
index ccf2e7ffe..657646757 100644
--- a/server/src/modules/auth/auth.controller.ts
+++ b/server/src/modules/auth/auth.controller.ts
@@ -8,12 +8,17 @@ import { AuthService } from './auth.service';
import { ChangePasswordDto } from './dto/change-password.dto';
import { ForgotPasswordDto } from './dto/forgot-password.dto';
import { LoginDto } from './dto/login.dto';
+import { OidcCallbackDto } from './dto/oidc-callback.dto';
import { RegisterDto } from './dto/register.dto';
import { ResetPasswordDto } from './dto/reset-password.dto';
+import { OidcService } from './oidc/oidc.service';
@Controller('auth')
export class AuthController {
- constructor(private readonly authService: AuthService) {}
+ constructor(
+ private readonly authService: AuthService,
+ private readonly oidcService: OidcService,
+ ) {}
@Public()
@Post('register')
@@ -37,7 +42,7 @@ export class AuthController {
@Public()
@Post('logout')
- @HttpCode(HttpStatus.NO_CONTENT)
+ @HttpCode(HttpStatus.OK)
logout(@Req() req: FastifyRequest, @Res({ passthrough: true }) reply: FastifyReply) {
return this.authService.logout(req, reply);
}
@@ -77,4 +82,27 @@ export class AuthController {
changePassword(@CurrentUser() user: RequestUser, @Body() dto: ChangePasswordDto, @Res({ passthrough: true }) reply: FastifyReply) {
return this.authService.changePassword(user.id, dto, reply);
}
+
+ @Public()
+ @Post('oidc/state')
+ @HttpCode(HttpStatus.OK)
+ oidcGenerateState() {
+ return { state: this.oidcService.generateState() };
+ }
+
+ @Public()
+ @Post('oidc/callback')
+ @HttpCode(HttpStatus.OK)
+ oidcCallback(@Body() dto: OidcCallbackDto, @Res({ passthrough: true }) reply: FastifyReply) {
+ return this.oidcService.handleCallback(dto, reply);
+ }
+
+ @Public()
+ @Post('oidc/backchannel-logout')
+ @HttpCode(HttpStatus.OK)
+ oidcBackchannelLogout(@Req() req: FastifyRequest) {
+ const body = req.body as Record | undefined;
+ const logoutToken = body?.['logout_token'] ?? '';
+ return this.oidcService.handleBackchannelLogout(logoutToken);
+ }
}
diff --git a/server/src/modules/auth/auth.module.ts b/server/src/modules/auth/auth.module.ts
index 6c32dddf4..a3411465e 100644
--- a/server/src/modules/auth/auth.module.ts
+++ b/server/src/modules/auth/auth.module.ts
@@ -4,10 +4,20 @@ import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { CommonModule } from '../../common/common.module';
+import { AppSettingsModule } from '../app-settings/app-settings.module';
import { UserModule } from '../user/user.module';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { JwtStrategy } from './jwt.strategy';
+import { BackchannelLogoutService } from './oidc/backchannel-logout.service';
+import { OidcClaimExtractorService } from './oidc/oidc-claim-extractor.service';
+import { OidcDiscoveryService } from './oidc/oidc-discovery.service';
+import { OidcGroupMappingService } from './oidc/oidc-group-mapping.service';
+import { OidcService } from './oidc/oidc.service';
+import { OidcSessionRepository } from './oidc/oidc-session.repository';
+import { OidcStateService } from './oidc/oidc-state.service';
+import { OidcTokenClientService } from './oidc/oidc-token-client.service';
+import { OidcTokenValidatorService } from './oidc/oidc-token-validator.service';
@Module({
imports: [
@@ -21,9 +31,22 @@ import { JwtStrategy } from './jwt.strategy';
}),
UserModule,
CommonModule,
+ AppSettingsModule,
],
controllers: [AuthController],
- providers: [AuthService, JwtStrategy],
+ providers: [
+ AuthService,
+ JwtStrategy,
+ OidcService,
+ OidcStateService,
+ OidcDiscoveryService,
+ OidcTokenClientService,
+ OidcTokenValidatorService,
+ OidcClaimExtractorService,
+ OidcSessionRepository,
+ OidcGroupMappingService,
+ BackchannelLogoutService,
+ ],
exports: [AuthService],
})
export class AuthModule {}
diff --git a/server/src/modules/auth/auth.service.ts b/server/src/modules/auth/auth.service.ts
index cd9713431..88bd6a9f5 100644
--- a/server/src/modules/auth/auth.service.ts
+++ b/server/src/modules/auth/auth.service.ts
@@ -21,12 +21,15 @@ import { DB } from '../../db/db.module';
import * as schema from '../../db/schema';
import type { RequestUser } from '../../common/types/request-user';
import { MailerService } from '../../common/services/mailer.service';
+import { AppSettingsService } from '../app-settings/app-settings.service';
import { UserService } from '../user/user.service';
import { ChangePasswordDto } from './dto/change-password.dto';
import { ForgotPasswordDto } from './dto/forgot-password.dto';
import { LoginDto } from './dto/login.dto';
import { RegisterDto } from './dto/register.dto';
import { ResetPasswordDto } from './dto/reset-password.dto';
+import { OidcDiscoveryService } from './oidc/oidc-discovery.service';
+import { OidcSessionRepository } from './oidc/oidc-session.repository';
function parseDurationMs(duration: string): number {
const match = duration.match(/^(\d+)([smhd])$/);
@@ -49,6 +52,9 @@ export class AuthService {
private readonly jwtService: JwtService,
private readonly config: ConfigService,
private readonly mailerService: MailerService,
+ private readonly appSettings: AppSettingsService,
+ private readonly oidcSessionRepo: OidcSessionRepository,
+ private readonly oidcDiscovery: OidcDiscoveryService,
@Inject(DB) private readonly db: NodePgDatabase,
) {}
@@ -116,6 +122,8 @@ export class AuthService {
active: user.active,
isDefaultPassword: user.isDefaultPassword,
settings: user.settings,
+ avatarUrl: user.avatarUrl,
+ provisioningMethod: user.provisioningMethod,
roles: user.roles.map((r) => ({
id: r.id,
name: r.name,
@@ -128,6 +136,14 @@ export class AuthService {
};
}
+ async issueTokensForUser(userId: number, reply: FastifyReply) {
+ const user = await this.userService.findByIdWithRolesAndPermissions(userId);
+ if (!user || !user.active) throw new UnauthorizedException();
+ const { accessToken, rawRefreshToken } = await this.issueTokenPair(userId, user.tokenVersion);
+ this.setRefreshCookie(reply, rawRefreshToken);
+ return { accessToken, user: this.buildUserResponse(user) };
+ }
+
async refresh(req: FastifyRequest, reply: FastifyReply) {
const rawToken = req.cookies?.refresh_token;
if (!rawToken) throw new UnauthorizedException();
@@ -163,19 +179,48 @@ export class AuthService {
return { accessToken };
}
- async logout(req: FastifyRequest, reply: FastifyReply) {
+ async logout(req: FastifyRequest, reply: FastifyReply): Promise<{ logoutUrl?: string }> {
+ let userId: number | undefined;
+
const rawToken: string | undefined = req.cookies?.refresh_token;
if (rawToken) {
const tokenHash = sha256(rawToken);
const row = await this.db.query.refreshTokens.findFirst({ where: eq(schema.refreshTokens.tokenHash, tokenHash) });
if (row) {
+ userId = row.userId;
await Promise.all([
this.userService.incrementTokenVersion(row.userId),
this.db.update(schema.refreshTokens).set({ revokedAt: new Date() }).where(eq(schema.refreshTokens.id, row.id)),
]);
}
}
+
this.clearRefreshCookie(reply);
+
+ if (!userId) return {};
+
+ const oidcSession = await this.oidcSessionRepo.findActiveByUserId(userId);
+ if (!oidcSession?.idTokenHint) return {};
+
+ try {
+ const config = await this.appSettings.getOidcConfig();
+ if (!config.enabled) return {};
+
+ const disc = await this.oidcDiscovery.getDiscoveryDoc(config.issuerUri);
+ if (!disc.endSessionEndpoint) return {};
+
+ const origin = (req.headers['origin'] as string | undefined) ?? (req.headers['referer'] as string | undefined);
+ const postLogoutUri = origin ? new URL('/login', origin).toString() : undefined;
+
+ const params = new URLSearchParams({ id_token_hint: oidcSession.idTokenHint });
+ if (postLogoutUri) params.set('post_logout_redirect_uri', postLogoutUri);
+
+ await this.oidcSessionRepo.revokeByUserId(userId);
+
+ return { logoutUrl: `${disc.endSessionEndpoint}?${params.toString()}` };
+ } catch {
+ return {};
+ }
}
async validateUser(userId: number, tokenVersion: number) {
@@ -247,6 +292,10 @@ export class AuthService {
});
if (!user) throw new UnauthorizedException();
+ if (user.provisioningMethod === 'oidc') {
+ throw new BadRequestException('OIDC accounts cannot change their password here');
+ }
+
const valid = await compare(dto.currentPassword, user.passwordHash);
if (!valid) throw new UnauthorizedException('Current password is incorrect');
diff --git a/server/src/modules/auth/dto/oidc-callback.dto.ts b/server/src/modules/auth/dto/oidc-callback.dto.ts
new file mode 100644
index 000000000..da36b92f8
--- /dev/null
+++ b/server/src/modules/auth/dto/oidc-callback.dto.ts
@@ -0,0 +1,23 @@
+import { IsNotEmpty, IsString } from 'class-validator';
+
+export class OidcCallbackDto {
+ @IsString()
+ @IsNotEmpty()
+ code: string;
+
+ @IsString()
+ @IsNotEmpty()
+ codeVerifier: string;
+
+ @IsString()
+ @IsNotEmpty()
+ redirectUri: string;
+
+ @IsString()
+ @IsNotEmpty()
+ nonce: string;
+
+ @IsString()
+ @IsNotEmpty()
+ state: string;
+}
diff --git a/server/src/modules/auth/oidc/backchannel-logout.service.ts b/server/src/modules/auth/oidc/backchannel-logout.service.ts
new file mode 100644
index 000000000..82b958b41
--- /dev/null
+++ b/server/src/modules/auth/oidc/backchannel-logout.service.ts
@@ -0,0 +1,95 @@
+import { Inject, Injectable, Logger } from '@nestjs/common';
+import { and, eq, isNull } from 'drizzle-orm';
+import { NodePgDatabase } from 'drizzle-orm/node-postgres';
+
+import { DB } from '../../../db/db.module';
+import * as schema from '../../../db/schema';
+import { AppSettingsService } from '../../app-settings/app-settings.service';
+import { UserService } from '../../user/user.service';
+import { OidcDiscoveryService } from './oidc-discovery.service';
+import { OidcSessionRepository } from './oidc-session.repository';
+import { OidcTokenValidatorService } from './oidc-token-validator.service';
+
+type Db = NodePgDatabase;
+
+@Injectable()
+export class BackchannelLogoutService {
+ private readonly logger = new Logger(BackchannelLogoutService.name);
+ // JTI replay prevention: jti -> expiry timestamp
+ private readonly usedJtis = new Map();
+
+ constructor(
+ @Inject(DB) private readonly db: Db,
+ private readonly appSettings: AppSettingsService,
+ private readonly discovery: OidcDiscoveryService,
+ private readonly tokenValidator: OidcTokenValidatorService,
+ private readonly sessionRepo: OidcSessionRepository,
+ private readonly userService: UserService,
+ ) {}
+
+ async handleLogout(logoutToken: string): Promise {
+ const config = await this.appSettings.getOidcConfig();
+ if (!config.enabled) return;
+
+ const discovery = await this.discovery.getDiscoveryDoc(config.issuerUri);
+
+ const claims = await this.tokenValidator.validateLogoutToken(logoutToken, {
+ issuer: discovery.issuer,
+ clientId: config.clientId,
+ jwksUri: discovery.jwksUri,
+ });
+
+ // Replay prevention
+ if (claims.jti) {
+ if (this.usedJtis.has(claims.jti)) {
+ this.logger.warn('Replay detected for logout token JTI');
+ return;
+ }
+ const exp = claims.exp ? claims.exp * 1000 : Date.now() + 3_600_000;
+ this.usedJtis.set(claims.jti, exp);
+
+ // Prune expired JTIs
+ const now = Date.now();
+ for (const [jti, expiry] of this.usedJtis) {
+ if (expiry < now) this.usedJtis.delete(jti);
+ }
+ }
+
+ const subject = String(claims.sub ?? '');
+ const sid = claims.sid ? String(claims.sid) : undefined;
+
+ let userId: number | undefined;
+
+ if (sid) {
+ const session = await this.sessionRepo.findActiveBySid(sid);
+ if (session) {
+ userId = session.userId;
+ await this.sessionRepo.revokeBySid(sid);
+ }
+ }
+
+ if (!userId && subject) {
+ const sessions = await this.sessionRepo.findActiveBySubjectAndIssuer(subject, discovery.issuer);
+ if (sessions.length > 0) {
+ userId = sessions[0].userId;
+ await this.sessionRepo.revokeBySubjectAndIssuer(subject, discovery.issuer);
+ }
+ }
+
+ if (!userId) {
+ this.logger.warn(`Backchannel logout: no active OIDC session for sub=${subject} sid=${sid}`);
+ return;
+ }
+
+ // Revoke all refresh tokens
+ await this.db
+ .update(schema.refreshTokens)
+ .set({ revokedAt: new Date() })
+ .where(and(eq(schema.refreshTokens.userId, userId), isNull(schema.refreshTokens.revokedAt)));
+
+ // Invalidate all JWTs via token version bump
+ await this.userService.incrementTokenVersion(userId);
+
+ this.logger.log(`Backchannel logout: revoked all sessions for userId=${userId}`);
+ }
+}
diff --git a/server/src/modules/auth/oidc/oidc-claim-extractor.service.ts b/server/src/modules/auth/oidc/oidc-claim-extractor.service.ts
new file mode 100644
index 000000000..79eeb6781
--- /dev/null
+++ b/server/src/modules/auth/oidc/oidc-claim-extractor.service.ts
@@ -0,0 +1,36 @@
+import { Injectable } from '@nestjs/common';
+
+export interface ExtractedClaims {
+ subject: string;
+ username: string;
+ name: string;
+ email?: string;
+ avatarUrl?: string;
+ groups: string[];
+}
+
+export interface ClaimMapping {
+ username: string;
+ name: string;
+ email: string;
+ groups: string;
+}
+
+@Injectable()
+export class OidcClaimExtractorService {
+ extract(idTokenClaims: Record, userInfoClaims: Record, mapping: ClaimMapping): ExtractedClaims {
+ // userInfo claims take precedence over ID token claims
+ const merged = { ...idTokenClaims, ...userInfoClaims };
+
+ const subject = String(merged.sub ?? '');
+ const username = String(merged[mapping.username] ?? merged.email ?? merged.sub ?? '');
+ const name = String(merged[mapping.name] ?? merged.name ?? username);
+ const email = merged[mapping.email] ? String(merged[mapping.email]) : undefined;
+ const avatarUrl = merged.picture ? String(merged.picture) : undefined;
+
+ const rawGroups = merged[mapping.groups];
+ const groups = Array.isArray(rawGroups) ? rawGroups.map(String) : [];
+
+ return { subject, username, name, email, avatarUrl, groups };
+ }
+}
diff --git a/server/src/modules/auth/oidc/oidc-discovery.service.ts b/server/src/modules/auth/oidc/oidc-discovery.service.ts
new file mode 100644
index 000000000..16ed47e61
--- /dev/null
+++ b/server/src/modules/auth/oidc/oidc-discovery.service.ts
@@ -0,0 +1,56 @@
+import { Injectable, InternalServerErrorException, Logger } from '@nestjs/common';
+
+export interface OidcDiscoveryDoc {
+ issuer: string;
+ authorizationEndpoint: string;
+ tokenEndpoint: string;
+ jwksUri: string;
+ userinfoEndpoint?: string;
+ endSessionEndpoint?: string;
+ backchannelLogoutSupported: boolean;
+}
+
+interface CacheEntry {
+ doc: OidcDiscoveryDoc;
+ fetchedAt: number;
+}
+
+@Injectable()
+export class OidcDiscoveryService {
+ private readonly logger = new Logger(OidcDiscoveryService.name);
+ private readonly cache = new Map();
+ private readonly TTL = 60 * 60 * 1000; // 1 hour
+
+ async getDiscoveryDoc(issuerUri: string): Promise {
+ const normalized = issuerUri.replace(/\/$/, '');
+ const cached = this.cache.get(normalized);
+
+ if (cached && Date.now() - cached.fetchedAt < this.TTL) {
+ return cached.doc;
+ }
+
+ const url = `${normalized}/.well-known/openid-configuration`;
+
+ try {
+ const res = await fetch(url);
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
+ const raw = await res.json();
+
+ const doc: OidcDiscoveryDoc = {
+ issuer: raw.issuer,
+ authorizationEndpoint: raw.authorization_endpoint,
+ tokenEndpoint: raw.token_endpoint,
+ jwksUri: raw.jwks_uri,
+ userinfoEndpoint: raw.userinfo_endpoint,
+ endSessionEndpoint: raw.end_session_endpoint,
+ backchannelLogoutSupported: raw.backchannel_logout_supported === true,
+ };
+
+ this.cache.set(normalized, { doc, fetchedAt: Date.now() });
+ return doc;
+ } catch (err) {
+ this.logger.error(`Failed to fetch OIDC discovery doc from ${url}`, err);
+ throw new InternalServerErrorException('Failed to reach OIDC provider');
+ }
+ }
+}
diff --git a/server/src/modules/auth/oidc/oidc-group-mapping.service.ts b/server/src/modules/auth/oidc/oidc-group-mapping.service.ts
new file mode 100644
index 000000000..b68b3d291
--- /dev/null
+++ b/server/src/modules/auth/oidc/oidc-group-mapping.service.ts
@@ -0,0 +1,32 @@
+import { Inject, Injectable, Logger } from '@nestjs/common';
+import { inArray } from 'drizzle-orm';
+import { NodePgDatabase } from 'drizzle-orm/node-postgres';
+
+import { DB } from '../../../db/db.module';
+import * as schema from '../../../db/schema';
+
+type Db = NodePgDatabase;
+
+@Injectable()
+export class OidcGroupMappingService {
+ private readonly logger = new Logger(OidcGroupMappingService.name);
+
+ constructor(@Inject(DB) private readonly db: Db) {}
+
+ async syncUserGroups(userId: number, groups: string[]): Promise {
+ if (groups.length === 0) return;
+
+ const mappings = await this.db.query.oidcGroupMappings.findMany({
+ where: inArray(schema.oidcGroupMappings.oidcGroupClaim, groups),
+ });
+
+ const roleIds = [...new Set(mappings.flatMap((m) => (m.roleId ? [m.roleId] : [])))];
+ if (roleIds.length === 0) return;
+
+ for (const roleId of roleIds) {
+ await this.db.insert(schema.userRoles).values({ userId, roleId }).onConflictDoNothing();
+ }
+
+ this.logger.debug(`Synced ${roleIds.length} role(s) for user ${userId} from OIDC groups`);
+ }
+}
diff --git a/server/src/modules/auth/oidc/oidc-session.repository.ts b/server/src/modules/auth/oidc/oidc-session.repository.ts
new file mode 100644
index 000000000..44e2dfb15
--- /dev/null
+++ b/server/src/modules/auth/oidc/oidc-session.repository.ts
@@ -0,0 +1,52 @@
+import { Inject, Injectable } from '@nestjs/common';
+import { and, eq } from 'drizzle-orm';
+import { NodePgDatabase } from 'drizzle-orm/node-postgres';
+
+import { DB } from '../../../db/db.module';
+import * as schema from '../../../db/schema';
+
+type Db = NodePgDatabase;
+
+@Injectable()
+export class OidcSessionRepository {
+ constructor(@Inject(DB) private readonly db: Db) {}
+
+ async create(data: typeof schema.oidcSessions.$inferInsert) {
+ const [session] = await this.db.insert(schema.oidcSessions).values(data).returning();
+ return session;
+ }
+
+ async findActiveBySid(sid: string) {
+ return this.db.query.oidcSessions.findFirst({
+ where: and(eq(schema.oidcSessions.oidcSessionId, sid), eq(schema.oidcSessions.revoked, false)),
+ });
+ }
+
+ async findActiveBySubjectAndIssuer(subject: string, issuer: string) {
+ return this.db.query.oidcSessions.findMany({
+ where: and(eq(schema.oidcSessions.oidcSubject, subject), eq(schema.oidcSessions.oidcIssuer, issuer), eq(schema.oidcSessions.revoked, false)),
+ });
+ }
+
+ async revokeBySid(sid: string) {
+ await this.db.update(schema.oidcSessions).set({ revoked: true }).where(eq(schema.oidcSessions.oidcSessionId, sid));
+ }
+
+ async revokeBySubjectAndIssuer(subject: string, issuer: string) {
+ await this.db
+ .update(schema.oidcSessions)
+ .set({ revoked: true })
+ .where(and(eq(schema.oidcSessions.oidcSubject, subject), eq(schema.oidcSessions.oidcIssuer, issuer)));
+ }
+
+ async findActiveByUserId(userId: number) {
+ return this.db.query.oidcSessions.findFirst({
+ where: and(eq(schema.oidcSessions.userId, userId), eq(schema.oidcSessions.revoked, false)),
+ orderBy: (t, { desc }) => [desc(t.createdAt)],
+ });
+ }
+
+ async revokeByUserId(userId: number) {
+ await this.db.update(schema.oidcSessions).set({ revoked: true }).where(eq(schema.oidcSessions.userId, userId));
+ }
+}
diff --git a/server/src/modules/auth/oidc/oidc-state.service.ts b/server/src/modules/auth/oidc/oidc-state.service.ts
new file mode 100644
index 000000000..1baa56e7c
--- /dev/null
+++ b/server/src/modules/auth/oidc/oidc-state.service.ts
@@ -0,0 +1,27 @@
+import { Injectable } from '@nestjs/common';
+import { randomBytes } from 'crypto';
+
+@Injectable()
+export class OidcStateService {
+ private readonly states = new Map();
+ private readonly TTL = 5 * 60 * 1000; // 5 minutes
+
+ generate(): string {
+ // Prune expired states
+ const now = Date.now();
+ for (const [state, ts] of this.states) {
+ if (now - ts > this.TTL) this.states.delete(state);
+ }
+
+ const state = randomBytes(32).toString('base64url');
+ this.states.set(state, Date.now());
+ return state;
+ }
+
+ validateAndConsume(state: string): boolean {
+ const ts = this.states.get(state);
+ if (!ts) return false;
+ this.states.delete(state); // one-time use
+ return Date.now() - ts <= this.TTL;
+ }
+}
diff --git a/server/src/modules/auth/oidc/oidc-token-client.service.ts b/server/src/modules/auth/oidc/oidc-token-client.service.ts
new file mode 100644
index 000000000..0b6180948
--- /dev/null
+++ b/server/src/modules/auth/oidc/oidc-token-client.service.ts
@@ -0,0 +1,63 @@
+import { Injectable, InternalServerErrorException, Logger } from '@nestjs/common';
+
+export interface TokenResponse {
+ accessToken: string;
+ idToken: string;
+ refreshToken?: string;
+ expiresIn?: number;
+}
+
+@Injectable()
+export class OidcTokenClientService {
+ private readonly logger = new Logger(OidcTokenClientService.name);
+
+ async exchangeCode(params: {
+ code: string;
+ codeVerifier: string;
+ redirectUri: string;
+ tokenEndpoint: string;
+ clientId: string;
+ clientSecret: string;
+ }): Promise {
+ const body = new URLSearchParams({
+ grant_type: 'authorization_code',
+ code: params.code,
+ code_verifier: params.codeVerifier,
+ redirect_uri: params.redirectUri,
+ client_id: params.clientId,
+ client_secret: params.clientSecret,
+ });
+
+ const res = await fetch(params.tokenEndpoint, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
+ body: body.toString(),
+ });
+
+ if (!res.ok) {
+ const text = await res.text().catch(() => '');
+ this.logger.error(`Token exchange failed: ${res.status} ${text}`);
+ throw new InternalServerErrorException('Token exchange with OIDC provider failed');
+ }
+
+ const data = await res.json();
+ return {
+ accessToken: data.access_token,
+ idToken: data.id_token,
+ refreshToken: data.refresh_token,
+ expiresIn: data.expires_in,
+ };
+ }
+
+ async fetchUserInfo(userinfoEndpoint: string, accessToken: string): Promise> {
+ try {
+ const res = await fetch(userinfoEndpoint, {
+ headers: { Authorization: `Bearer ${accessToken}` },
+ });
+ if (!res.ok) return {};
+ return res.json();
+ } catch {
+ return {};
+ }
+ }
+}
diff --git a/server/src/modules/auth/oidc/oidc-token-validator.service.ts b/server/src/modules/auth/oidc/oidc-token-validator.service.ts
new file mode 100644
index 000000000..7f0b06acb
--- /dev/null
+++ b/server/src/modules/auth/oidc/oidc-token-validator.service.ts
@@ -0,0 +1,75 @@
+import { Injectable, Logger, UnauthorizedException } from '@nestjs/common';
+import { createRemoteJWKSet, jwtVerify } from 'jose';
+
+type JWKSGetter = ReturnType;
+
+interface JWKSCacheEntry {
+ jwks: JWKSGetter;
+ fetchedAt: number;
+}
+
+@Injectable()
+export class OidcTokenValidatorService {
+ private readonly logger = new Logger(OidcTokenValidatorService.name);
+ private readonly jwksCache = new Map();
+ private readonly JWKS_TTL = 6 * 3600 * 1000; // 6 hours
+
+ private getJwks(jwksUri: string): JWKSGetter {
+ const cached = this.jwksCache.get(jwksUri);
+ if (cached && Date.now() - cached.fetchedAt < this.JWKS_TTL) {
+ return cached.jwks;
+ }
+ const jwks = createRemoteJWKSet(new URL(jwksUri));
+ this.jwksCache.set(jwksUri, { jwks, fetchedAt: Date.now() });
+ return jwks;
+ }
+
+ async validateIdToken(idToken: string, opts: { issuer: string; clientId: string; nonce: string; jwksUri: string }) {
+ const jwks = this.getJwks(opts.jwksUri);
+ try {
+ const { payload } = await jwtVerify(idToken, jwks, {
+ issuer: opts.issuer,
+ audience: opts.clientId,
+ clockTolerance: 30,
+ });
+
+ if (payload.nonce !== opts.nonce) {
+ throw new UnauthorizedException('Invalid nonce');
+ }
+
+ return payload;
+ } catch (err) {
+ if (err instanceof UnauthorizedException) throw err;
+ this.logger.warn('ID token validation failed', err);
+ throw new UnauthorizedException('Invalid ID token');
+ }
+ }
+
+ async validateLogoutToken(logoutToken: string, opts: { issuer: string; clientId: string; jwksUri: string }) {
+ const jwks = this.getJwks(opts.jwksUri);
+ try {
+ const { payload } = await jwtVerify(logoutToken, jwks, {
+ issuer: opts.issuer,
+ audience: opts.clientId,
+ clockTolerance: 30,
+ });
+
+ // Logout tokens must have the backchannel-logout event
+ const events = payload['events'] as Record | undefined;
+ if (!events?.['http://schemas.openid.net/event/backchannel-logout']) {
+ throw new UnauthorizedException('Not a valid logout token');
+ }
+
+ // Logout tokens must not have a nonce
+ if (payload.nonce) {
+ throw new UnauthorizedException('Logout token must not have nonce');
+ }
+
+ return payload;
+ } catch (err) {
+ if (err instanceof UnauthorizedException) throw err;
+ this.logger.warn('Logout token validation failed', err);
+ throw new UnauthorizedException('Invalid logout token');
+ }
+ }
+}
diff --git a/server/src/modules/auth/oidc/oidc.service.ts b/server/src/modules/auth/oidc/oidc.service.ts
new file mode 100644
index 000000000..2b9283e53
--- /dev/null
+++ b/server/src/modules/auth/oidc/oidc.service.ts
@@ -0,0 +1,138 @@
+import { Injectable, Logger, UnauthorizedException } from '@nestjs/common';
+import type { FastifyReply } from 'fastify';
+
+import { AppSettingsService } from '../../app-settings/app-settings.service';
+import { UserService } from '../../user/user.service';
+import { AuthService } from '../auth.service';
+import { BackchannelLogoutService } from './backchannel-logout.service';
+import { OidcClaimExtractorService } from './oidc-claim-extractor.service';
+import { OidcDiscoveryService } from './oidc-discovery.service';
+import { OidcGroupMappingService } from './oidc-group-mapping.service';
+import { OidcSessionRepository } from './oidc-session.repository';
+import { OidcStateService } from './oidc-state.service';
+import { OidcTokenClientService } from './oidc-token-client.service';
+import { OidcTokenValidatorService } from './oidc-token-validator.service';
+
+@Injectable()
+export class OidcService {
+ private readonly logger = new Logger(OidcService.name);
+
+ constructor(
+ private readonly appSettings: AppSettingsService,
+ private readonly discovery: OidcDiscoveryService,
+ private readonly tokenClient: OidcTokenClientService,
+ private readonly tokenValidator: OidcTokenValidatorService,
+ private readonly claimExtractor: OidcClaimExtractorService,
+ private readonly stateService: OidcStateService,
+ private readonly sessionRepo: OidcSessionRepository,
+ private readonly groupMapping: OidcGroupMappingService,
+ private readonly backchannelLogout: BackchannelLogoutService,
+ private readonly userService: UserService,
+ private readonly authService: AuthService,
+ ) {}
+
+ generateState(): string {
+ return this.stateService.generate();
+ }
+
+ handleBackchannelLogout(logoutToken: string): Promise {
+ return this.backchannelLogout.handleLogout(logoutToken);
+ }
+
+ async handleCallback(params: { code: string; codeVerifier: string; redirectUri: string; nonce: string; state: string }, reply: FastifyReply) {
+ if (!this.stateService.validateAndConsume(params.state)) {
+ throw new UnauthorizedException('Invalid or expired state');
+ }
+
+ const config = await this.appSettings.getOidcConfig();
+ if (!config.enabled) throw new UnauthorizedException('OIDC is not enabled');
+
+ const disc = await this.discovery.getDiscoveryDoc(config.issuerUri);
+
+ const tokens = await this.tokenClient.exchangeCode({
+ code: params.code,
+ codeVerifier: params.codeVerifier,
+ redirectUri: params.redirectUri,
+ tokenEndpoint: disc.tokenEndpoint,
+ clientId: config.clientId,
+ clientSecret: config.clientSecret,
+ });
+
+ const idTokenClaims = await this.tokenValidator.validateIdToken(tokens.idToken, {
+ issuer: disc.issuer,
+ clientId: config.clientId,
+ nonce: params.nonce,
+ jwksUri: disc.jwksUri,
+ });
+
+ let userInfoClaims: Record = {};
+ if (disc.userinfoEndpoint) {
+ userInfoClaims = await this.tokenClient.fetchUserInfo(disc.userinfoEndpoint, tokens.accessToken);
+ }
+
+ const claims = this.claimExtractor.extract(idTokenClaims as Record, userInfoClaims, config.claimMapping);
+
+ const user = await this.findOrProvisionUser(claims, config, disc.issuer);
+
+ // Track OIDC session for backchannel logout
+ const sid = (idTokenClaims as Record).sid ? String((idTokenClaims as Record).sid) : undefined;
+ await this.sessionRepo.create({
+ userId: user.id,
+ oidcSubject: claims.subject,
+ oidcIssuer: disc.issuer,
+ oidcSessionId: sid,
+ idTokenHint: tokens.idToken,
+ });
+
+ return this.authService.issueTokensForUser(user.id, reply);
+ }
+
+ private async findOrProvisionUser(
+ claims: { subject: string; username: string; name: string; email?: string; avatarUrl?: string; groups: string[] },
+ config: Awaited>,
+ issuer: string,
+ ) {
+ // 1. Look up by OIDC subject+issuer (primary key for OIDC identity)
+ let user = await this.userService.findByOidcSubject(claims.subject, issuer);
+
+ // 2. Fallback: look up by username and link if allowed
+ if (!user && config.autoProvision.allowLocalLinking) {
+ const byUsername = await this.userService.findByUsername(claims.username);
+ if (byUsername) {
+ await this.userService.linkOidcIdentity(byUsername.id, claims.subject, issuer, claims.avatarUrl);
+ user = await this.userService.findByOidcSubject(claims.subject, issuer);
+ }
+ }
+
+ // 3. Auto-provision new user
+ if (!user && config.autoProvision.enabled) {
+ user = await this.userService.createOidcUser({
+ username: claims.username,
+ name: claims.name,
+ email: claims.email,
+ oidcSubject: claims.subject,
+ oidcIssuer: issuer,
+ avatarUrl: claims.avatarUrl,
+ });
+
+ if (config.autoProvision.defaultRoleId) {
+ await this.userService.assignRoleDirectly(user.id, config.autoProvision.defaultRoleId);
+ }
+ }
+
+ if (!user) {
+ throw new UnauthorizedException('User not found and auto-provisioning is disabled');
+ }
+
+ if (!user.active) {
+ throw new UnauthorizedException('Account is deactivated');
+ }
+
+ // Sync OIDC group-to-role mappings
+ if (claims.groups.length > 0) {
+ await this.groupMapping.syncUserGroups(user.id, claims.groups);
+ }
+
+ return user;
+ }
+}
diff --git a/server/src/modules/seed/seed.service.ts b/server/src/modules/seed/seed.service.ts
index 6794d3b13..33571f3e3 100644
--- a/server/src/modules/seed/seed.service.ts
+++ b/server/src/modules/seed/seed.service.ts
@@ -93,6 +93,20 @@ export class SeedService implements OnApplicationBootstrap {
.insert(schema.appSettings)
.values({ key: 'allow_registration', value: 'false' })
.onConflictDoNothing({ target: schema.appSettings.key });
+
+ const defaultOidcConfig = JSON.stringify({
+ enabled: false,
+ issuerUri: '',
+ clientId: '',
+ clientSecret: '',
+ scopes: 'openid profile email',
+ claimMapping: { username: 'preferred_username', name: 'name', email: 'email', groups: 'groups' },
+ autoProvision: { enabled: false, allowLocalLinking: true, defaultRoleId: null },
+ });
+ await this.db
+ .insert(schema.appSettings)
+ .values({ key: 'oidc_config', value: defaultOidcConfig })
+ .onConflictDoNothing({ target: schema.appSettings.key });
}
private async seedDefaultAdmin() {
diff --git a/server/src/modules/user/user.module.ts b/server/src/modules/user/user.module.ts
index df806bac1..8fee8e61e 100644
--- a/server/src/modules/user/user.module.ts
+++ b/server/src/modules/user/user.module.ts
@@ -7,6 +7,6 @@ import { UserService } from './user.service';
@Module({
controllers: [UserController],
providers: [UserService, UserRepository],
- exports: [UserService],
+ exports: [UserService, UserRepository],
})
export class UserModule {}
diff --git a/server/src/modules/user/user.repository.ts b/server/src/modules/user/user.repository.ts
index b1762a888..6ceed4b30 100644
--- a/server/src/modules/user/user.repository.ts
+++ b/server/src/modules/user/user.repository.ts
@@ -1,7 +1,8 @@
import { Inject, Injectable } from '@nestjs/common';
-import { createHash, randomBytes } from 'crypto';
+import { createHash, randomBytes, randomUUID } from 'crypto';
import { and, count, eq, inArray, isNull, ne, sql } from 'drizzle-orm';
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
+import { hash } from 'bcryptjs';
import { RequestUser, RequestUserRole } from '../../common/types/request-user';
import { DB } from '../../db';
@@ -94,6 +95,8 @@ export class UserRepository {
isDefaultPassword: schema.users.isDefaultPassword,
tokenVersion: schema.users.tokenVersion,
settings: schema.users.settings,
+ avatarUrl: schema.users.avatarUrl,
+ provisioningMethod: schema.users.provisioningMethod,
roleId: schema.roles.id,
roleName: schema.roles.name,
roleDescription: schema.roles.description,
@@ -143,6 +146,8 @@ export class UserRepository {
isDefaultPassword: first.isDefaultPassword,
tokenVersion: first.tokenVersion,
settings: first.settings as Record,
+ avatarUrl: first.avatarUrl,
+ provisioningMethod: first.provisioningMethod,
roles: Array.from(rolesMap.values()),
};
}
@@ -214,4 +219,36 @@ export class UserRepository {
return rawToken;
}
+
+ async findByOidcSubject(subject: string, issuer: string) {
+ return this.db.query.users.findFirst({
+ where: and(eq(schema.users.oidcSubject, subject), eq(schema.users.oidcIssuer, issuer)),
+ });
+ }
+
+ async linkOidcIdentity(userId: number, oidcSubject: string, oidcIssuer: string, avatarUrl?: string) {
+ await this.db
+ .update(schema.users)
+ .set({ oidcSubject, oidcIssuer, ...(avatarUrl ? { avatarUrl } : {}) })
+ .where(eq(schema.users.id, userId));
+ }
+
+ async createOidcUser(data: { username: string; name: string; email?: string; oidcSubject: string; oidcIssuer: string; avatarUrl?: string }) {
+ const passwordHash = await hash(`OIDC_USER_${randomUUID()}`, 12);
+ const [user] = await this.db
+ .insert(schema.users)
+ .values({
+ username: data.username,
+ name: data.name,
+ email: data.email,
+ passwordHash,
+ isDefaultPassword: false,
+ oidcSubject: data.oidcSubject,
+ oidcIssuer: data.oidcIssuer,
+ avatarUrl: data.avatarUrl,
+ provisioningMethod: 'oidc',
+ })
+ .returning();
+ return user;
+ }
}
diff --git a/server/src/modules/user/user.service.ts b/server/src/modules/user/user.service.ts
index 98b90657b..61dd6f603 100644
--- a/server/src/modules/user/user.service.ts
+++ b/server/src/modules/user/user.service.ts
@@ -29,6 +29,22 @@ export class UserService {
return this.userRepo.findByEmail(email);
}
+ findByOidcSubject(subject: string, issuer: string) {
+ return this.userRepo.findByOidcSubject(subject, issuer);
+ }
+
+ linkOidcIdentity(userId: number, oidcSubject: string, oidcIssuer: string, avatarUrl?: string) {
+ return this.userRepo.linkOidcIdentity(userId, oidcSubject, oidcIssuer, avatarUrl);
+ }
+
+ createOidcUser(data: Parameters[0]) {
+ return this.userRepo.createOidcUser(data);
+ }
+
+ assignRoleDirectly(userId: number, roleId: number) {
+ return this.userRepo.assignRole(userId, roleId);
+ }
+
generatePasswordResetToken(userId: number): Promise {
return this.userRepo.generateResetToken(userId);
}