Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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 <origin>/api/auth/google/callback and <origin>/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
Expand Down
1 change: 1 addition & 0 deletions changelog.d/features/social-oauth-google-github.md
Original file line number Diff line number Diff line change
@@ -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))
6 changes: 6 additions & 0 deletions docs/reference/ENVIRONMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
145 changes: 145 additions & 0 deletions src/app/api/auth/github/callback/route.ts
Original file line number Diff line number Diff line change
@@ -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<SocialCookieStore>,
};

const GITHUB_API_HEADERS = { "User-Agent": "OmniRoute-OAuth" };

async function exchangeCodeForAccessToken(
config: ReturnType<typeof getGitHubOAuthConfig>,
code: string,
redirectUri: string
): Promise<StepResult<string>> {
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<string> {
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<string> {
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`);
}
54 changes: 54 additions & 0 deletions src/app/api/auth/github/login/route.ts
Original file line number Diff line number Diff line change
@@ -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;
}
125 changes: 125 additions & 0 deletions src/app/api/auth/google/callback/route.ts
Original file line number Diff line number Diff line change
@@ -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<SocialCookieStore>,
};

async function exchangeCodeForAccessToken(
config: ReturnType<typeof getGoogleOAuthConfig>,
code: string,
redirectUri: string
): Promise<StepResult<string>> {
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<StepResult<string>> {
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`);
}
Loading