From 5f637389f3eb4a09a00a6b68892afa5293037c78 Mon Sep 17 00:00:00 2001 From: Joachim Date: Thu, 9 Apr 2026 09:50:43 +0200 Subject: [PATCH 1/6] Add OIDC SSO support --- .env.example | 22 +++ SELF-HOSTING.md | 62 +++++++ app/pages/auth/sign-in.vue | 269 ++++++++++++++++++---------- app/pages/auth/sign-up.vue | 351 +++++++++++++++++++++++-------------- app/utils/auth-client.ts | 12 +- nuxt.config.ts | 231 ++++++++++++++---------- server/utils/auth.ts | 132 +++++++++----- server/utils/env.ts | 133 ++++++++------ 8 files changed, 797 insertions(+), 415 deletions(-) diff --git a/.env.example b/.env.example index a79937c2..4e220d61 100644 --- a/.env.example +++ b/.env.example @@ -74,3 +74,25 @@ NUXT_PUBLIC_SITE_URL=http://localhost:3000 # POSTHOG_PUBLIC_KEY=phc_... # EU data center (default). Use https://us.i.posthog.com for US. # POSTHOG_HOST=https://eu.i.posthog.com + +# ─── Optional: OIDC SSO (Keycloak, Authentik, Authelia, Okta, etc.) ────────── +# Enable Single Sign-On via any OIDC-compliant identity provider. +# All three variables (CLIENT_ID, CLIENT_SECRET, DISCOVERY_URL) must be set to activate SSO. +# When configured, a "Sign in with SSO" button appears on the login page. + +# OIDC client ID — from your identity provider's client/application settings +# OIDC_CLIENT_ID=reqcore + +# OIDC client secret — from your identity provider's credentials tab +# OIDC_CLIENT_SECRET=your-client-secret-here + +# OIDC discovery URL — the .well-known/openid-configuration endpoint +# Keycloak: https://keycloak.example.com/realms/YOUR_REALM/.well-known/openid-configuration +# Authentik: https://authentik.example.com/application/o/YOUR_APP/.well-known/openid-configuration +# Authelia: https://authelia.example.com/.well-known/openid-configuration +# Okta: https://YOUR_ORG.okta.com/.well-known/openid-configuration +# Azure AD: https://login.microsoftonline.com/YOUR_TENANT_ID/v2.0/.well-known/openid-configuration +# OIDC_DISCOVERY_URL=https://keycloak.example.com/realms/master/.well-known/openid-configuration + +# Display name for the SSO button (default: "SSO") +# OIDC_PROVIDER_NAME=Company SSO diff --git a/SELF-HOSTING.md b/SELF-HOSTING.md index 20c522fb..1f0f8a96 100644 --- a/SELF-HOSTING.md +++ b/SELF-HOSTING.md @@ -450,6 +450,68 @@ sudo dpkg-reconfigure -plow unattended-upgrades --- +## OIDC Single Sign-On (SSO) + +Reqcore supports Single Sign-On via any OIDC-compliant identity provider — Keycloak, Authentik, Authelia, Okta, Azure AD, and more. When configured, a "Sign in with SSO" button appears on the login and registration pages. + +### Why SSO? + +- **Centralized identity** — users sign in once across all internal tools +- **Zero-friction onboarding** — new hires get instant access, leavers are cut off centrally +- **Enterprise security** — MFA, session policies, and brute-force protection managed in one place + +### Setup + +**1. Create an OIDC client in your identity provider:** + +| Setting | Value | +|---|---| +| Client type | OpenID Connect (confidential) | +| Client ID | Any name (e.g., `reqcore`) | +| Client authentication | ON (confidential/secret) | +| Valid redirect URI | `https://your-reqcore-domain.com/api/auth/oauth2/callback/oidc` | +| Valid post-logout redirect URI | `https://your-reqcore-domain.com/*` | +| Scopes | `openid`, `email`, `profile` | + +**2. Set environment variables:** + +```bash +# All three are required to activate SSO +OIDC_CLIENT_ID=reqcore +OIDC_CLIENT_SECRET=your-client-secret-from-provider +OIDC_DISCOVERY_URL=https://keycloak.example.com/realms/master/.well-known/openid-configuration + +# Optional: customize the button label (default: "SSO") +OIDC_PROVIDER_NAME=Company SSO +``` + +**3. Restart Reqcore:** + +```bash +docker compose down && docker compose up -d +``` + +The SSO button appears automatically on the sign-in and sign-up pages. + +### Provider-Specific Discovery URLs + +| Provider | Discovery URL format | +|---|---| +| Keycloak | `https://keycloak.example.com/realms/YOUR_REALM/.well-known/openid-configuration` | +| Authentik | `https://authentik.example.com/application/o/YOUR_APP/.well-known/openid-configuration` | +| Authelia | `https://authelia.example.com/.well-known/openid-configuration` | +| Okta | `https://YOUR_ORG.okta.com/.well-known/openid-configuration` | +| Azure AD | `https://login.microsoftonline.com/YOUR_TENANT_ID/v2.0/.well-known/openid-configuration` | + +### Security + +- **PKCE** (Proof Key for Code Exchange) is enabled by default for protection against authorization code interception +- **Issuer validation** (RFC 9207) is enforced to prevent OAuth mix-up attacks +- **OIDC discovery** automatically fetches and validates all provider endpoints +- SSO is **completely opt-in** — it has zero impact when the environment variables are not set + +--- + ## Monitoring & Health Checks ### Built-in System Health Dashboard diff --git a/app/pages/auth/sign-in.vue b/app/pages/auth/sign-in.vue index c8bdc4b2..0beca04e 100644 --- a/app/pages/auth/sign-in.vue +++ b/app/pages/auth/sign-in.vue @@ -1,114 +1,191 @@ - diff --git a/app/pages/auth/sign-up.vue b/app/pages/auth/sign-up.vue index 0161341f..4046a442 100644 --- a/app/pages/auth/sign-up.vue +++ b/app/pages/auth/sign-up.vue @@ -1,149 +1,232 @@ - diff --git a/app/utils/auth-client.ts b/app/utils/auth-client.ts index e94d3e7b..019ee7ac 100644 --- a/app/utils/auth-client.ts +++ b/app/utils/auth-client.ts @@ -1,12 +1,16 @@ -import { createAuthClient } from 'better-auth/vue' -import { organizationClient } from 'better-auth/client/plugins' -import { ac, owner, admin, member } from '~~/shared/permissions' +import { createAuthClient } from "better-auth/vue"; +import { + organizationClient, + genericOAuthClient, +} from "better-auth/client/plugins"; +import { ac, owner, admin, member } from "~~/shared/permissions"; export const authClient = createAuthClient({ plugins: [ + genericOAuthClient(), organizationClient({ ac, roles: { owner, admin, member }, }), ], -}) +}); diff --git a/nuxt.config.ts b/nuxt.config.ts index 44a8db2a..c0e862b9 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -1,74 +1,106 @@ // https://nuxt.com/docs/api/configuration/nuxt-config -import tailwindcss from '@tailwindcss/vite' +import tailwindcss from "@tailwindcss/vite"; -const railwayEnvironmentName = process.env.RAILWAY_ENVIRONMENT_NAME?.toLowerCase() ?? '' -const railwayPublicDomain = process.env.RAILWAY_PUBLIC_DOMAIN?.toLowerCase() ?? '' -const siteUrl = process.env.NUXT_PUBLIC_SITE_URL || 'https://reqcore.com' -const i18nDefaultLocale = 'en' +const railwayEnvironmentName = + process.env.RAILWAY_ENVIRONMENT_NAME?.toLowerCase() ?? ""; +const railwayPublicDomain = + process.env.RAILWAY_PUBLIC_DOMAIN?.toLowerCase() ?? ""; +const siteUrl = process.env.NUXT_PUBLIC_SITE_URL || "https://reqcore.com"; +const i18nDefaultLocale = "en"; const i18nLocales = [ - { code: 'en', language: 'en-US', name: 'English', file: 'en.json' }, - { code: 'es', language: 'es-ES', name: 'Español', file: 'es.json', partial: true }, - { code: 'fr', language: 'fr-FR', name: 'Français', file: 'fr.json', partial: true }, - { code: 'de', language: 'de-DE', name: 'Deutsch', file: 'de.json', partial: true }, - { code: 'nb', language: 'nb-NO', name: 'Norsk Bokmål', file: 'nb.json' }, - { code: 'vi', language: 'vi-VN', name: 'Tiếng Việt', file: 'vi.json', partial: true }, -] + { code: "en", language: "en-US", name: "English", file: "en.json" }, + { + code: "es", + language: "es-ES", + name: "Español", + file: "es.json", + partial: true, + }, + { + code: "fr", + language: "fr-FR", + name: "Français", + file: "fr.json", + partial: true, + }, + { + code: "de", + language: "de-DE", + name: "Deutsch", + file: "de.json", + partial: true, + }, + { code: "nb", language: "nb-NO", name: "Norsk Bokmål", file: "nb.json" }, + { + code: "vi", + language: "vi-VN", + name: "Tiếng Việt", + file: "vi.json", + partial: true, + }, +]; const localizedPublicRouteRules = Object.fromEntries( i18nLocales - .filter(locale => locale.code !== i18nDefaultLocale) - .flatMap(locale => ([ + .filter((locale) => locale.code !== i18nDefaultLocale) + .flatMap((locale) => [ [`/${locale.code}/jobs`, { isr: 3600 }], [`/${locale.code}/jobs/**`, { isr: 3600 }], - ])), -) + ]), +); // Allow search-engine indexing for localized job board pages const localizedJobsRobotsRules = Object.fromEntries( i18nLocales - .filter(locale => locale.code !== i18nDefaultLocale) - .flatMap(locale => ([ - [`/${locale.code}/jobs`, { headers: { 'X-Robots-Tag': 'index, follow' } }], - [`/${locale.code}/jobs/**`, { headers: { 'X-Robots-Tag': 'index, follow' } }], - ])), -) + .filter((locale) => locale.code !== i18nDefaultLocale) + .flatMap((locale) => [ + [ + `/${locale.code}/jobs`, + { headers: { "X-Robots-Tag": "index, follow" } }, + ], + [ + `/${locale.code}/jobs/**`, + { headers: { "X-Robots-Tag": "index, follow" } }, + ], + ]), +); const isRailwayPreview = - railwayEnvironmentName.startsWith('pr') - || railwayEnvironmentName.includes('pr-') - || railwayEnvironmentName.includes('pull request') - || railwayEnvironmentName.includes('pull-request') - || railwayEnvironmentName.includes('preview') - || railwayPublicDomain.includes('-pr-') + railwayEnvironmentName.startsWith("pr") || + railwayEnvironmentName.includes("pr-") || + railwayEnvironmentName.includes("pull request") || + railwayEnvironmentName.includes("pull-request") || + railwayEnvironmentName.includes("preview") || + railwayPublicDomain.includes("-pr-"); export default defineNuxtConfig({ - compatibilityDate: '2025-07-15', + compatibilityDate: "2025-07-15", devtools: { enabled: true }, modules: [ - '@nuxtjs/i18n', - '@nuxtjs/mdc', + "@nuxtjs/i18n", + "@nuxtjs/mdc", // Only load PostHog module when the API key is available; // the SDK crashes during prerender/build if the key is empty. - ...(process.env.POSTHOG_PUBLIC_KEY ? ['@posthog/nuxt' as const] : []), + ...(process.env.POSTHOG_PUBLIC_KEY ? ["@posthog/nuxt" as const] : []), ], - css: ['~/assets/css/main.css'], + css: ["~/assets/css/main.css"], // ───────────────────────────────────────────── // PostHog — privacy-focused product analytics & feature flags // ───────────────────────────────────────────── // Enable source maps so PostHog error tracking can display readable stack traces - sourcemap: { client: 'hidden' }, + sourcemap: { client: "hidden" }, posthogConfig: { - publicKey: process.env.POSTHOG_PUBLIC_KEY || '', - host: process.env.POSTHOG_HOST || 'https://eu.i.posthog.com', + publicKey: process.env.POSTHOG_PUBLIC_KEY || "", + host: process.env.POSTHOG_HOST || "https://eu.i.posthog.com", clientConfig: { // ── Reverse proxy: route PostHog through reqcore.com to bypass ad blockers ── // Requests to /ingest/** are proxied by Nitro to eu.i.posthog.com - api_host: '/ingest', - ui_host: 'https://eu.posthog.com', + api_host: "/ingest", + ui_host: "https://eu.posthog.com", // ── Privacy: disable invasive features ── autocapture: false, disable_session_recording: true, @@ -87,8 +119,8 @@ export default defineNuxtConfig({ // No cookies stored until user grants consent. Events still flow for // aggregate analytics. On consent, persistence is upgraded to // 'localStorage+cookie' via set_config() in the consent composable. - persistence: 'memory', - person_profiles: 'never', + persistence: "memory", + person_profiles: "never", cross_subdomain_cookie: false, }, serverConfig: { @@ -102,15 +134,15 @@ export default defineNuxtConfig({ i18n: { baseUrl: siteUrl, defaultLocale: i18nDefaultLocale, - strategy: 'prefix_except_default', + strategy: "prefix_except_default", locales: i18nLocales, - langDir: 'locales', + langDir: "locales", detectBrowserLanguage: { useCookie: true, - cookieKey: 'reqcore_i18n_redirected', - redirectOn: 'root', + cookieKey: "reqcore_i18n_redirected", + redirectOn: "root", }, - vueI18n: './i18n.config.ts', + vueI18n: "./i18n.config.ts", }, // ───────────────────────────────────────────── @@ -118,21 +150,29 @@ export default defineNuxtConfig({ // ───────────────────────────────────────────── app: { head: { - titleTemplate: '%s — Reqcore', + titleTemplate: "%s — Reqcore", link: [ - { rel: 'icon', type: 'image/png', href: '/favicon.png' }, - { rel: 'icon', type: 'image/svg+xml', href: '/favicon.svg' }, - { rel: 'apple-touch-icon', sizes: '180x180', href: '/apple-touch-icon.png' }, + { rel: "icon", type: "image/png", href: "/favicon.png" }, + { rel: "icon", type: "image/svg+xml", href: "/favicon.svg" }, + { + rel: "apple-touch-icon", + sizes: "180x180", + href: "/apple-touch-icon.png", + }, ], meta: [ - { name: 'theme-color', content: '#09090b' }, - { name: 'viewport', content: 'width=device-width, initial-scale=1.0, maximum-scale=5.0' }, + { name: "theme-color", content: "#09090b" }, + { + name: "viewport", + content: "width=device-width, initial-scale=1.0, maximum-scale=5.0", + }, ], script: [ { // Blocking inline script to apply dark mode before first paint (prevents white flash) - innerHTML: '(function(){try{var s=localStorage.getItem("reqcore-color-mode");if(s==="dark"||(!s&&window.matchMedia("(prefers-color-scheme:dark)").matches)){document.documentElement.classList.add("dark")}}catch(e){}})()', - tagPosition: 'head', + innerHTML: + '(function(){try{var s=localStorage.getItem("reqcore-color-mode");if(s==="dark"||(!s&&window.matchMedia("(prefers-color-scheme:dark)").matches)){document.documentElement.classList.add("dark")}}catch(e){}})()', + tagPosition: "head", }, ], // Plausible removed — PostHog handles all analytics @@ -142,33 +182,45 @@ export default defineNuxtConfig({ runtimeConfig: { public: { /** Base URL of the marketing site (reqcore-web) for cross-domain links */ - marketingUrl: process.env.NUXT_PUBLIC_MARKETING_URL || 'https://reqcore.com', + marketingUrl: + process.env.NUXT_PUBLIC_MARKETING_URL || "https://reqcore.com", /** Cookie domain for cross-subdomain sharing (e.g. '.reqcore.com') */ - cookieDomain: process.env.NUXT_PUBLIC_COOKIE_DOMAIN || '', + cookieDomain: process.env.NUXT_PUBLIC_COOKIE_DOMAIN || "", // PostHog runtimeConfig is managed by @posthog/nuxt via posthogConfig above. // Override at runtime with NUXT_PUBLIC_POSTHOG_PUBLIC_KEY / NUXT_PUBLIC_POSTHOG_HOST. /** When set, the dashboard shows a read-only demo banner for this org slug */ - demoOrgSlug: process.env.DEMO_ORG_SLUG || (isRailwayPreview ? 'reqcore-demo' : ''), + demoOrgSlug: + process.env.DEMO_ORG_SLUG || (isRailwayPreview ? "reqcore-demo" : ""), /** Public live-demo account email used to prefill sign-in */ liveDemoEmail: (() => { const email = - process.env.LIVE_DEMO_EMAIL - || process.env.DEMO_EMAIL - || 'demo@reqcore.com' + process.env.LIVE_DEMO_EMAIL || + process.env.DEMO_EMAIL || + "demo@reqcore.com"; // Guard against stale applirank.com domain from old env vars - if (email.endsWith('@applirank.com')) { - console.warn('[config] Stale demo email detected (applirank.com domain) — falling back to demo@reqcore.com') - return 'demo@reqcore.com' + if (email.endsWith("@applirank.com")) { + console.warn( + "[config] Stale demo email detected (applirank.com domain) — falling back to demo@reqcore.com", + ); + return "demo@reqcore.com"; } - return email + return email; })(), /** Public live-demo passcode used to prefill sign-in */ liveDemoPasscode: - process.env.LIVE_DEMO_SECRET - || process.env.DEMO_PASSWORD - || 'demo1234', + process.env.LIVE_DEMO_SECRET || process.env.DEMO_PASSWORD || "demo1234", /** Whether in-app feedback via GitHub Issues is enabled */ - feedbackEnabled: !!(process.env.GITHUB_FEEDBACK_TOKEN && process.env.GITHUB_FEEDBACK_REPO), + feedbackEnabled: !!( + process.env.GITHUB_FEEDBACK_TOKEN && process.env.GITHUB_FEEDBACK_REPO + ), + /** Whether OIDC SSO is enabled (all three OIDC env vars are set) */ + oidcEnabled: !!( + process.env.OIDC_CLIENT_ID && + process.env.OIDC_CLIENT_SECRET && + process.env.OIDC_DISCOVERY_URL + ), + /** Display name for the SSO provider button */ + oidcProviderName: process.env.OIDC_PROVIDER_NAME || "SSO", }, }, @@ -185,49 +237,52 @@ export default defineNuxtConfig({ // NOTE: Targets are hardcoded to the EU data center (eu.i.posthog.com). // If you use the US data center, set POSTHOG_HOST=https://us.i.posthog.com // and update these two proxy targets to us-assets.i.posthog.com / us.i.posthog.com. - '/ingest/static/**': { proxy: 'https://eu-assets.i.posthog.com/static/**' }, - '/ingest/**': { proxy: 'https://eu.i.posthog.com/**' }, - '/jobs': { isr: 3600 }, - '/jobs/**': { isr: 3600 }, + "/ingest/static/**": { proxy: "https://eu-assets.i.posthog.com/static/**" }, + "/ingest/**": { proxy: "https://eu.i.posthog.com/**" }, + "/jobs": { isr: 3600 }, + "/jobs/**": { isr: 3600 }, ...localizedPublicRouteRules, }, nitro: { routeRules: { - '/**': { + "/**": { headers: { - 'X-Content-Type-Options': 'nosniff', - 'X-Frame-Options': 'DENY', - 'Referrer-Policy': 'strict-origin-when-cross-origin', - 'X-XSS-Protection': '1; mode=block', - 'Permissions-Policy': 'camera=(), microphone=(), geolocation=()', - 'Strict-Transport-Security': 'max-age=63072000; includeSubDomains; preload', - 'Content-Security-Policy': "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self' https://eu.i.posthog.com https://eu.posthog.com; frame-ancestors 'none'; base-uri 'self'; form-action 'self'", + "X-Content-Type-Options": "nosniff", + "X-Frame-Options": "DENY", + "Referrer-Policy": "strict-origin-when-cross-origin", + "X-XSS-Protection": "1; mode=block", + "Permissions-Policy": "camera=(), microphone=(), geolocation=()", + "Strict-Transport-Security": + "max-age=63072000; includeSubDomains; preload", + "Content-Security-Policy": + "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self' https://eu.i.posthog.com https://eu.posthog.com; frame-ancestors 'none'; base-uri 'self'; form-action 'self'", // Block indexing for all non-public routes by default; // overridden below for /jobs/** which should be indexable. - 'X-Robots-Tag': 'noindex, nofollow', + "X-Robots-Tag": "noindex, nofollow", }, }, // Public job board pages — allow indexing - '/jobs/**': { + "/jobs/**": { headers: { - 'X-Robots-Tag': 'index, follow', + "X-Robots-Tag": "index, follow", }, }, - '/jobs': { + "/jobs": { headers: { - 'X-Robots-Tag': 'index, follow', + "X-Robots-Tag": "index, follow", }, }, // Localized job board pages — allow indexing ...localizedJobsRobotsRules, // Allow same-origin framing for inline PDF preview in the sidebar iframe - '/api/documents/*/preview': { + "/api/documents/*/preview": { headers: { - 'X-Frame-Options': 'SAMEORIGIN', - 'Content-Security-Policy': "default-src 'none'; style-src 'unsafe-inline'", + "X-Frame-Options": "SAMEORIGIN", + "Content-Security-Policy": + "default-src 'none'; style-src 'unsafe-inline'", }, }, }, }, -}) +}); diff --git a/server/utils/auth.ts b/server/utils/auth.ts index a4108e12..8bd0897b 100644 --- a/server/utils/auth.ts +++ b/server/utils/auth.ts @@ -1,55 +1,61 @@ -import { betterAuth } from 'better-auth' -import { drizzleAdapter } from 'better-auth/adapters/drizzle' -import { organization } from 'better-auth/plugins' -import { ac, owner, admin, member } from '~~/shared/permissions' -import { sendOrgInvitationEmail } from './email' -import * as schema from '../database/schema' +import { betterAuth } from "better-auth"; +import { drizzleAdapter } from "better-auth/adapters/drizzle"; +import { organization, genericOAuth } from "better-auth/plugins"; +import { ac, owner, admin, member } from "~~/shared/permissions"; +import { sendOrgInvitationEmail } from "./email"; +import * as schema from "../database/schema"; -type Auth = ReturnType -let _auth: Auth | undefined +type Auth = ReturnType; +let _auth: Auth | undefined; function resolveTrustedOrigins(baseUrl: string): string[] { - const configuredOrigins = env.BETTER_AUTH_TRUSTED_ORIGINS - const baseOrigin = new URL(baseUrl) - const isLocalBase = baseOrigin.hostname === 'localhost' || baseOrigin.hostname === '127.0.0.1' - const defaultDevOrigins = (import.meta.dev || isLocalBase) - ? [ - 'http://localhost:3000', - 'http://localhost:3001', - 'http://localhost:3002', - 'http://localhost:3333', - 'http://127.0.0.1:3000', - 'http://127.0.0.1:3001', - 'http://127.0.0.1:3002', - 'http://127.0.0.1:3333', - ] - : [] + const configuredOrigins = env.BETTER_AUTH_TRUSTED_ORIGINS; + const baseOrigin = new URL(baseUrl); + const isLocalBase = + baseOrigin.hostname === "localhost" || baseOrigin.hostname === "127.0.0.1"; + const defaultDevOrigins = + import.meta.dev || isLocalBase + ? [ + "http://localhost:3000", + "http://localhost:3001", + "http://localhost:3002", + "http://localhost:3333", + "http://127.0.0.1:3000", + "http://127.0.0.1:3001", + "http://127.0.0.1:3002", + "http://127.0.0.1:3333", + ] + : []; - return Array.from(new Set([baseOrigin.origin, ...configuredOrigins, ...defaultDevOrigins])) + return Array.from( + new Set([baseOrigin.origin, ...configuredOrigins, ...defaultDevOrigins]), + ); } function resolveBetterAuthUrl(): string { - const explicitUrl = env.BETTER_AUTH_URL?.trim() - const railwayDomain = env.RAILWAY_PUBLIC_DOMAIN?.trim() + const explicitUrl = env.BETTER_AUTH_URL?.trim(); + const railwayDomain = env.RAILWAY_PUBLIC_DOMAIN?.trim(); // Explicit URL always wins (custom domain, local dev, etc.) if (explicitUrl) { - return explicitUrl + return explicitUrl; } // Derive from Railway's auto-injected public domain (works for all environments) if (railwayDomain) { // Railway sets this as bare domain (e.g. "app.up.railway.app"), never with protocol - const domain = railwayDomain.replace(/^https?:\/\//, '') - const url = `https://${domain}` - console.info(`[Reqcore] Using Railway public-domain BETTER_AUTH_URL: ${url}`) - return url + const domain = railwayDomain.replace(/^https?:\/\//, ""); + const url = `https://${domain}`; + console.info( + `[Reqcore] Using Railway public-domain BETTER_AUTH_URL: ${url}`, + ); + return url; } throw new Error( - 'BETTER_AUTH_URL is required. Either set it explicitly or generate a public domain in Railway.\n' + - 'Railway users: go to Settings → Networking → Generate Domain, then redeploy.', - ) + "BETTER_AUTH_URL is required. Either set it explicitly or generate a public domain in Railway.\n" + + "Railway users: go to Settings → Networking → Generate Domain, then redeploy.", + ); } /** @@ -59,13 +65,13 @@ function resolveBetterAuthUrl(): string { */ function getAuth(): Auth { if (!_auth) { - const baseURL = resolveBetterAuthUrl() + const baseURL = resolveBetterAuthUrl(); _auth = betterAuth({ baseURL, trustedOrigins: resolveTrustedOrigins(baseURL), database: drizzleAdapter(db, { - provider: 'pg', + provider: "pg", schema, }), secret: env.BETTER_AUTH_SECRET, @@ -89,8 +95,8 @@ function getAuth(): Auth { // Constructs a link the invitee clicks to accept. // Uses Resend when RESEND_API_KEY is configured, otherwise logs to console. async sendInvitationEmail(data) { - const inviteLink = `${baseURL}/auth/accept-invitation/${data.id}` - await sendOrgInvitationEmail(data, inviteLink) + const inviteLink = `${baseURL}/auth/accept-invitation/${data.id}`; + await sendOrgInvitationEmail(data, inviteLink); }, // ── Security Hardening ────────────────────────────────── @@ -99,10 +105,46 @@ function getAuth(): Auth { // 48 hours (default) — explicitly stated for auditability. invitationExpiresIn: 48 * 60 * 60, }), + + // ── OIDC SSO (Keycloak, Authentik, Authelia, Okta, Azure AD, etc.) ── + // Activated only when all three OIDC env vars are set. + // Uses better-auth's genericOAuth plugin with OIDC discovery. + ...(env.OIDC_CLIENT_ID && + env.OIDC_CLIENT_SECRET && + env.OIDC_DISCOVERY_URL + ? [ + genericOAuth({ + config: [ + { + providerId: "oidc", + clientId: env.OIDC_CLIENT_ID, + clientSecret: env.OIDC_CLIENT_SECRET, + discoveryUrl: env.OIDC_DISCOVERY_URL, + scopes: ["openid", "email", "profile"], + pkce: true, + requireIssuerValidation: true, + async mapProfileToUser(profile) { + return { + name: + profile.name || + [profile.given_name, profile.family_name] + .filter(Boolean) + .join(" ") || + profile.preferred_username || + profile.email, + email: profile.email, + image: profile.picture, + }; + }, + }, + ], + }), + ] + : []), ], - }) as unknown as Auth + }) as unknown as Auth; } - return _auth! + return _auth!; } /** @@ -113,8 +155,10 @@ function getAuth(): Auth { */ export const auth: Auth = new Proxy({} as Auth, { get(_, prop) { - const instance = getAuth() - const value = (instance as Record)[prop] - return typeof value === 'function' ? (value as Function).bind(instance) : value + const instance = getAuth(); + const value = (instance as Record)[prop]; + return typeof value === "function" + ? (value as Function).bind(instance) + : value; }, -}) +}); diff --git a/server/utils/env.ts b/server/utils/env.ts index a9b58e2d..ad4bff40 100644 --- a/server/utils/env.ts +++ b/server/utils/env.ts @@ -1,4 +1,4 @@ -import { z } from 'zod' +import { z } from "zod"; /** * Preprocessor that normalizes empty strings to undefined. @@ -6,68 +6,82 @@ import { z } from 'zod' * This ensures `.default()` and `.optional()` work as expected. */ const emptyToUndefined = z.preprocess( - (val) => (typeof val === 'string' && val.trim() === '' ? undefined : val), + (val) => (typeof val === "string" && val.trim() === "" ? undefined : val), z.string(), -) +); /** * Detect whether the current Railway environment is a PR/preview environment. * Production and long-lived environments must provide explicit BETTER_AUTH_URL. */ export function isRailwayPreviewEnvironment(environmentName?: string): boolean { - const name = environmentName?.toLowerCase().trim() ?? '' + const name = environmentName?.toLowerCase().trim() ?? ""; - if (!name) return false + if (!name) return false; // Never treat production as preview. - if (name === 'production' || name === 'prod') return false + if (name === "production" || name === "prod") return false; return ( - name.startsWith('pr') - || - /^pr(?:-|\d)/.test(name) - || name.includes('pr-') - || name.includes('pr ') - || name.includes('pull request') - || name.includes('pull-request') - || name.includes('preview') - ) + name.startsWith("pr") || + /^pr(?:-|\d)/.test(name) || + name.includes("pr-") || + name.includes("pr ") || + name.includes("pull request") || + name.includes("pull-request") || + name.includes("preview") + ); } const envSchema = z .object({ DATABASE_URL: z.url(), - BETTER_AUTH_SECRET: emptyToUndefined.pipe(z.string().min(32, 'BETTER_AUTH_SECRET must be at least 32 characters')), - BETTER_AUTH_URL: z.preprocess( - (val) => { - if (typeof val !== 'string') return val - const trimmed = val.trim() + BETTER_AUTH_SECRET: emptyToUndefined.pipe( + z.string().min(32, "BETTER_AUTH_SECRET must be at least 32 characters"), + ), + BETTER_AUTH_URL: z + .preprocess((val) => { + if (typeof val !== "string") return val; + const trimmed = val.trim(); // Treat empty strings and broken Railway template refs ("https://") as unset - if (trimmed === '' || trimmed === 'https://' || trimmed === 'http://') return undefined - return trimmed - }, - z.string().url(), - ).optional(), + if (trimmed === "" || trimmed === "https://" || trimmed === "http://") + return undefined; + return trimmed; + }, z.string().url()) + .optional(), /** Comma-separated list of additional trusted origins for Better Auth CSRF checks. */ BETTER_AUTH_TRUSTED_ORIGINS: emptyToUndefined .pipe(z.string()) - .transform(value => value.split(',').map(origin => origin.trim()).filter(Boolean)) + .transform((value) => + value + .split(",") + .map((origin) => origin.trim()) + .filter(Boolean), + ) .optional() .default([]), /** Railway environment metadata for PR/preview detection. */ RAILWAY_ENVIRONMENT_NAME: emptyToUndefined.optional(), /** PR number provided by Railway for GitHub-triggered deployments. */ - RAILWAY_GIT_PR_NUMBER: emptyToUndefined.pipe(z.string().regex(/^\d+$/, 'RAILWAY_GIT_PR_NUMBER must be numeric')).optional(), + RAILWAY_GIT_PR_NUMBER: emptyToUndefined + .pipe(z.string().regex(/^\d+$/, "RAILWAY_GIT_PR_NUMBER must be numeric")) + .optional(), /** Public domain generated by Railway for the current deployment. */ RAILWAY_PUBLIC_DOMAIN: emptyToUndefined.optional(), S3_ENDPOINT: z.url(), S3_ACCESS_KEY: emptyToUndefined.pipe(z.string().min(1)), S3_SECRET_KEY: emptyToUndefined.pipe(z.string().min(1)), S3_BUCKET: emptyToUndefined.pipe(z.string().min(1)), - S3_REGION: emptyToUndefined.pipe(z.string().min(1)).optional().default('us-east-1'), + S3_REGION: emptyToUndefined + .pipe(z.string().min(1)) + .optional() + .default("us-east-1"), /** Use path-style S3 URLs. Required for MinIO (local dev), must be `false` for Railway Buckets / AWS S3. */ S3_FORCE_PATH_STYLE: z.preprocess( - (val) => (typeof val === 'string' && val.trim() === '' ? undefined : val === 'true' || val === undefined), + (val) => + typeof val === "string" && val.trim() === "" + ? undefined + : val === "true" || val === undefined, z.boolean().default(true), ), /** IP address of the trusted reverse proxy (e.g., Railway, Cloudflare). When set, X-Forwarded-For is trusted for rate limiting. */ @@ -77,17 +91,35 @@ const envSchema = z /** Fine-grained GitHub PAT with Issues:write scope. When set (along with GITHUB_FEEDBACK_REPO), enables in-app feedback. */ GITHUB_FEEDBACK_TOKEN: emptyToUndefined.pipe(z.string().min(1)).optional(), /** GitHub repo in "owner/repo" format for feedback issues. */ - GITHUB_FEEDBACK_REPO: emptyToUndefined.pipe(z.string().regex(/^[^/]+\/[^/]+$/, 'Must be in "owner/repo" format')).optional(), + GITHUB_FEEDBACK_REPO: emptyToUndefined + .pipe( + z.string().regex(/^[^/]+\/[^/]+$/, 'Must be in "owner/repo" format'), + ) + .optional(), /** Resend API key for transactional emails (invitations, etc.). When not set, emails are logged to console. */ RESEND_API_KEY: emptyToUndefined.pipe(z.string().min(1)).optional(), /** Sender email address for Resend emails. Must be a verified domain in Resend. Defaults to "Reqcore ". */ - RESEND_FROM_EMAIL: emptyToUndefined.pipe(z.string().min(1)).optional().default('Reqcore '), + RESEND_FROM_EMAIL: emptyToUndefined + .pipe(z.string().min(1)) + .optional() + .default("Reqcore "), /** Google OAuth2 Client ID for Calendar integration. Obtain from Google Cloud Console. */ GOOGLE_CLIENT_ID: emptyToUndefined.pipe(z.string().min(1)).optional(), /** Google OAuth2 Client Secret for Calendar integration. */ GOOGLE_CLIENT_SECRET: emptyToUndefined.pipe(z.string().min(1)).optional(), /** Shared secret for authenticating cron/scheduled job requests (e.g., webhook renewal). */ CRON_SECRET: emptyToUndefined.pipe(z.string().min(16)).optional(), + /** OIDC client ID for SSO authentication (e.g., Keycloak, Authentik, Authelia, Okta). */ + OIDC_CLIENT_ID: emptyToUndefined.pipe(z.string().min(1)).optional(), + /** OIDC client secret for SSO authentication. */ + OIDC_CLIENT_SECRET: emptyToUndefined.pipe(z.string().min(1)).optional(), + /** OIDC discovery URL (must point to a .well-known/openid-configuration endpoint). */ + OIDC_DISCOVERY_URL: emptyToUndefined.pipe(z.string().url()).optional(), + /** Display name for the SSO button (e.g., "Company SSO", "Keycloak"). Defaults to "SSO". */ + OIDC_PROVIDER_NAME: emptyToUndefined + .pipe(z.string().min(1)) + .optional() + .default("SSO"), }) .superRefine((data, ctx) => { // BETTER_AUTH_URL can be derived at runtime from RAILWAY_PUBLIC_DOMAIN, @@ -95,11 +127,12 @@ const envSchema = z if (!data.BETTER_AUTH_URL && !data.RAILWAY_PUBLIC_DOMAIN) { ctx.addIssue({ code: z.ZodIssueCode.custom, - path: ['BETTER_AUTH_URL'], - message: 'BETTER_AUTH_URL is required when RAILWAY_PUBLIC_DOMAIN is not available', - }) + path: ["BETTER_AUTH_URL"], + message: + "BETTER_AUTH_URL is required when RAILWAY_PUBLIC_DOMAIN is not available", + }); } - }) + }); /** * Validated environment variables. Uses lazy initialization so the schema @@ -113,30 +146,32 @@ export const env = new Proxy({} as z.infer, { // During build-time prerendering, env vars aren't available. // Return safe defaults so the prerenderer can boot without crashing. if (import.meta.prerender) { - return '' + return ""; } - if (typeof prop === 'symbol') return undefined + if (typeof prop === "symbol") return undefined; // Parse once on first access, then cache for all subsequent reads if (!(globalThis as Record).__env) { - const result = envSchema.safeParse(process.env) + const result = envSchema.safeParse(process.env); if (!result.success) { const missing = result.error.issues - .map(i => ` - ${i.path.join('.')}: ${i.message}`) - .join('\n') + .map((i) => ` - ${i.path.join(".")}: ${i.message}`) + .join("\n"); console.error( `\n[Reqcore] ❌ Missing or invalid environment variables:\n${missing}\n\n` + - `Ensure these variables are set in your Railway service (Settings → Variables).\n` + - `Required: DATABASE_URL, BETTER_AUTH_SECRET, S3_ENDPOINT, S3_ACCESS_KEY, S3_SECRET_KEY, S3_BUCKET\n` + - `Required when not on Railway: BETTER_AUTH_URL (or generate a Railway domain)\n` + - `Optional: BETTER_AUTH_TRUSTED_ORIGINS, S3_REGION (default: us-east-1), S3_FORCE_PATH_STYLE (default: true), TRUSTED_PROXY_IP, DEMO_ORG_SLUG, RESEND_API_KEY, RESEND_FROM_EMAIL\n`, - ) - throw result.error + `Ensure these variables are set in your Railway service (Settings → Variables).\n` + + `Required: DATABASE_URL, BETTER_AUTH_SECRET, S3_ENDPOINT, S3_ACCESS_KEY, S3_SECRET_KEY, S3_BUCKET\n` + + `Required when not on Railway: BETTER_AUTH_URL (or generate a Railway domain)\n` + + `Optional: BETTER_AUTH_TRUSTED_ORIGINS, S3_REGION (default: us-east-1), S3_FORCE_PATH_STYLE (default: true), TRUSTED_PROXY_IP, DEMO_ORG_SLUG, RESEND_API_KEY, RESEND_FROM_EMAIL, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET, OIDC_DISCOVERY_URL, OIDC_PROVIDER_NAME\n`, + ); + throw result.error; } - ;(globalThis as Record).__env = result.data + (globalThis as Record).__env = result.data; } - return ((globalThis as Record).__env as Record)[prop] + return ( + (globalThis as Record).__env as Record + )[prop]; }, -}) +}); From 1b23af31b04d150e277701401e29424a07f9b8a8 Mon Sep 17 00:00:00 2001 From: Joachim Date: Thu, 9 Apr 2026 10:09:01 +0200 Subject: [PATCH 2/6] feat: add OIDC SSO environment validation and unit tests --- server/utils/env.ts | 22 +++- tests/unit/oidc-sso.test.ts | 202 ++++++++++++++++++++++++++++++++++++ 2 files changed, 223 insertions(+), 1 deletion(-) create mode 100644 tests/unit/oidc-sso.test.ts diff --git a/server/utils/env.ts b/server/utils/env.ts index ad4bff40..1448320e 100644 --- a/server/utils/env.ts +++ b/server/utils/env.ts @@ -33,7 +33,8 @@ export function isRailwayPreviewEnvironment(environmentName?: string): boolean { ); } -const envSchema = z +/** @internal Exported for unit testing. */ +export const envSchema = z .object({ DATABASE_URL: z.url(), BETTER_AUTH_SECRET: emptyToUndefined.pipe( @@ -132,6 +133,25 @@ const envSchema = z "BETTER_AUTH_URL is required when RAILWAY_PUBLIC_DOMAIN is not available", }); } + + // OIDC SSO requires all three vars or none — partial config is a misconfiguration. + const oidcVars = [ + ["OIDC_CLIENT_ID", data.OIDC_CLIENT_ID], + ["OIDC_CLIENT_SECRET", data.OIDC_CLIENT_SECRET], + ["OIDC_DISCOVERY_URL", data.OIDC_DISCOVERY_URL], + ] as const; + const setVars = oidcVars.filter(([, v]) => v); + const missingVars = oidcVars.filter(([, v]) => !v); + + if (setVars.length > 0 && missingVars.length > 0) { + for (const [name] of missingVars) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: [name], + message: `${name} is required when OIDC SSO is partially configured. Set all three OIDC variables (OIDC_CLIENT_ID, OIDC_CLIENT_SECRET, OIDC_DISCOVERY_URL) or none.`, + }); + } + } }); /** diff --git a/tests/unit/oidc-sso.test.ts b/tests/unit/oidc-sso.test.ts new file mode 100644 index 00000000..5d67a380 --- /dev/null +++ b/tests/unit/oidc-sso.test.ts @@ -0,0 +1,202 @@ +import { describe, it, expect } from 'vitest' +import { envSchema } from '../../server/utils/env' + +/** + * Base env vars required to satisfy the schema (all non-OIDC required fields). + * Used as a foundation; individual tests override specific fields. + */ +const baseEnv = { + DATABASE_URL: 'postgresql://user:pass@localhost:5432/test', + BETTER_AUTH_SECRET: 'a'.repeat(32), + BETTER_AUTH_URL: 'https://app.example.com', + S3_ENDPOINT: 'https://s3.example.com', + S3_ACCESS_KEY: 'test-key', + S3_SECRET_KEY: 'test-secret', + S3_BUCKET: 'test-bucket', +} + +describe('OIDC SSO environment configuration', () => { + describe('all three OIDC vars set', () => { + it('accepts valid OIDC configuration', () => { + const result = envSchema.safeParse({ + ...baseEnv, + OIDC_CLIENT_ID: 'reqcore', + OIDC_CLIENT_SECRET: 'super-secret', + OIDC_DISCOVERY_URL: 'https://keycloak.example.com/realms/master/.well-known/openid-configuration', + }) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.OIDC_CLIENT_ID).toBe('reqcore') + expect(result.data.OIDC_CLIENT_SECRET).toBe('super-secret') + expect(result.data.OIDC_DISCOVERY_URL).toBe('https://keycloak.example.com/realms/master/.well-known/openid-configuration') + expect(result.data.OIDC_PROVIDER_NAME).toBe('SSO') // default + } + }) + + it('accepts custom OIDC_PROVIDER_NAME', () => { + const result = envSchema.safeParse({ + ...baseEnv, + OIDC_CLIENT_ID: 'reqcore', + OIDC_CLIENT_SECRET: 'super-secret', + OIDC_DISCOVERY_URL: 'https://keycloak.example.com/realms/master/.well-known/openid-configuration', + OIDC_PROVIDER_NAME: 'Company SSO', + }) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.OIDC_PROVIDER_NAME).toBe('Company SSO') + } + }) + }) + + describe('no OIDC vars set (opt-out)', () => { + it('accepts config without any OIDC vars', () => { + const result = envSchema.safeParse(baseEnv) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.OIDC_CLIENT_ID).toBeUndefined() + expect(result.data.OIDC_CLIENT_SECRET).toBeUndefined() + expect(result.data.OIDC_DISCOVERY_URL).toBeUndefined() + } + }) + + it('rejects empty strings rather than treating as unset', () => { + // Empty strings go through emptyToUndefined → undefined → fails z.string() + // This is correct defensive behavior: partial/empty config is rejected. + const result = envSchema.safeParse({ + ...baseEnv, + OIDC_CLIENT_ID: '', + OIDC_CLIENT_SECRET: ' ', + OIDC_DISCOVERY_URL: '', + }) + + expect(result.success).toBe(false) + }) + }) + + describe('partial OIDC config (misconfiguration)', () => { + it('rejects when only OIDC_CLIENT_ID is set', () => { + const result = envSchema.safeParse({ + ...baseEnv, + OIDC_CLIENT_ID: 'reqcore', + }) + + expect(result.success).toBe(false) + if (!result.success) { + const messages = result.error.issues.map(i => i.message) + expect(messages.some(m => m.includes('OIDC_CLIENT_SECRET'))).toBe(true) + expect(messages.some(m => m.includes('OIDC_DISCOVERY_URL'))).toBe(true) + } + }) + + it('rejects when only OIDC_CLIENT_SECRET is set', () => { + const result = envSchema.safeParse({ + ...baseEnv, + OIDC_CLIENT_SECRET: 'secret', + }) + + expect(result.success).toBe(false) + if (!result.success) { + const messages = result.error.issues.map(i => i.message) + expect(messages.some(m => m.includes('OIDC_CLIENT_ID'))).toBe(true) + expect(messages.some(m => m.includes('OIDC_DISCOVERY_URL'))).toBe(true) + } + }) + + it('rejects when only OIDC_DISCOVERY_URL is set', () => { + const result = envSchema.safeParse({ + ...baseEnv, + OIDC_DISCOVERY_URL: 'https://keycloak.example.com/realms/master/.well-known/openid-configuration', + }) + + expect(result.success).toBe(false) + if (!result.success) { + const messages = result.error.issues.map(i => i.message) + expect(messages.some(m => m.includes('OIDC_CLIENT_ID'))).toBe(true) + expect(messages.some(m => m.includes('OIDC_CLIENT_SECRET'))).toBe(true) + } + }) + + it('rejects when OIDC_DISCOVERY_URL is missing but others are set', () => { + const result = envSchema.safeParse({ + ...baseEnv, + OIDC_CLIENT_ID: 'reqcore', + OIDC_CLIENT_SECRET: 'secret', + }) + + expect(result.success).toBe(false) + if (!result.success) { + const messages = result.error.issues.map(i => i.message) + expect(messages.some(m => m.includes('OIDC_DISCOVERY_URL'))).toBe(true) + // Should NOT complain about the vars that ARE set + expect(messages.some(m => m.startsWith('OIDC_CLIENT_ID is required'))).toBe(false) + expect(messages.some(m => m.startsWith('OIDC_CLIENT_SECRET is required'))).toBe(false) + } + }) + }) + + describe('OIDC_DISCOVERY_URL validation', () => { + it('rejects non-URL discovery URL', () => { + const result = envSchema.safeParse({ + ...baseEnv, + OIDC_CLIENT_ID: 'reqcore', + OIDC_CLIENT_SECRET: 'secret', + OIDC_DISCOVERY_URL: 'not-a-url', + }) + + expect(result.success).toBe(false) + }) + + it('accepts various OIDC provider discovery URLs', () => { + const urls = [ + 'https://keycloak.example.com/realms/master/.well-known/openid-configuration', + 'https://authentik.example.com/application/o/reqcore/.well-known/openid-configuration', + 'https://login.microsoftonline.com/tenant-id/v2.0/.well-known/openid-configuration', + 'https://accounts.google.com/.well-known/openid-configuration', + ] + + for (const url of urls) { + const result = envSchema.safeParse({ + ...baseEnv, + OIDC_CLIENT_ID: 'reqcore', + OIDC_CLIENT_SECRET: 'secret', + OIDC_DISCOVERY_URL: url, + }) + + expect(result.success, `Expected ${url} to be accepted`).toBe(true) + } + }) + }) + + describe('OIDC_PROVIDER_NAME defaults', () => { + it('defaults to "SSO" when OIDC vars are set but name is omitted', () => { + const result = envSchema.safeParse({ + ...baseEnv, + OIDC_CLIENT_ID: 'reqcore', + OIDC_CLIENT_SECRET: 'secret', + OIDC_DISCOVERY_URL: 'https://keycloak.example.com/realms/master/.well-known/openid-configuration', + }) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.OIDC_PROVIDER_NAME).toBe('SSO') + } + }) + + it('rejects empty string for provider name', () => { + // Empty string → emptyToUndefined → undefined → falls through to default. + // But the preprocess inner z.string() rejects undefined, so this fails. + const result = envSchema.safeParse({ + ...baseEnv, + OIDC_CLIENT_ID: 'reqcore', + OIDC_CLIENT_SECRET: 'secret', + OIDC_DISCOVERY_URL: 'https://keycloak.example.com/realms/master/.well-known/openid-configuration', + OIDC_PROVIDER_NAME: '', + }) + + expect(result.success).toBe(false) + }) + }) +}) From 62fdf399d79132e30889ded51b312642454de2f9 Mon Sep 17 00:00:00 2001 From: Joachim Date: Fri, 10 Apr 2026 08:41:23 +0200 Subject: [PATCH 3/6] feat: add SSO provider schema and relations for better authentication integration --- app/components/SettingsMobileNav.vue | 8 +- app/components/SettingsSidebar.vue | 9 +- app/pages/auth/sign-in.vue | 86 +- app/pages/dashboard/settings/sso.vue | 488 ++ app/utils/auth-client.ts | 2 + package-lock.json | 6188 +++++++++-------- package.json | 3 +- server/api/sso/providers.get.ts | 33 + server/api/sso/providers.post.ts | 83 + server/api/sso/providers/[id].delete.ts | 44 + .../migrations/0019_noisy_strong_guy.sql | 16 + .../migrations/meta/0019_snapshot.json | 4088 +++++++++++ server/database/migrations/meta/_journal.json | 7 + server/database/schema/index.ts | 1 + server/database/schema/sso.ts | 31 + server/utils/auth.ts | 67 +- 16 files changed, 8055 insertions(+), 3099 deletions(-) create mode 100644 app/pages/dashboard/settings/sso.vue create mode 100644 server/api/sso/providers.get.ts create mode 100644 server/api/sso/providers.post.ts create mode 100644 server/api/sso/providers/[id].delete.ts create mode 100644 server/database/migrations/0019_noisy_strong_guy.sql create mode 100644 server/database/migrations/meta/0019_snapshot.json create mode 100644 server/database/schema/sso.ts diff --git a/app/components/SettingsMobileNav.vue b/app/components/SettingsMobileNav.vue index f98e04b2..96b0227c 100644 --- a/app/components/SettingsMobileNav.vue +++ b/app/components/SettingsMobileNav.vue @@ -1,6 +1,6 @@