diff --git a/.env.example b/.env.example index c2996f0a133..8ee4751a232 100644 --- a/.env.example +++ b/.env.example @@ -1804,6 +1804,22 @@ CURSOR_USER_AGENT="Cursor/3.4" # OMNIROUTE_OIDC_DISABLE_PASSWORD_LOGIN=false # OIDC_DISABLE_PASSWORD_LOGIN=false +# Native Google / GitHub dashboard login (#15153). A provider is only offered on the login page +# when BOTH its client id + secret AND a non-empty AUTH_ALLOWED_EMAILS are set — the allowlist is +# deny-by-default (no "*" wildcard), so a bare client id never opens the dashboard to every account. +# AUTH_ALLOWED_EMAILS is a comma-separated list: exact e-mails, "@domain.com" / "*@domain.com" +# domain entries, and bare GitHub usernames (usernames never match e-mail/domain entries). +# Register the callbacks /api/auth/google/callback and /api/auth/github/callback. +# Used by: src/lib/auth/socialOAuth.ts, src/app/api/auth/{google,github}/*, src/app/api/settings/require-login/route.ts. +# AUTH_GOOGLE_CLIENT_ID= +# AUTH_GOOGLE_CLIENT_SECRET= +# AUTH_GITHUB_CLIENT_ID= +# AUTH_GITHUB_CLIENT_SECRET= +# AUTH_ALLOWED_EMAILS= +# Hide the password form on the login page when a social provider is enabled (UI only — the +# password endpoint still works, which keeps a recovery path if the identity provider is down). +# AUTH_DISABLE_PASSWORD_LOGIN=false + # ── Adobe Firefly browser sign-in (system Chrome/Edge CDP) ── # Used by: open-sse/services/adobeFireflyBrowserLogin.ts. The Firefly login # flow drives a real, system-installed Chrome or Microsoft Edge via CDP so the diff --git a/changelog.d/features/social-oauth-google-github.md b/changelog.d/features/social-oauth-google-github.md new file mode 100644 index 00000000000..eb12442d7d3 --- /dev/null +++ b/changelog.d/features/social-oauth-google-github.md @@ -0,0 +1 @@ +- **feat(auth):** Native Google OAuth 2.0 and GitHub OAuth login for the dashboard. Providers are opt-in per operator, deny-by-default (a non-empty `AUTH_ALLOWED_EMAILS` allowlist is required; `*` is ignored), only a verified e-mail authorizes a session, and the login/callback routes are public in the route guard. ([#15153](https://github.com/diegosouzapw/OmniRoute/pull/15153)) diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index fbd9a2299c0..6c5adefe369 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -249,6 +249,12 @@ OmniRoute uses **SQLite** (via `better-sqlite3`) for all persistence. These vari | `RERANK_REMOTE_PROVIDER_NODES` | `false` | `src/app/api/v1/_shared/rerankProviderNodes.ts` | Let `POST /v1/rerank` (and the memory engine's loopback rerank step) use an OpenAI-compatible provider node hosted outside localhost — a LAN box or Tailscale peer running TEI, Infinity, vLLM, etc. Off by default — routing to a remote host changes egress identity and must be an explicit operator decision. Loopback nodes (localhost, 127.0.0.1, 172.16-31.x) are always allowed and unaffected. Remote nodes must also pass the provider outbound URL policy (`OMNIROUTE_ALLOW_LOCAL_PROVIDER_URLS` / `OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS`); cloud-metadata hosts are never routed to. | | `OMNIROUTE_OIDC_DISABLE_PASSWORD_LOGIN` | `false` | `src/app/api/auth/login/route.ts` | When OIDC is enabled, disable password login so users can only authenticate via OIDC Single Sign-On. The bare alias `OIDC_DISABLE_PASSWORD_LOGIN` is also accepted; the Dashboard Feature Flag of the same key takes precedence. (#10889) | | `OIDC_DISABLE_PASSWORD_LOGIN` | `false` | `src/app/api/auth/login/route.ts` | Bare alias of `OMNIROUTE_OIDC_DISABLE_PASSWORD_LOGIN` (#10889). | +| `AUTH_GOOGLE_CLIENT_ID` | `(unset)` | ``src/lib/auth/socialOAuth.ts`` | Google OAuth client id for the dashboard "Continue with Google" login (#15153). Only the dedicated `AUTH_GOOGLE_*` variables are read; generic unprefixed Google client variables are ignored. | +| `AUTH_GOOGLE_CLIENT_SECRET` | `(unset)` | ``src/lib/auth/socialOAuth.ts`` | Google OAuth client secret. Never returned by `GET /api/settings`. | +| `AUTH_GITHUB_CLIENT_ID` | `(unset)` | ``src/lib/auth/socialOAuth.ts`` | GitHub OAuth client id for the dashboard "Continue with GitHub" login (#15153). | +| `AUTH_GITHUB_CLIENT_SECRET` | `(unset)` | ``src/lib/auth/socialOAuth.ts`` | GitHub OAuth client secret. Never returned by `GET /api/settings`. | +| `AUTH_ALLOWED_EMAILS` | `(unset)` | ``src/lib/auth/socialOAuth.ts`` | Comma-separated allowlist for social login: exact e-mails, `@domain` / `*@domain` entries, bare GitHub usernames. Deny-by-default: a social provider stays disabled until this (or the `authAllowedEmails` setting) is non-empty; `*` is ignored. | +| `AUTH_DISABLE_PASSWORD_LOGIN` | `false` | ``src/lib/auth/socialOAuth.ts`` | Hide the password form on the login page while a social provider is enabled. UI only — `/api/auth/login` keeps accepting the password. | ### Hardening Checklist diff --git a/src/app/api/auth/github/callback/route.ts b/src/app/api/auth/github/callback/route.ts new file mode 100644 index 00000000000..485a3dfc774 --- /dev/null +++ b/src/app/api/auth/github/callback/route.ts @@ -0,0 +1,145 @@ +import { NextResponse } from "next/server"; +import { getCachedSettings } from "@/lib/db/readCache"; +import { cookies } from "next/headers"; +import { getAuditRequestContext, logAuditEvent } from "@/lib/compliance/index"; +import { + getGitHubOAuthConfig, + getRequestOrigin, + isEmailAllowed, + isGithubLoginAllowed, + isRequestSecure, +} from "@/lib/auth/socialOAuth"; +import { + asRecord, + consumeOAuthState, + getJsonWithBearer, + postForJson, + startDashboardSession, + type SocialCookieStore, + type StepResult, +} from "@/lib/auth/socialLogin"; + +export const githubCallbackInternals = { + getCookieStore: cookies as unknown as () => Promise, +}; + +const GITHUB_API_HEADERS = { "User-Agent": "OmniRoute-OAuth" }; + +async function exchangeCodeForAccessToken( + config: ReturnType, + code: string, + redirectUri: string +): Promise> { + const token = await postForJson( + "https://github.com/login/oauth/access_token", + { + headers: { Accept: "application/json", "Content-Type": "application/json" }, + body: JSON.stringify({ + client_id: config.clientId, + client_secret: config.clientSecret, + code, + redirect_uri: redirectUri, + }), + }, + { request: "token_exchange", response: "token_response" } + ); + if (!token.ok) return token; + const accessToken = token.value.access_token; + return typeof accessToken === "string" && accessToken + ? { ok: true, value: accessToken } + : { ok: false, error: "token_response" }; +} + +/** + * Resolves the account's verified e-mail from `/user/emails` (primary first, then any verified). + * The public profile e-mail carries no `verified` flag and is deliberately NOT a fallback: when + * this call fails or yields nothing verified, the login fails closed. + */ +async function fetchVerifiedEmail(accessToken: string): Promise { + const raw = await getJsonWithBearer( + "https://api.github.com/user/emails", + accessToken, + GITHUB_API_HEADERS + ); + const entries = Array.isArray(raw) ? raw.map(asRecord) : []; + const match = + entries.find((entry) => entry.primary === true && entry.verified === true) ?? + entries.find((entry) => entry.verified === true); + return typeof match?.email === "string" ? match.email.trim().toLowerCase() : ""; +} + +async function fetchLogin(accessToken: string): Promise { + const profile = asRecord( + await getJsonWithBearer("https://api.github.com/user", accessToken, GITHUB_API_HEADERS) + ); + return typeof profile.login === "string" ? profile.login.toLowerCase() : ""; +} + +/** + * GET /api/auth/github/callback + * Handles the GitHub OAuth authorization code exchange and sets the dashboard session cookie. + */ +export async function GET(request: Request) { + const url = new URL(request.url); + const code = url.searchParams.get("code"); + const returnedState = url.searchParams.get("state"); + const origin = getRequestOrigin(request); + const fail = (error: string) => NextResponse.redirect(new URL(`/login?error=${error}`, origin)); + + if (!code || !returnedState) return fail("missing_code"); + + const cookieStore = await githubCallbackInternals.getCookieStore(); + if (!consumeOAuthState(cookieStore, "github_oauth_state", returnedState)) { + return fail("invalid_state"); + } + + const settings = await getCachedSettings(); + const config = getGitHubOAuthConfig(settings); + if (!config.enabled) return fail("not_configured"); + + const accessToken = await exchangeCodeForAccessToken( + config, + code, + `${origin}${config.redirectPath}` + ); + if (!accessToken.ok) return fail(accessToken.error); + + const email = await fetchVerifiedEmail(accessToken.value); + if (!email) return fail("email_not_verified"); + const githubUsername = await fetchLogin(accessToken.value); + + const auditContext = getAuditRequestContext(request); + const allowed = + isEmailAllowed(email, settings.authAllowedEmails) || + isGithubLoginAllowed(githubUsername, settings.authAllowedEmails); + if (!allowed) { + logAuditEvent({ + action: "auth.login.github.unauthorized", + actor: email, + target: "dashboard-auth", + resourceType: "auth_session", + status: "failed", + ipAddress: auditContext.ipAddress || undefined, + requestId: auditContext.requestId, + metadata: { email, githubUsername, reason: "not_in_allowlist" }, + }); + return fail("unauthorized_email"); + } + + if (!(await startDashboardSession(cookieStore, isRequestSecure(request)))) { + return fail("server_misconfigured"); + } + + logAuditEvent({ + action: "auth.login.github.success", + actor: email, + target: "dashboard-auth", + resourceType: "auth_session", + status: "success", + ipAddress: auditContext.ipAddress || undefined, + requestId: auditContext.requestId, + metadata: { email, githubUsername }, + }); + + return NextResponse.redirect(`${origin}/dashboard`); +} diff --git a/src/app/api/auth/github/login/route.ts b/src/app/api/auth/github/login/route.ts new file mode 100644 index 00000000000..e32465bd8b8 --- /dev/null +++ b/src/app/api/auth/github/login/route.ts @@ -0,0 +1,54 @@ +import { NextResponse } from "next/server"; +import { getCachedSettings } from "@/lib/db/readCache"; +import { cookies } from "next/headers"; +import { errorResponse } from "@omniroute/open-sse/utils/error"; +import { + generateOAuthState, + getGitHubOAuthConfig, + getRequestOrigin, + isRequestSecure, +} from "@/lib/auth/socialOAuth"; + +export const githubLoginInternals = { + getCookieStore: cookies, +}; + +/** + * GET /api/auth/github/login + * Starts GitHub OAuth login flow for the OmniRoute dashboard. + */ +export async function GET(request: Request) { + const settings = await getCachedSettings(); + const config = getGitHubOAuthConfig(settings); + + if (!config.enabled || !config.clientId || !config.clientSecret) { + return errorResponse( + 400, + "GitHub OAuth is not configured. Set AUTH_GITHUB_CLIENT_ID, AUTH_GITHUB_CLIENT_SECRET and a non-empty AUTH_ALLOWED_EMAILS (or the matching settings)." + ); + } + + const origin = getRequestOrigin(request); + const redirectUri = `${origin}${config.redirectPath}`; + + const state = generateOAuthState(); + + const authUrl = new URL("https://github.com/login/oauth/authorize"); + authUrl.searchParams.set("client_id", config.clientId); + authUrl.searchParams.set("redirect_uri", redirectUri); + authUrl.searchParams.set("scope", "read:user user:email"); + authUrl.searchParams.set("state", state); + + const useSecureCookie = isRequestSecure(request); + + const res = NextResponse.redirect(authUrl.toString()); + res.cookies.set("github_oauth_state", state, { + httpOnly: true, + sameSite: "lax", + path: "/", + maxAge: 60 * 10, + secure: useSecureCookie, + }); + + return res; +} diff --git a/src/app/api/auth/google/callback/route.ts b/src/app/api/auth/google/callback/route.ts new file mode 100644 index 00000000000..386af578ca0 --- /dev/null +++ b/src/app/api/auth/google/callback/route.ts @@ -0,0 +1,125 @@ +import { NextResponse } from "next/server"; +import { getCachedSettings } from "@/lib/db/readCache"; +import { cookies } from "next/headers"; +import { getAuditRequestContext, logAuditEvent } from "@/lib/compliance/index"; +import { + getGoogleOAuthConfig, + getRequestOrigin, + isEmailAllowed, + isRequestSecure, +} from "@/lib/auth/socialOAuth"; +import { + asRecord, + consumeOAuthState, + getJsonWithBearer, + postForJson, + startDashboardSession, + type SocialCookieStore, + type StepResult, +} from "@/lib/auth/socialLogin"; + +export const googleCallbackInternals = { + getCookieStore: cookies as unknown as () => Promise, +}; + +async function exchangeCodeForAccessToken( + config: ReturnType, + code: string, + redirectUri: string +): Promise> { + const token = await postForJson( + "https://oauth2.googleapis.com/token", + { + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "authorization_code", + code, + redirect_uri: redirectUri, + client_id: config.clientId, + client_secret: config.clientSecret, + }).toString(), + }, + { request: "token_exchange", response: "token_response" } + ); + if (!token.ok) return token; + const accessToken = token.value.access_token; + return typeof accessToken === "string" && accessToken + ? { ok: true, value: accessToken } + : { ok: false, error: "token_response" }; +} + +/** Returns the lower-cased e-mail only when Google reports it as verified. */ +async function fetchVerifiedEmail(accessToken: string): Promise> { + const raw = await getJsonWithBearer("https://www.googleapis.com/oauth2/v3/userinfo", accessToken); + if (raw === null) return { ok: false, error: "user_info_failed" }; + const info = asRecord(raw); + const email = typeof info.email === "string" ? info.email.trim().toLowerCase() : ""; + if (!email || info.email_verified !== true) return { ok: false, error: "email_not_verified" }; + return { ok: true, value: email }; +} + +/** + * GET /api/auth/google/callback + * Handles the Google OAuth 2.0 authorization code exchange and sets the dashboard session cookie. + */ +export async function GET(request: Request) { + const url = new URL(request.url); + const code = url.searchParams.get("code"); + const returnedState = url.searchParams.get("state"); + const origin = getRequestOrigin(request); + const fail = (error: string) => NextResponse.redirect(new URL(`/login?error=${error}`, origin)); + + if (!code || !returnedState) return fail("missing_code"); + + const cookieStore = await googleCallbackInternals.getCookieStore(); + if (!consumeOAuthState(cookieStore, "google_oauth_state", returnedState)) { + return fail("invalid_state"); + } + + const settings = await getCachedSettings(); + const config = getGoogleOAuthConfig(settings); + if (!config.enabled) return fail("not_configured"); + + const accessToken = await exchangeCodeForAccessToken( + config, + code, + `${origin}${config.redirectPath}` + ); + if (!accessToken.ok) return fail(accessToken.error); + + const verified = await fetchVerifiedEmail(accessToken.value); + if (!verified.ok) return fail(verified.error); + const email = verified.value; + + const auditContext = getAuditRequestContext(request); + if (!isEmailAllowed(email, settings.authAllowedEmails)) { + logAuditEvent({ + action: "auth.login.google.unauthorized", + actor: email, + target: "dashboard-auth", + resourceType: "auth_session", + status: "failed", + ipAddress: auditContext.ipAddress || undefined, + requestId: auditContext.requestId, + metadata: { email, reason: "email_not_in_allowlist" }, + }); + return fail("unauthorized_email"); + } + + if (!(await startDashboardSession(cookieStore, isRequestSecure(request)))) { + return fail("server_misconfigured"); + } + + logAuditEvent({ + action: "auth.login.google.success", + actor: email, + target: "dashboard-auth", + resourceType: "auth_session", + status: "success", + ipAddress: auditContext.ipAddress || undefined, + requestId: auditContext.requestId, + metadata: { email }, + }); + + return NextResponse.redirect(`${origin}/dashboard`); +} diff --git a/src/app/api/auth/google/login/route.ts b/src/app/api/auth/google/login/route.ts new file mode 100644 index 00000000000..6d24a7e0802 --- /dev/null +++ b/src/app/api/auth/google/login/route.ts @@ -0,0 +1,56 @@ +import { NextResponse } from "next/server"; +import { getCachedSettings } from "@/lib/db/readCache"; +import { cookies } from "next/headers"; +import { errorResponse } from "@omniroute/open-sse/utils/error"; +import { + generateOAuthState, + getGoogleOAuthConfig, + getRequestOrigin, + isRequestSecure, +} from "@/lib/auth/socialOAuth"; + +export const googleLoginInternals = { + getCookieStore: cookies, +}; + +/** + * GET /api/auth/google/login + * Starts Google OAuth 2.0 login flow for the OmniRoute dashboard. + */ +export async function GET(request: Request) { + const settings = await getCachedSettings(); + const config = getGoogleOAuthConfig(settings); + + if (!config.enabled || !config.clientId || !config.clientSecret) { + return errorResponse( + 400, + "Google OAuth is not configured. Set AUTH_GOOGLE_CLIENT_ID, AUTH_GOOGLE_CLIENT_SECRET and a non-empty AUTH_ALLOWED_EMAILS (or the matching settings)." + ); + } + + const origin = getRequestOrigin(request); + const redirectUri = `${origin}${config.redirectPath}`; + + const state = generateOAuthState(); + + const authUrl = new URL("https://accounts.google.com/o/oauth2/v2/auth"); + authUrl.searchParams.set("response_type", "code"); + authUrl.searchParams.set("client_id", config.clientId); + authUrl.searchParams.set("redirect_uri", redirectUri); + authUrl.searchParams.set("scope", "openid email profile"); + authUrl.searchParams.set("state", state); + authUrl.searchParams.set("prompt", "select_account"); + + const useSecureCookie = isRequestSecure(request); + + const res = NextResponse.redirect(authUrl.toString()); + res.cookies.set("google_oauth_state", state, { + httpOnly: true, + sameSite: "lax", + path: "/", + maxAge: 60 * 10, + secure: useSecureCookie, + }); + + return res; +} diff --git a/src/app/api/settings/require-login/route.ts b/src/app/api/settings/require-login/route.ts index eaac69ac7a4..9b40af66591 100644 --- a/src/app/api/settings/require-login/route.ts +++ b/src/app/api/settings/require-login/route.ts @@ -7,6 +7,7 @@ import { hashManagementPassword, } from "@/lib/auth/managementPassword"; import { consumeBootstrapToken } from "@/lib/auth/bootstrapToken"; +import { getGoogleOAuthConfig, getGitHubOAuthConfig } from "@/lib/auth/socialOAuth"; import { isAuthenticated } from "@/shared/utils/apiAuth"; import { BOOTSTRAP_TOKEN_HEADER } from "@/server/authz/headers"; import { @@ -57,6 +58,16 @@ export async function GET() { isFeatureFlagEnabled("OMNIROUTE_OIDC_DISABLE_PASSWORD_LOGIN") || process.env.OMNIROUTE_OIDC_DISABLE_PASSWORD_LOGIN === "true" || process.env.OIDC_DISABLE_PASSWORD_LOGIN === "true"); + + const googleConfig = getGoogleOAuthConfig(settings); + const githubConfig = getGitHubOAuthConfig(settings); + const googleAuthEnabled = googleConfig.enabled; + const githubAuthEnabled = githubConfig.enabled; + const socialAuthDisablePasswordLogin = + (googleAuthEnabled || githubAuthEnabled) && + (settings.disablePasswordLogin === true || + process.env.AUTH_DISABLE_PASSWORD_LOGIN === "true"); + return NextResponse.json({ authenticated, requireLogin, @@ -64,6 +75,9 @@ export async function GET() { setupComplete, oidcEnabled, oidcDisablePasswordLogin, + googleAuthEnabled, + githubAuthEnabled, + disablePasswordLogin: oidcDisablePasswordLogin || socialAuthDisablePasswordLogin, ...nodeInfo, }); } catch (error) { @@ -76,6 +90,9 @@ export async function GET() { setupComplete: true, oidcEnabled: false, oidcDisablePasswordLogin: false, + googleAuthEnabled: false, + githubAuthEnabled: false, + disablePasswordLogin: false, ...nodeInfo, }, { status: 200 } diff --git a/src/app/api/settings/route.ts b/src/app/api/settings/route.ts index 1c0e36e598c..f8fb3ef1149 100644 --- a/src/app/api/settings/route.ts +++ b/src/app/api/settings/route.ts @@ -132,6 +132,12 @@ const SECURITY_IMPACTING_KEYS = [ "oidcEnabled", "oidcDisablePasswordLogin", "oidcClientSecret", + "googleAuthEnabled", + "googleClientSecret", + "githubAuthEnabled", + "githubClientSecret", + "authAllowedEmails", + "disablePasswordLogin", ] as const; /** @@ -238,6 +244,8 @@ export async function GET(request: Request) { password, [SESSIONS_VALID_AFTER_SETTING]: _sessionsValidAfter, [REVOKED_SESSIONS_SETTING]: _revokedSessions, + googleClientSecret, + githubClientSecret, ...safeSettings } = settings; @@ -259,6 +267,9 @@ export async function GET(request: Request) { return NextResponse.json( { ...safeSettings, + // Social-login secrets never leave the server; the UI only learns whether one is set. + googleClientSecretConfigured: Boolean(googleClientSecret), + githubClientSecretConfigured: Boolean(githubClientSecret), settingsRevision, hasPassword: hasManagementPasswordConfigured(settings), runtimePorts, @@ -570,11 +581,18 @@ export async function PATCH(request: Request) { password, [SESSIONS_VALID_AFTER_SETTING]: _sessionsValidAfter, [REVOKED_SESSIONS_SETTING]: _revokedSessions, + googleClientSecret, + githubClientSecret, ...safeSettings } = settings; const settingsRevision = await getSettingsRevision(); const response = NextResponse.json( - { ...safeSettings, settingsRevision }, + { + ...safeSettings, + googleClientSecretConfigured: Boolean(googleClientSecret), + githubClientSecretConfigured: Boolean(githubClientSecret), + settingsRevision, + }, { headers: settingsResponseHeaders(settingsRevision) } ); // The browser that changed the password keeps its own session: swap its cookie for one issued diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index 664eb3c1721..a3f2ca0e9ec 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -15,11 +15,36 @@ export default function LoginPage() { const [setupComplete, setSetupComplete] = useState(null); const [oidcEnabled, setOidcEnabled] = useState(null); const [oidcDisablePasswordLogin, setOidcDisablePasswordLogin] = useState(null); + const [googleAuthEnabled, setGoogleAuthEnabled] = useState(null); + const [githubAuthEnabled, setGithubAuthEnabled] = useState(null); + const [disablePasswordLogin, setDisablePasswordLogin] = useState(null); const [mounted, setMounted] = useState(false); const [nodeVersion, setNodeVersion] = useState(null); const [nodeCompatible, setNodeCompatible] = useState(true); const router = useRouter(); + useEffect(() => { + const params = new URLSearchParams(window.location.search); + const err = params.get("error") || params.get("oidc_error"); + if (!err) return; + // Deferred like `setMounted` below: reading the query string needs the browser, and a + // synchronous setState inside the effect trips react-hooks/set-state-in-effect. + const raf = requestAnimationFrame(() => { + if (err === "unauthorized_email" || err === "subject_not_allowed") { + setError(t("oauthEmailNotAllowed")); + } else if (err === "email_not_verified") { + setError(t("oauthEmailNotVerified")); + } else if (err === "invalid_state") { + setError(t("oauthStateInvalid")); + } else if (err === "not_configured") { + setError(t("oauthNotConfigured")); + } else { + setError(t("oauthLoginFailed")); + } + }); + return () => cancelAnimationFrame(raf); + }, [t]); + useEffect(() => { const raf = requestAnimationFrame(() => setMounted(true)); async function checkAuth() { @@ -45,11 +70,17 @@ export default function LoginPage() { setSetupComplete(!!data.setupComplete); setOidcEnabled(!!data.oidcEnabled); setOidcDisablePasswordLogin(!!data.oidcDisablePasswordLogin); + setGoogleAuthEnabled(!!data.googleAuthEnabled); + setGithubAuthEnabled(!!data.githubAuthEnabled); + setDisablePasswordLogin(!!data.disablePasswordLogin); } else { setHasPassword(true); setSetupComplete(true); setOidcEnabled(false); setOidcDisablePasswordLogin(false); + setGoogleAuthEnabled(false); + setGithubAuthEnabled(false); + setDisablePasswordLogin(false); } } catch (err) { clearTimeout(timeoutId); @@ -57,6 +88,9 @@ export default function LoginPage() { setSetupComplete(true); setOidcEnabled(false); setOidcDisablePasswordLogin(false); + setGoogleAuthEnabled(false); + setGithubAuthEnabled(false); + setDisablePasswordLogin(false); } } checkAuth(); @@ -243,26 +277,107 @@ export default function LoginPage() {

{t("signIn")}

- {oidcEnabled && oidcDisablePasswordLogin - ? t("continueWithOidc") + {disablePasswordLogin || (oidcEnabled && oidcDisablePasswordLogin) + ? t("signIn") : t("enterPassword")}

- {oidcEnabled && oidcDisablePasswordLogin ? ( -
- + {(googleAuthEnabled || githubAuthEnabled || oidcEnabled) && ( +
+ {googleAuthEnabled && ( + + )} + + {githubAuthEnabled && ( + + )} + + {oidcEnabled && ( + + )}
- ) : ( + )} + + {error && (disablePasswordLogin || (oidcEnabled && oidcDisablePasswordLogin)) && ( +

+ error + {error} +

+ )} + + {!(disablePasswordLogin || (oidcEnabled && oidcDisablePasswordLogin)) && ( <> + {(googleAuthEnabled || githubAuthEnabled || oidcEnabled) && ( +
+
+
+
+
+ {t("or")} +
+
+ )} +
@@ -293,24 +408,10 @@ export default function LoginPage() { {t("continue")} - - {oidcEnabled && ( -
- -
- )} )} - {!oidcEnabled && ( + {!(disablePasswordLogin || (oidcEnabled && oidcDisablePasswordLogin)) && (
): unknown; +} + +/** Outcome of a provider HTTP step: the parsed value, or a `/login?error=` code. */ +export type StepResult = { ok: true; value: T } | { ok: false; error: string }; + +export function asRecord(value: unknown): Record { + return value && typeof value === "object" ? (value as Record) : {}; +} + +/** + * Checks the returned OAuth `state` against the per-browser cookie set by the login route and + * clears the cookie once it matches (single use). + */ +export function consumeOAuthState( + cookieStore: SocialCookieStore, + cookieName: string, + returnedState: string +): boolean { + const storedState = cookieStore.get(cookieName)?.value; + if (!storedState || !timingSafeCompare(storedState, returnedState)) return false; + cookieStore.set(cookieName, "", { httpOnly: true, sameSite: "lax", path: "/", maxAge: 0 }); + return true; +} + +/** + * POSTs to a provider endpoint and parses a JSON body. Every failure mode collapses into a short + * error code — provider response text is never reflected to the browser. + */ +export async function postForJson( + url: string, + init: { headers: Record; body: string }, + errors: { request: string; response: string } +): Promise>> { + let resp: Response; + try { + resp = await fetch(url, { + method: "POST", + headers: init.headers, + body: init.body, + signal: AbortSignal.timeout(10000), + }); + } catch { + return { ok: false, error: errors.request }; + } + if (!resp.ok) return { ok: false, error: errors.request }; + try { + return { ok: true, value: asRecord(await resp.json()) }; + } catch { + return { ok: false, error: errors.response }; + } +} + +/** GETs a provider API endpoint with a bearer token; returns the parsed JSON or `null`. */ +export async function getJsonWithBearer( + url: string, + accessToken: string, + extraHeaders: Record = {} +): Promise { + try { + const resp = await fetch(url, { + headers: { Authorization: `Bearer ${accessToken}`, ...extraHeaders }, + signal: AbortSignal.timeout(5000), + }); + return resp.ok ? await resp.json() : null; + } catch { + return null; + } +} + +/** + * Completes a successful social login: marks setup complete, mints the standard 30-day dashboard + * session JWT and sets the `auth_token` cookie. Returns false when no JWT secret is available. + */ +export async function startDashboardSession( + cookieStore: SocialCookieStore, + secure: boolean +): Promise { + try { + await updateSettings({ setupComplete: true }); + } catch { + // non-fatal + } + + const secret = getDashboardJwtSecret(); + if (!secret) return false; + + cookieStore.set("auth_token", await createDashboardSessionJwt(secret), { + httpOnly: true, + secure, + sameSite: "lax", + path: "/", + maxAge: 60 * 60 * 24 * 30, + }); + return true; +} diff --git a/src/lib/auth/socialOAuth.ts b/src/lib/auth/socialOAuth.ts new file mode 100644 index 00000000000..b60145c76df --- /dev/null +++ b/src/lib/auth/socialOAuth.ts @@ -0,0 +1,192 @@ +import { SignJWT } from "jose"; +import { DASHBOARD_SESSION_CLAIM } from "@/shared/utils/dashboardSessionToken"; +import { timingSafeCompare } from "@/shared/utils/timingSafeCompare"; + +export interface GoogleOAuthConfig { + enabled: boolean; + clientId: string; + clientSecret: string; + redirectPath: string; +} + +export interface GitHubOAuthConfig { + enabled: boolean; + clientId: string; + clientSecret: string; + redirectPath: string; +} + +/** + * Derives the absolute origin for the incoming request. Mirrors the OIDC routes: the scheme may + * come from `X-Forwarded-Proto`, but the host is only ever the `Host` header — trusting + * `X-Forwarded-Host` would let a caller steer `redirect_uri` and the post-login redirect. + */ +export function getRequestOrigin(request: Request): string { + const forwardedProto = (request.headers.get("x-forwarded-proto") || "") + .split(",")[0] + .trim() + .toLowerCase(); + const reqUrl = new URL(request.url); + const scheme = forwardedProto === "https" || reqUrl.protocol === "https:" ? "https" : "http"; + const host = request.headers.get("host") || request.headers.get("Host") || reqUrl.host; + return `${scheme}://${host}`; +} + +/** + * Checks whether the request requires secure cookies. + */ +export function isRequestSecure(request: Request): boolean { + if (process.env.AUTH_COOKIE_SECURE === "true") return true; + const forwardedProto = (request.headers.get("x-forwarded-proto") || "") + .split(",")[0] + .trim() + .toLowerCase(); + const reqUrl = new URL(request.url); + return forwardedProto === "https" || reqUrl.protocol === "https:"; +} + +/** + * Accepts only a same-origin absolute path ("/x", never "//x" or "x@evil") for the OAuth + * `redirect_uri` suffix, falling back to the default — the value comes from settings and is + * concatenated onto the request origin. + */ +function resolveRedirectPath(value: unknown, fallback: string): string { + if (typeof value !== "string") return fallback; + const trimmed = value.trim(); + return /^\/(?!\/)[A-Za-z0-9/_.~-]*$/.test(trimmed) ? trimmed : fallback; +} + +/** + * Resolves the effective allowlist entries: the settings value (array or comma-separated string), + * falling back to `AUTH_ALLOWED_EMAILS`. Blank entries and a bare `*` are dropped — there is no + * "allow everyone" spelling, because that would hand an admin session to any Google/GitHub account. + */ +export function resolveAuthAllowlist(allowedConfig?: unknown): string[] { + let candidates: string[] = []; + if (Array.isArray(allowedConfig)) { + candidates = allowedConfig.filter((item): item is string => typeof item === "string"); + } else if (typeof allowedConfig === "string" && allowedConfig.trim().length > 0) { + candidates = allowedConfig.split(","); + } + candidates = candidates.map((item) => item.trim()).filter((item) => item.length > 0); + + if (candidates.length === 0 && process.env.AUTH_ALLOWED_EMAILS) { + candidates = process.env.AUTH_ALLOWED_EMAILS.split(",") + .map((item) => item.trim()) + .filter((item) => item.length > 0); + } + + return candidates.filter((item) => item !== "*"); +} + +/** + * Resolves Google OAuth 2.0 configuration from settings with environment variable fallbacks. + * Only the dedicated `AUTH_GOOGLE_*` variables are read (the generic `GOOGLE_CLIENT_ID` is commonly + * present for unrelated reasons). The provider stays disabled until an allowlist is configured. + */ +export function getGoogleOAuthConfig(settings: Record): GoogleOAuthConfig { + const clientId = + (typeof settings.googleClientId === "string" && settings.googleClientId.trim()) || + process.env.AUTH_GOOGLE_CLIENT_ID?.trim() || + ""; + const clientSecret = + (typeof settings.googleClientSecret === "string" && settings.googleClientSecret.trim()) || + process.env.AUTH_GOOGLE_CLIENT_SECRET?.trim() || + ""; + const redirectPath = resolveRedirectPath( + settings.googleRedirectPath, + "/api/auth/google/callback" + ); + const enabled = + Boolean(clientId && clientSecret) && + resolveAuthAllowlist(settings.authAllowedEmails).length > 0; + + return { enabled, clientId, clientSecret, redirectPath }; +} + +/** + * Resolves GitHub OAuth configuration from settings with environment variable fallbacks. + * Only the dedicated `AUTH_GITHUB_*` variables are read; disabled until an allowlist is configured. + */ +export function getGitHubOAuthConfig(settings: Record): GitHubOAuthConfig { + const clientId = + (typeof settings.githubClientId === "string" && settings.githubClientId.trim()) || + process.env.AUTH_GITHUB_CLIENT_ID?.trim() || + ""; + const clientSecret = + (typeof settings.githubClientSecret === "string" && settings.githubClientSecret.trim()) || + process.env.AUTH_GITHUB_CLIENT_SECRET?.trim() || + ""; + const redirectPath = resolveRedirectPath( + settings.githubRedirectPath, + "/api/auth/github/callback" + ); + const enabled = + Boolean(clientId && clientSecret) && + resolveAuthAllowlist(settings.authAllowedEmails).length > 0; + + return { enabled, clientId, clientSecret, redirectPath }; +} + +/** + * Validates whether an email address is authorized against the allowlist. Deny-by-default: an + * empty allowlist (or a bare "*") admits nobody. Supports exact email matches and wildcard domain + * matches (e.g. "*@example.com" or "@example.com"). + */ +export function isEmailAllowed(email: string | null | undefined, allowedConfig?: unknown): boolean { + if (!email || typeof email !== "string") return false; + const normalizedEmail = email.trim().toLowerCase(); + + for (const allowed of resolveAuthAllowlist(allowedConfig)) { + const normAllowed = allowed.toLowerCase(); + if (normAllowed === normalizedEmail) { + return true; + } + // Handle wildcard domain: *@domain.com or @domain.com + if (normAllowed.startsWith("*@") && normalizedEmail.endsWith(normAllowed.slice(1))) { + return true; + } + if (normAllowed.startsWith("@") && normalizedEmail.endsWith(normAllowed)) { + return true; + } + } + + return false; +} + +/** + * Validates a GitHub login against the allowlist. Only bare username entries (no "@", no "*") + * match, so a username can never satisfy an e-mail or domain entry (and vice versa). + */ +export function isGithubLoginAllowed( + login: string | null | undefined, + allowedConfig?: unknown +): boolean { + if (!login || typeof login !== "string") return false; + const normalizedLogin = login.trim().toLowerCase(); + if (!normalizedLogin) return false; + + return resolveAuthAllowlist(allowedConfig).some( + (allowed) => + !allowed.includes("@") && !allowed.includes("*") && allowed.toLowerCase() === normalizedLogin + ); +} + +/** + * Generates the opaque OAuth `state` value bound to the browser through a short-lived cookie. + */ +export function generateOAuthState(): string { + return crypto.randomUUID(); +} + +/** + * Creates the standard 30-day dashboard session JWT. + */ +export async function createDashboardSessionJwt(secret: Uint8Array): Promise { + return new SignJWT({ [DASHBOARD_SESSION_CLAIM]: true }) + .setProtectedHeader({ alg: "HS256" }) + .setExpirationTime("30d") + .sign(secret); +} + +export { timingSafeCompare }; diff --git a/src/lib/db/settings.ts b/src/lib/db/settings.ts index 448c38e6d7a..b29d791a6e5 100644 --- a/src/lib/db/settings.ts +++ b/src/lib/db/settings.ts @@ -130,6 +130,20 @@ export async function getSettingsRevision(): Promise { return readSettingsRevision(getDbInstance()); } +// Settings stored encrypted at rest (AES-256-GCM): decrypted on read, encrypted on write. +const SECRET_SETTING_KEYS = new Set([ + "oidcClientSecret", + "googleClientSecret", + "githubClientSecret", +]); + +function decryptSecretSettings(settings: Record): void { + for (const key of SECRET_SETTING_KEYS) { + const value = settings[key]; + if (typeof value === "string") settings[key] = decrypt(value) ?? ""; + } +} + /** * #7274: read-fallback for the codexSessionAffinityTtlMs -> sessionAffinityTtlMs * rename. Migration 124 already backfills the new key from any pre-existing @@ -182,6 +196,16 @@ export async function getSettings() { oidcScopes: ["openid", "profile", "email"], oidcRedirectPath: "/api/auth/oidc/callback", oidcAllowedSubjects: [], // optional sub or email whitelist + googleAuthEnabled: false, + googleClientId: "", + googleClientSecret: "", + googleRedirectPath: "/api/auth/google/callback", + githubAuthEnabled: false, + githubClientId: "", + githubClientSecret: "", + githubRedirectPath: "/api/auth/github/callback", + authAllowedEmails: [], // optional list of allowed email addresses + disablePasswordLogin: false, mcpEnabled: false, a2aEnabled: false, hiddenSidebarItems: [], @@ -292,9 +316,7 @@ export async function getSettings() { } } - if (typeof settings.oidcClientSecret === "string") { - settings.oidcClientSecret = decrypt(settings.oidcClientSecret) ?? ""; - } + decryptSecretSettings(settings); applySessionAffinityLegacyFallback(settings); // Auto-complete onboarding for pre-configured deployments (Docker/VM) @@ -338,7 +360,8 @@ export async function updateSettings( throw new SettingsRevisionConflictError(currentRevision); } for (const [key, value] of Object.entries(updates)) { - const toStore = key === "oidcClientSecret" ? encrypt(value as string) : value; + const toStore = + SECRET_SETTING_KEYS.has(key) && typeof value === "string" ? encrypt(value) : value; insert.run(key, JSON.stringify(toStore)); } insert.run(SETTINGS_REVISION_KEY, JSON.stringify(currentRevision + 1)); diff --git a/src/shared/constants/publicApiRoutes.ts b/src/shared/constants/publicApiRoutes.ts index c998be941a2..f5d3bc22f97 100644 --- a/src/shared/constants/publicApiRoutes.ts +++ b/src/shared/constants/publicApiRoutes.ts @@ -17,6 +17,11 @@ // Genuine subtrees. Every entry MUST end in "/". const PUBLIC_API_ROUTE_PREFIXES = [ "/api/auth/oidc/", + // Native Google/GitHub dashboard login (login redirect + OAuth callback). Hit by a visitor with + // no session yet; the handlers enforce their own state-cookie check and deny-by-default + // allowlist (src/lib/auth/socialOAuth.ts). Do not widen to other /api/auth/* routes. + "/api/auth/google/", + "/api/auth/github/", "/api/v1/", "/api/oauth/", // Public, ticket-gated Codex device-flow completion (validate + persist). diff --git a/src/shared/validation/settingsSchemas.ts b/src/shared/validation/settingsSchemas.ts index 3c2133f1077..7c2dd40d939 100644 --- a/src/shared/validation/settingsSchemas.ts +++ b/src/shared/validation/settingsSchemas.ts @@ -99,6 +99,12 @@ const transformObfuscateWordsSchema = z.object({ .optional(), }); +/** Same-origin absolute path only: it is appended to the request origin to form `redirect_uri`. */ +const socialRedirectPathSchema = z + .string() + .max(500) + .regex(/^\/(?!\/)[A-Za-z0-9/_.~-]*$/, "must be an absolute same-origin path"); + export const updateSettingsSchema = z.object({ /** #7784: opt-in optimistic concurrency — must match GET settingsRevision / ETag. */ expectedRevision: z.number().int().nonnegative().optional(), @@ -116,6 +122,16 @@ export const updateSettingsSchema = z.object({ oidcScopes: z.array(z.string().max(100)).optional(), oidcRedirectPath: z.string().max(500).optional(), oidcAllowedSubjects: z.array(z.string().max(200)).optional(), + googleAuthEnabled: z.boolean().optional(), + googleClientId: z.string().max(200).optional(), + googleClientSecret: z.string().max(500).optional(), + googleRedirectPath: socialRedirectPathSchema.optional(), + githubAuthEnabled: z.boolean().optional(), + githubClientId: z.string().max(200).optional(), + githubClientSecret: z.string().max(500).optional(), + githubRedirectPath: socialRedirectPathSchema.optional(), + authAllowedEmails: z.array(z.string().max(200)).max(500).optional(), + disablePasswordLogin: z.boolean().optional(), enableSocks5Proxy: z.boolean().optional(), instanceName: z.string().max(100).optional(), customLogoUrl: z.string().max(2000).optional(), diff --git a/tests/unit/authz/social-oauth-public-routes.test.ts b/tests/unit/authz/social-oauth-public-routes.test.ts new file mode 100644 index 00000000000..906475c6e20 --- /dev/null +++ b/tests/unit/authz/social-oauth-public-routes.test.ts @@ -0,0 +1,44 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + PUBLIC_API_ROUTE_PREFIXES, + isPublicApiRoute, +} from "../../../src/shared/constants/publicApiRoutes.ts"; +import { classifyRoute } from "../../../src/server/authz/classify.ts"; + +// #15153 — the Google/GitHub login routes are hit by a visitor that has no session yet. If the +// route guard classifies them MANAGEMENT, requireLogin answers 401 before the handler runs and +// the "Continue with Google/GitHub" button can never work. The handlers' own unit tests call +// GET() directly, so only a classifyRoute assertion catches this. + +const SOCIAL_ROUTES = [ + "/api/auth/google/login", + "/api/auth/google/callback", + "/api/auth/github/login", + "/api/auth/github/callback", +]; + +test("social login routes classify PUBLIC so an unauthenticated visitor can reach them", () => { + for (const path of SOCIAL_ROUTES) { + const classification = classifyRoute(path, "GET"); + assert.equal(classification.routeClass, "PUBLIC", `${path} must be PUBLIC`); + } +}); + +test("social login prefixes are genuine subtrees (end in a slash)", () => { + for (const prefix of ["/api/auth/google/", "/api/auth/github/"]) { + assert.ok(PUBLIC_API_ROUTE_PREFIXES.includes(prefix), `${prefix} missing from public prefixes`); + } +}); + +test("siblings that merely share the leading characters stay MANAGEMENT", () => { + for (const path of [ + "/api/auth/google", + "/api/auth/googleplus/login", + "/api/auth/github-app/login", + "/api/auth/githubber", + ]) { + assert.equal(isPublicApiRoute(path, "GET"), false, path); + } +}); diff --git a/tests/unit/login-bootstrap-route.test.ts b/tests/unit/login-bootstrap-route.test.ts index 8df1f6ccaef..2f3b0a79f6c 100644 --- a/tests/unit/login-bootstrap-route.test.ts +++ b/tests/unit/login-bootstrap-route.test.ts @@ -61,6 +61,10 @@ test("public login bootstrap route exposes metadata login page consumes", async setupComplete: true, oidcEnabled: false, oidcDisablePasswordLogin: false, + // #15153: social login flags (providers stay off without credentials + an allowlist). + googleAuthEnabled: false, + githubAuthEnabled: false, + disablePasswordLogin: false, nodeVersion: body.nodeVersion, nodeCompatible: body.nodeCompatible, }); @@ -86,6 +90,10 @@ test("public login bootstrap route reports env-provided bootstrap password metad setupComplete: true, oidcEnabled: false, oidcDisablePasswordLogin: false, + // #15153: social login flags (providers stay off without credentials + an allowlist). + googleAuthEnabled: false, + githubAuthEnabled: false, + disablePasswordLogin: false, nodeVersion: body.nodeVersion, nodeCompatible: body.nodeCompatible, }); @@ -110,6 +118,10 @@ test("public login bootstrap route reports stored password metadata in disabled setupComplete: true, oidcEnabled: false, oidcDisablePasswordLogin: false, + // #15153: social login flags (providers stay off without credentials + an allowlist). + googleAuthEnabled: false, + githubAuthEnabled: false, + disablePasswordLogin: false, nodeVersion: body.nodeVersion, nodeCompatible: body.nodeCompatible, }); @@ -261,3 +273,25 @@ test("public login bootstrap route POST returns 500 when hashing fails", async ( assert.equal(response.status, 500); assert.deepEqual(body, { error: "hash failed" }); }); + +test("public login bootstrap route only advertises social login when credentials AND an allowlist exist", async () => { + await settingsDb.updateSettings({ + requireLogin: true, + setupComplete: true, + googleClientId: "g-id", + googleClientSecret: "g-secret", + githubClientId: "gh-id", + githubClientSecret: "gh-secret", + authAllowedEmails: [], + }); + let body = (await (await route.GET()).json()) as Record; + assert.equal(body.googleAuthEnabled, false); + assert.equal(body.githubAuthEnabled, false); + assert.equal("googleClientSecret" in body, false); + + await settingsDb.updateSettings({ authAllowedEmails: ["admin@company.com"] }); + body = (await (await route.GET()).json()) as Record; + assert.equal(body.googleAuthEnabled, true); + assert.equal(body.githubAuthEnabled, true); + assert.ok(!JSON.stringify(body).includes("g-secret")); +}); diff --git a/tests/unit/settings-route-social-oauth.test.ts b/tests/unit/settings-route-social-oauth.test.ts new file mode 100644 index 00000000000..713a7c1be82 --- /dev/null +++ b/tests/unit/settings-route-social-oauth.test.ts @@ -0,0 +1,106 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { makeManagementSessionRequest } from "../helpers/managementSession.ts"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-settings-social-oauth-")); +process.env.DATA_DIR = TEST_DATA_DIR; +const ORIGINAL_INITIAL_PASSWORD = process.env.INITIAL_PASSWORD; + +const core = await import("../../src/lib/db/core.ts"); +const settingsDb = await import("../../src/lib/db/settings.ts"); +const settingsRoute = await import("../../src/app/api/settings/route.ts"); + +test.beforeEach(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); + process.env.INITIAL_PASSWORD = "bootstrap-secret"; +}); + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + if (ORIGINAL_INITIAL_PASSWORD === undefined) delete process.env.INITIAL_PASSWORD; + else process.env.INITIAL_PASSWORD = ORIGINAL_INITIAL_PASSWORD; +}); + +async function patch(body: Record) { + return settingsRoute.PATCH( + await makeManagementSessionRequest("http://localhost/api/settings", { + method: "PATCH", + body: { currentPassword: "bootstrap-secret", ...body }, + }) + ); +} + +test("social login keys are persistable through PATCH /api/settings (schema is strict)", async () => { + const res = await patch({ + googleClientId: "g-client", + googleClientSecret: "g-secret-value", + googleAuthEnabled: true, + githubClientId: "gh-client", + githubClientSecret: "gh-secret-value", + githubAuthEnabled: true, + authAllowedEmails: ["admin@company.com", "octocat"], + disablePasswordLogin: false, + }); + assert.equal(res.status, 200); + + const stored = await settingsDb.getSettings(); + assert.equal(stored.googleClientId, "g-client"); + assert.equal(stored.googleClientSecret, "g-secret-value"); + assert.equal(stored.githubClientSecret, "gh-secret-value"); + assert.deepEqual(stored.authAllowedEmails, ["admin@company.com", "octocat"]); +}); + +test("GET and PATCH responses never carry the Google/GitHub client secrets", async () => { + const patchRes = await patch({ + googleClientSecret: "g-secret-value", + githubClientSecret: "gh-secret-value", + }); + const patchText = await patchRes.text(); + assert.equal(patchRes.status, 200); + assert.ok(!patchText.includes("g-secret-value"), "PATCH response leaked the Google secret"); + assert.ok(!patchText.includes("gh-secret-value"), "PATCH response leaked the GitHub secret"); + + const getRes = await settingsRoute.GET( + await makeManagementSessionRequest("http://localhost/api/settings") + ); + const getText = await getRes.text(); + assert.equal(getRes.status, 200); + assert.ok(!getText.includes("g-secret-value"), "GET leaked the Google secret"); + assert.ok(!getText.includes("gh-secret-value"), "GET leaked the GitHub secret"); + const body = JSON.parse(getText); + assert.equal(body.googleClientSecretConfigured, true); + assert.equal(body.githubClientSecretConfigured, true); + assert.equal("googleClientSecret" in body, false); + assert.equal("githubClientSecret" in body, false); +}); + +test("social login settings are security-impacting: changing them needs the current password", async () => { + const res = await settingsRoute.PATCH( + await makeManagementSessionRequest("http://localhost/api/settings", { + method: "PATCH", + body: { authAllowedEmails: ["intruder@evil.test"], currentPassword: "wrong" }, + }) + ); + assert.ok(res.status === 401 || res.status === 403, `status ${res.status}`); + const stored = await settingsDb.getSettings(); + assert.deepEqual(stored.authAllowedEmails, []); +}); + +test("redirect paths that could rewrite the redirect_uri host are rejected", async () => { + for (const bad of ["//evil.example.net/cb", "@evil.example.net", "https://evil.example.net"]) { + const res = await patch({ googleRedirectPath: bad }); + assert.equal(res.status, 400, bad); + } + assert.equal((await patch({ googleRedirectPath: "/api/auth/google/callback" })).status, 200); +}); + +test("the OIDC redirect path default survives (#15153 regression)", async () => { + const stored = await settingsDb.getSettings(); + assert.equal(stored.oidcRedirectPath, "/api/auth/oidc/callback"); +}); diff --git a/tests/unit/social-oauth-routes.test.ts b/tests/unit/social-oauth-routes.test.ts new file mode 100644 index 00000000000..98805b508af --- /dev/null +++ b/tests/unit/social-oauth-routes.test.ts @@ -0,0 +1,559 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { verifyDashboardSessionToken } from "../../src/shared/utils/dashboardSessionToken.ts"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-social-auth-test-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.JWT_SECRET = "test-jwt-secret-social-oauth-routes-32chars"; + +// Dynamic imports matching repo's auth test harness pattern +// @ts-ignore +const core = await import("../../src/lib/db/core.ts"); +// @ts-ignore +const { updateSettings } = await import("@/lib/db/settings"); +// @ts-ignore +const googleLoginRoute = await import("../../src/app/api/auth/google/login/route.ts"); +// @ts-ignore +const googleCallbackRoute = await import("../../src/app/api/auth/google/callback/route.ts"); +// @ts-ignore +const githubLoginRoute = await import("../../src/app/api/auth/github/login/route.ts"); +// @ts-ignore +const githubCallbackRoute = await import("../../src/app/api/auth/github/callback/route.ts"); + +interface CapturedCookie { + value: string; + options?: Record; +} + +let capturedCookies: Record = {}; + +function makeTestCookieStore() { + return { + get(name: string) { + const c = capturedCookies[name]; + return c ? { value: c.value } : undefined; + }, + set(name: string, value: string, options?: Record) { + capturedCookies[name] = { value, options }; + }, + delete(name: string) { + delete capturedCookies[name]; + }, + }; +} + +async function resetStorage() { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); + capturedCookies = {}; +} + +test.beforeEach(async () => { + await resetStorage(); + googleCallbackRoute.googleCallbackInternals.getCookieStore = async () => makeTestCookieStore(); + githubCallbackRoute.githubCallbackInternals.getCookieStore = async () => makeTestCookieStore(); +}); + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); +}); + +test("Google Login: returns 400 when not configured", async () => { + await updateSettings({ googleAuthEnabled: false, googleClientId: "", googleClientSecret: "" }); + const req = new Request("http://localhost/api/auth/google/login"); + const res = await googleLoginRoute.GET(req); + assert.equal(res.status, 400); +}); + +test("Google Login: redirects to accounts.google.com with valid params and sets state cookie", async () => { + await updateSettings({ + googleAuthEnabled: true, + googleClientId: "g-test-client-id", + googleClientSecret: "g-test-client-secret", + googleRedirectPath: "/api/auth/google/callback", + authAllowedEmails: ["admin@company.com"], + }); + + const req = new Request("http://localhost/api/auth/google/login", { + headers: { "x-forwarded-proto": "https", host: "app.example.com" }, + }); + const res = await googleLoginRoute.GET(req); + assert.equal(res.status, 307); + + const location = res.headers.get("location"); + assert.ok(location); + const authUrl = new URL(location); + assert.equal(authUrl.origin, "https://accounts.google.com"); + assert.equal(authUrl.pathname, "/o/oauth2/v2/auth"); + assert.equal(authUrl.searchParams.get("client_id"), "g-test-client-id"); + assert.equal( + authUrl.searchParams.get("redirect_uri"), + "https://app.example.com/api/auth/google/callback" + ); + assert.equal(authUrl.searchParams.get("response_type"), "code"); + assert.ok(authUrl.searchParams.get("state")); + + const setCookie = res.headers.get("set-cookie"); + assert.ok(setCookie?.includes("google_oauth_state=")); +}); + +test("Google Callback: rejects missing or mismatched state", async () => { + await updateSettings({ + googleAuthEnabled: true, + googleClientId: "g-test-client-id", + googleClientSecret: "g-test-client-secret", + authAllowedEmails: ["admin@company.com"], + }); + + // Missing state + const req1 = new Request("http://localhost/api/auth/google/callback?code=123"); + const res1 = await googleCallbackRoute.GET(req1); + assert.equal(res1.status, 307); + assert.ok(res1.headers.get("location")?.includes("error=missing_code")); + + // Mismatched state + capturedCookies["google_oauth_state"] = { value: "expected-state-value" }; + const req2 = new Request("http://localhost/api/auth/google/callback?code=123&state=wrong-state"); + const res2 = await googleCallbackRoute.GET(req2); + assert.equal(res2.status, 307); + assert.ok(res2.headers.get("location")?.includes("error=invalid_state")); +}); + +test("Google Callback: exchanges code, checks allowlist, and sets auth_token cookie", async () => { + await updateSettings({ + googleAuthEnabled: true, + googleClientId: "g-test-client-id", + googleClientSecret: "g-test-client-secret", + authAllowedEmails: ["allowed@company.com"], + }); + + const stateVal = "correct-test-state-999"; + capturedCookies["google_oauth_state"] = { value: stateVal }; + + const originalFetch = globalThis.fetch; + globalThis.fetch = (async (input: RequestInfo | URL) => { + const urlStr = typeof input === "string" ? input : input.toString(); + if (urlStr.includes("oauth2.googleapis.com/token")) { + return new Response(JSON.stringify({ access_token: "mock-google-token" }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + if (urlStr.includes("googleapis.com/oauth2/v3/userinfo")) { + return new Response( + JSON.stringify({ + email: "allowed@company.com", + email_verified: true, + name: "Test Admin", + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + } + return new Response("Not Found", { status: 404 }); + }) as typeof fetch; + + try { + const req = new Request( + `http://localhost/api/auth/google/callback?code=auth-code-123&state=${stateVal}`, + { + headers: { host: "app.example.com" }, + } + ); + const res = await googleCallbackRoute.GET(req); + assert.equal(res.status, 307); + assert.equal(res.headers.get("location"), "http://app.example.com/dashboard"); + + // auth_token session cookie must be set + const sessionCookie = capturedCookies["auth_token"]; + assert.ok(sessionCookie?.value, "auth_token cookie must be set"); + + // Verify minted token passes verifyDashboardSessionToken + const secret = new TextEncoder().encode(process.env.JWT_SECRET); + const verified = await verifyDashboardSessionToken(sessionCookie.value, secret); + assert.ok(verified !== null); + assert.equal(verified.authenticated, true); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("Google Callback: blocks email not in allowlist", async () => { + await updateSettings({ + googleAuthEnabled: true, + googleClientId: "g-test-client-id", + googleClientSecret: "g-test-client-secret", + authAllowedEmails: ["admin@company.com"], + }); + + const stateVal = "correct-test-state-888"; + capturedCookies["google_oauth_state"] = { value: stateVal }; + + const originalFetch = globalThis.fetch; + globalThis.fetch = (async (input: RequestInfo | URL) => { + const urlStr = typeof input === "string" ? input : input.toString(); + if (urlStr.includes("oauth2.googleapis.com/token")) { + return new Response(JSON.stringify({ access_token: "mock-google-token" }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + if (urlStr.includes("googleapis.com/oauth2/v3/userinfo")) { + return new Response( + JSON.stringify({ + email: "intruder@other.com", + email_verified: true, + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + } + return new Response("Not Found", { status: 404 }); + }) as typeof fetch; + + try { + const req = new Request( + `http://localhost/api/auth/google/callback?code=auth-code-123&state=${stateVal}` + ); + const res = await googleCallbackRoute.GET(req); + assert.equal(res.status, 307); + assert.ok(res.headers.get("location")?.includes("error=unauthorized_email")); + assert.equal(capturedCookies["auth_token"], undefined); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("GitHub Login: redirects to github.com/login/oauth/authorize and sets state cookie", async () => { + await updateSettings({ + githubAuthEnabled: true, + githubClientId: "gh-test-client-id", + githubClientSecret: "gh-test-client-secret", + authAllowedEmails: ["admin@company.com"], + }); + + const req = new Request("http://localhost/api/auth/github/login", { + headers: { host: "myhub.test" }, + }); + const res = await githubLoginRoute.GET(req); + assert.equal(res.status, 307); + + const location = res.headers.get("location"); + assert.ok(location); + const authUrl = new URL(location); + assert.equal(authUrl.origin, "https://github.com"); + assert.equal(authUrl.pathname, "/login/oauth/authorize"); + assert.equal(authUrl.searchParams.get("client_id"), "gh-test-client-id"); + assert.equal( + authUrl.searchParams.get("redirect_uri"), + "http://myhub.test/api/auth/github/callback" + ); + assert.ok(authUrl.searchParams.get("state")); + + const setCookie = res.headers.get("set-cookie"); + assert.ok(setCookie?.includes("github_oauth_state=")); +}); + +test("GitHub Callback: exchanges code, resolves verified email, and mints session", async () => { + await updateSettings({ + githubAuthEnabled: true, + githubClientId: "gh-test-client-id", + githubClientSecret: "gh-test-client-secret", + authAllowedEmails: ["github-user@domain.com"], + }); + + const stateVal = "github-state-xyz-777"; + capturedCookies["github_oauth_state"] = { value: stateVal }; + + const originalFetch = globalThis.fetch; + globalThis.fetch = (async (input: RequestInfo | URL) => { + const urlStr = typeof input === "string" ? input : input.toString(); + if (urlStr.includes("github.com/login/oauth/access_token")) { + return new Response(JSON.stringify({ access_token: "mock-gh-access-token" }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + if (urlStr === "https://api.github.com/user") { + return new Response( + JSON.stringify({ + login: "octocat", + id: 1, + name: "The Octocat", + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + } + if (urlStr === "https://api.github.com/user/emails") { + return new Response( + JSON.stringify([ + { email: "unverified@domain.com", primary: false, verified: false }, + { email: "github-user@domain.com", primary: true, verified: true }, + ]), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + } + return new Response("Not Found", { status: 404 }); + }) as typeof fetch; + + try { + const req = new Request( + `http://localhost/api/auth/github/callback?code=gh-code-456&state=${stateVal}`, + { + headers: { host: "myhub.test" }, + } + ); + const res = await githubCallbackRoute.GET(req); + assert.equal(res.status, 307); + assert.equal(res.headers.get("location"), "http://myhub.test/dashboard"); + + const sessionCookie = capturedCookies["auth_token"]; + assert.ok(sessionCookie?.value, "auth_token cookie must be set"); + + const secret = new TextEncoder().encode(process.env.JWT_SECRET); + const verified = await verifyDashboardSessionToken(sessionCookie.value, secret); + assert.ok(verified !== null); + assert.equal(verified.authenticated, true); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("Google Login: 400 when credentials exist but no allowlist is configured (deny-by-default)", async () => { + await updateSettings({ + googleAuthEnabled: true, + googleClientId: "g-test-client-id", + googleClientSecret: "g-test-client-secret", + authAllowedEmails: [], + }); + const res = await googleLoginRoute.GET(new Request("http://localhost/api/auth/google/login")); + assert.equal(res.status, 400); + assert.equal(res.headers.get("set-cookie"), null); +}); + +test("GitHub Login: 400 when credentials exist but no allowlist is configured (deny-by-default)", async () => { + await updateSettings({ + githubAuthEnabled: true, + githubClientId: "gh-test-client-id", + githubClientSecret: "gh-test-client-secret", + authAllowedEmails: ["*"], + }); + const res = await githubLoginRoute.GET(new Request("http://localhost/api/auth/github/login")); + assert.equal(res.status, 400); +}); + +test("Login routes build redirect_uri from Host and ignore X-Forwarded-Host", async () => { + await updateSettings({ + googleAuthEnabled: true, + googleClientId: "g-test-client-id", + googleClientSecret: "g-test-client-secret", + authAllowedEmails: ["admin@company.com"], + }); + const res = await googleLoginRoute.GET( + new Request("http://localhost/api/auth/google/login", { + headers: { host: "app.example.com", "x-forwarded-host": "evil.example.net" }, + }) + ); + const authUrl = new URL(res.headers.get("location") as string); + assert.equal( + authUrl.searchParams.get("redirect_uri"), + "http://app.example.com/api/auth/google/callback" + ); +}); + +test("Login routes return a body without stack traces or raw error text", async () => { + await updateSettings({ googleAuthEnabled: false, githubAuthEnabled: false }); + for (const route of [googleLoginRoute, githubLoginRoute]) { + const res = await route.GET(new Request("http://localhost/api/auth/x/login")); + const text = await res.text(); + assert.equal(res.status, 400); + assert.ok(!text.includes("at /"), "no stack frames in the body"); + assert.ok(JSON.parse(text).error, "error envelope present"); + } +}); + +test("Google Callback: an allowlist-less deployment rejects even a verified account", async () => { + await updateSettings({ + googleAuthEnabled: true, + googleClientId: "g-test-client-id", + googleClientSecret: "g-test-client-secret", + authAllowedEmails: [], + }); + capturedCookies["google_oauth_state"] = { value: "state-open-world" }; + const res = await googleCallbackRoute.GET( + new Request("http://localhost/api/auth/google/callback?code=c&state=state-open-world") + ); + assert.equal(res.status, 307); + assert.ok(res.headers.get("location")?.includes("error=not_configured")); + assert.equal(capturedCookies["auth_token"], undefined); +}); + +function githubFetchMock(opts: { + profile: Record; + emails: unknown; + emailsStatus?: number; +}) { + return (async (input: RequestInfo | URL) => { + const urlStr = typeof input === "string" ? input : input.toString(); + if (urlStr.includes("github.com/login/oauth/access_token")) { + return new Response(JSON.stringify({ access_token: "mock-gh-access-token" }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + if (urlStr === "https://api.github.com/user") { + return new Response(JSON.stringify(opts.profile), { status: 200 }); + } + if (urlStr === "https://api.github.com/user/emails") { + return new Response(JSON.stringify(opts.emails), { status: opts.emailsStatus ?? 200 }); + } + return new Response("Not Found", { status: 404 }); + }) as typeof fetch; +} + +async function runGithubCallback(fetchMock: typeof fetch, stateVal: string) { + capturedCookies["github_oauth_state"] = { value: stateVal }; + const originalFetch = globalThis.fetch; + globalThis.fetch = fetchMock; + try { + return await githubCallbackRoute.GET( + new Request(`http://localhost/api/auth/github/callback?code=c&state=${stateVal}`, { + headers: { host: "myhub.test", "x-forwarded-host": "evil.example.net" }, + }) + ); + } finally { + globalThis.fetch = originalFetch; + } +} + +test("GitHub Callback: does not fall back to the unverified public profile e-mail", async () => { + await updateSettings({ + githubAuthEnabled: true, + githubClientId: "gh-test-client-id", + githubClientSecret: "gh-test-client-secret", + authAllowedEmails: ["victim@company.com"], + }); + // /user/emails fails, the public profile claims an allowlisted address (unverified). + const res = await runGithubCallback( + githubFetchMock({ + profile: { login: "attacker", email: "victim@company.com" }, + emails: { message: "forbidden" }, + emailsStatus: 403, + }), + "gh-state-profile-fallback" + ); + assert.equal(res.status, 307); + assert.ok(res.headers.get("location")?.includes("error=email_not_verified")); + assert.equal(capturedCookies["auth_token"], undefined); +}); + +test("GitHub Callback: only a verified e-mail counts, and redirects stay on Host", async () => { + await updateSettings({ + githubAuthEnabled: true, + githubClientId: "gh-test-client-id", + githubClientSecret: "gh-test-client-secret", + authAllowedEmails: ["victim@company.com"], + }); + const res = await runGithubCallback( + githubFetchMock({ + profile: { login: "attacker" }, + emails: [{ email: "victim@company.com", primary: true, verified: false }], + }), + "gh-state-unverified" + ); + assert.equal(res.status, 307); + const location = res.headers.get("location") as string; + assert.ok(location.includes("error=email_not_verified")); + assert.equal(new URL(location).host, "myhub.test", "X-Forwarded-Host must not steer redirects"); + assert.equal(capturedCookies["auth_token"], undefined); +}); + +test("GitHub Callback: username allowlist entry admits that login (verified e-mail still required)", async () => { + await updateSettings({ + githubAuthEnabled: true, + githubClientId: "gh-test-client-id", + githubClientSecret: "gh-test-client-secret", + authAllowedEmails: ["octocat"], + }); + const res = await runGithubCallback( + githubFetchMock({ + profile: { login: "octocat" }, + emails: [{ email: "octo@elsewhere.dev", primary: true, verified: true }], + }), + "gh-state-username" + ); + assert.equal(res.headers.get("location"), "http://myhub.test/dashboard"); + assert.ok(capturedCookies["auth_token"]?.value); +}); + +test("GitHub Callback: a domain entry is not satisfied by a GitHub username", async () => { + await updateSettings({ + githubAuthEnabled: true, + githubClientId: "gh-test-client-id", + githubClientSecret: "gh-test-client-secret", + authAllowedEmails: ["@corp.local"], + }); + const res = await runGithubCallback( + githubFetchMock({ + profile: { login: "corp.local" }, + emails: [{ email: "x@other.dev", primary: true, verified: true }], + }), + "gh-state-domain" + ); + assert.ok(res.headers.get("location")?.includes("error=unauthorized_email")); + assert.equal(capturedCookies["auth_token"], undefined); +}); + +test("Google Callback: token exchange failure redirects with a bare code, never upstream text", async () => { + await updateSettings({ + googleAuthEnabled: true, + googleClientId: "g-test-client-id", + googleClientSecret: "g-test-client-secret", + authAllowedEmails: ["admin@company.com"], + }); + capturedCookies["google_oauth_state"] = { value: "state-token-fail" }; + + const originalFetch = globalThis.fetch; + globalThis.fetch = (async () => + new Response("invalid_grant: client_secret g-test-client-secret at /srv/app.js:1", { + status: 500, + })) as typeof fetch; + try { + const res = await googleCallbackRoute.GET( + new Request("http://localhost/api/auth/google/callback?code=c&state=state-token-fail") + ); + const location = res.headers.get("location") as string; + assert.equal(new URL(location).pathname, "/login"); + assert.equal(new URL(location).searchParams.get("error"), "token_exchange"); + assert.ok(!location.includes("invalid_grant") && !location.includes("g-test-client-secret")); + assert.equal(capturedCookies["auth_token"], undefined); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("Callbacks consume the state cookie: a replayed callback is rejected", async () => { + await updateSettings({ + githubAuthEnabled: true, + githubClientId: "gh-test-client-id", + githubClientSecret: "gh-test-client-secret", + authAllowedEmails: ["octo@elsewhere.dev"], + }); + const fetchMock = githubFetchMock({ + profile: { login: "octocat" }, + emails: [{ email: "octo@elsewhere.dev", primary: true, verified: true }], + }); + const first = await runGithubCallback(fetchMock, "gh-state-replay"); + assert.equal(first.headers.get("location"), "http://myhub.test/dashboard"); + assert.equal(capturedCookies["github_oauth_state"]?.value, "", "state cookie must be cleared"); + + const replay = await githubCallbackRoute.GET( + new Request("http://localhost/api/auth/github/callback?code=c&state=gh-state-replay", { + headers: { host: "myhub.test" }, + }) + ); + assert.ok(replay.headers.get("location")?.includes("error=invalid_state")); +}); diff --git a/tests/unit/social-oauth.test.ts b/tests/unit/social-oauth.test.ts new file mode 100644 index 00000000000..f1891c3cbda --- /dev/null +++ b/tests/unit/social-oauth.test.ts @@ -0,0 +1,245 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + isEmailAllowed, + isGithubLoginAllowed, + getGoogleOAuthConfig, + getGitHubOAuthConfig, + getRequestOrigin, + timingSafeCompare, + createDashboardSessionJwt, +} from "../../src/lib/auth/socialOAuth.ts"; +import { verifyDashboardSessionToken } from "../../src/shared/utils/dashboardSessionToken.ts"; + +test("timingSafeCompare properly checks string equality in constant time", () => { + assert.equal(timingSafeCompare("abcdef123", "abcdef123"), true); + assert.equal(timingSafeCompare("abcdef123", "abcdef124"), false); + assert.equal(timingSafeCompare("abcdef123", "abcdef"), false); + assert.equal(timingSafeCompare("", ""), true); + assert.equal(timingSafeCompare(null, null), true); + assert.equal(timingSafeCompare("a", null), false); + assert.equal(timingSafeCompare(undefined, "b"), false); +}); + +test("isEmailAllowed validates emails against configured allowlists", () => { + // Deny-by-default (#15153): an empty/undefined allowlist — or a bare "*" — never admits anyone, + // otherwise any Google/GitHub account on the planet would receive a 30-day admin session. + const savedEnv = process.env.AUTH_ALLOWED_EMAILS; + delete process.env.AUTH_ALLOWED_EMAILS; + assert.equal(isEmailAllowed("developer@example.com", []), false); + assert.equal(isEmailAllowed("developer@example.com", undefined), false); + assert.equal(isEmailAllowed("developer@example.com", "*"), false); + assert.equal(isEmailAllowed("developer@example.com", ["*"]), false); + assert.equal(isEmailAllowed("developer@example.com", [" ", ""]), false); + if (savedEnv !== undefined) process.env.AUTH_ALLOWED_EMAILS = savedEnv; + + // Exact matching with case insensitivity + const allowedList = ["admin@company.com", "CTO@Company.com", "DevOps@CLOUD.io"]; + assert.equal(isEmailAllowed("admin@company.com", allowedList), true); + assert.equal(isEmailAllowed("ADMIN@COMPANY.COM", allowedList), true); + assert.equal(isEmailAllowed("cto@company.com", allowedList), true); + assert.equal(isEmailAllowed("devops@cloud.io", allowedList), true); + assert.equal(isEmailAllowed("stranger@company.com", allowedList), false); + + // Comma-separated string format + const csvAllowed = "admin@example.com, test@example.com"; + assert.equal(isEmailAllowed("admin@example.com", csvAllowed), true); + assert.equal(isEmailAllowed("test@example.com", csvAllowed), true); + assert.equal(isEmailAllowed("other@example.com", csvAllowed), false); + + // Wildcard domain matching: *@domain.com or @domain.com + const domainAllowed = ["*@trusted.org", "@corp.local"]; + assert.equal(isEmailAllowed("alice@trusted.org", domainAllowed), true); + assert.equal(isEmailAllowed("bob@trusted.org", domainAllowed), true); + assert.equal(isEmailAllowed("charlie@corp.local", domainAllowed), true); + assert.equal(isEmailAllowed("hacker@untrusted.org", domainAllowed), false); + + // Invalid inputs + assert.equal(isEmailAllowed("", allowedList), false); + assert.equal(isEmailAllowed(null, allowedList), false); + assert.equal(isEmailAllowed(undefined, allowedList), false); +}); + +test("getGoogleOAuthConfig resolves credentials from settings and env", () => { + // From settings + const settings = { + googleAuthEnabled: true, + authAllowedEmails: ["admin@company.com"], + googleClientId: "g-client-123", + googleClientSecret: "g-secret-456", + googleRedirectPath: "/custom/callback", + }; + const config = getGoogleOAuthConfig(settings); + assert.equal(config.enabled, true); + assert.equal(config.clientId, "g-client-123"); + assert.equal(config.clientSecret, "g-secret-456"); + assert.equal(config.redirectPath, "/custom/callback"); + + // Fallback to env + process.env.AUTH_GOOGLE_CLIENT_ID = "env-g-client"; + process.env.AUTH_GOOGLE_CLIENT_SECRET = "env-g-secret"; + const envConfig = getGoogleOAuthConfig({ authAllowedEmails: ["admin@company.com"] }); + assert.equal(envConfig.enabled, true); + assert.equal(envConfig.clientId, "env-g-client"); + assert.equal(envConfig.clientSecret, "env-g-secret"); + assert.equal(envConfig.redirectPath, "/api/auth/google/callback"); + delete process.env.AUTH_GOOGLE_CLIENT_ID; + delete process.env.AUTH_GOOGLE_CLIENT_SECRET; +}); + +test("getGitHubOAuthConfig resolves credentials from settings and env", () => { + // From settings + const settings = { + githubAuthEnabled: true, + authAllowedEmails: ["admin@company.com"], + githubClientId: "gh-client-123", + githubClientSecret: "gh-secret-456", + }; + const config = getGitHubOAuthConfig(settings); + assert.equal(config.enabled, true); + assert.equal(config.clientId, "gh-client-123"); + assert.equal(config.clientSecret, "gh-secret-456"); + assert.equal(config.redirectPath, "/api/auth/github/callback"); + + // Fallback to env + process.env.AUTH_GITHUB_CLIENT_ID = "env-gh-client"; + process.env.AUTH_GITHUB_CLIENT_SECRET = "env-gh-secret"; + const envConfig = getGitHubOAuthConfig({ authAllowedEmails: ["admin@company.com"] }); + assert.equal(envConfig.enabled, true); + assert.equal(envConfig.clientId, "env-gh-client"); + assert.equal(envConfig.clientSecret, "env-gh-secret"); + delete process.env.AUTH_GITHUB_CLIENT_ID; + delete process.env.AUTH_GITHUB_CLIENT_SECRET; +}); + +test("getRequestOrigin honours forwarded proto and Host, never X-Forwarded-Host", () => { + const req = new Request("http://internal-docker:3000/api/auth/google/login", { + headers: { + "x-forwarded-proto": "https", + host: "omniroute.example.com", + "x-forwarded-host": "evil.example.net", + }, + }); + const origin = getRequestOrigin(req); + assert.equal(origin, "https://omniroute.example.com"); + + const localReq = new Request("http://localhost:20128/api/auth/github/login"); + assert.equal(getRequestOrigin(localReq), "http://localhost:20128"); +}); + +test("createDashboardSessionJwt mints a valid session token that passes verifyDashboardSessionToken", async () => { + const testSecret = new TextEncoder().encode("super-secure-test-jwt-secret-at-least-32-chars"); + const jwt = await createDashboardSessionJwt(testSecret); + + assert.ok(jwt && typeof jwt === "string", "JWT must be a non-empty string"); + + // Verifier requires matching secret and authenticated: true claim + const payload = await verifyDashboardSessionToken(jwt, testSecret); + assert.ok(payload !== null, "Session token must verify successfully"); + assert.equal(payload.authenticated, true, "Payload must carry authenticated: true"); + + // Wrong secret must reject + const wrongSecret = new TextEncoder().encode("wrong-secret-key-32-characters-long!!"); + const invalidPayload = await verifyDashboardSessionToken(jwt, wrongSecret); + assert.equal(invalidPayload, null, "Wrong secret must fail verification"); +}); + +test("provider is disabled until an allowlist is configured (#15153)", () => { + const savedEnv = process.env.AUTH_ALLOWED_EMAILS; + delete process.env.AUTH_ALLOWED_EMAILS; + try { + const creds = { + googleClientId: "g-id", + googleClientSecret: "g-secret", + githubClientId: "gh-id", + githubClientSecret: "gh-secret", + }; + assert.equal(getGoogleOAuthConfig(creds).enabled, false); + assert.equal(getGitHubOAuthConfig(creds).enabled, false); + assert.equal(getGoogleOAuthConfig({ ...creds, authAllowedEmails: ["*"] }).enabled, false); + assert.equal(getGoogleOAuthConfig({ ...creds, authAllowedEmails: ["a@b.co"] }).enabled, true); + assert.equal(getGitHubOAuthConfig({ ...creds, authAllowedEmails: ["a@b.co"] }).enabled, true); + + process.env.AUTH_ALLOWED_EMAILS = "ops@company.com"; + assert.equal(getGoogleOAuthConfig(creds).enabled, true); + } finally { + if (savedEnv === undefined) delete process.env.AUTH_ALLOWED_EMAILS; + else process.env.AUTH_ALLOWED_EMAILS = savedEnv; + } +}); + +test("generic GOOGLE_CLIENT_ID / GITHUB_CLIENT_ID env vars are NOT used as login credentials", () => { + const saved = { + gid: process.env.GOOGLE_CLIENT_ID, + gsecret: process.env.GOOGLE_CLIENT_SECRET, + hid: process.env.GITHUB_CLIENT_ID, + hsecret: process.env.GITHUB_CLIENT_SECRET, + }; + process.env.GOOGLE_CLIENT_ID = "generic-g"; + process.env.GOOGLE_CLIENT_SECRET = "generic-g-secret"; + process.env.GITHUB_CLIENT_ID = "generic-gh"; + process.env.GITHUB_CLIENT_SECRET = "generic-gh-secret"; + try { + const settings = { authAllowedEmails: ["admin@company.com"] }; + const google = getGoogleOAuthConfig(settings); + const github = getGitHubOAuthConfig(settings); + assert.equal(google.enabled, false); + assert.equal(google.clientId, ""); + assert.equal(github.enabled, false); + assert.equal(github.clientId, ""); + } finally { + for (const [key, value] of [ + ["GOOGLE_CLIENT_ID", saved.gid], + ["GOOGLE_CLIENT_SECRET", saved.gsecret], + ["GITHUB_CLIENT_ID", saved.hid], + ["GITHUB_CLIENT_SECRET", saved.hsecret], + ] as const) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + } +}); + +test("isGithubLoginAllowed only matches bare username entries, never email/domain entries", () => { + const list = ["octocat", "@corp.local", "*@trusted.org", "admin@company.com"]; + assert.equal(isGithubLoginAllowed("octocat", list), true); + assert.equal(isGithubLoginAllowed("OctoCat", list), true); + assert.equal(isGithubLoginAllowed("corp.local", list), false); + assert.equal(isGithubLoginAllowed("trusted.org", list), false); + assert.equal(isGithubLoginAllowed("admin", list), false); + assert.equal(isGithubLoginAllowed("someone-else", list), false); + assert.equal(isGithubLoginAllowed("", list), false); + assert.equal(isGithubLoginAllowed("octocat", []), false); + assert.equal(isGithubLoginAllowed("octocat", ["*"]), false); +}); + +test("redirect path from settings must be a same-origin absolute path (no open redirect_uri)", () => { + const base = { + authAllowedEmails: ["a@b.co"], + googleClientId: "id", + googleClientSecret: "secret", + githubClientId: "id", + githubClientSecret: "secret", + }; + for (const bad of [ + "//evil.example.net/cb", + "@evil.example.net", + "https://evil.example.net/cb", + "cb", + ]) { + assert.equal( + getGoogleOAuthConfig({ ...base, googleRedirectPath: bad }).redirectPath, + "/api/auth/google/callback", + bad + ); + assert.equal( + getGitHubOAuthConfig({ ...base, githubRedirectPath: bad }).redirectPath, + "/api/auth/github/callback", + bad + ); + } + assert.equal( + getGoogleOAuthConfig({ ...base, googleRedirectPath: "/custom/callback" }).redirectPath, + "/custom/callback" + ); +});