From 0242cbdebb35bbcc5221be15bdc42e9c8da36e2f Mon Sep 17 00:00:00 2001 From: grootbro Date: Mon, 7 Sep 2026 22:13:36 +0700 Subject: [PATCH 1/2] feat(auth): allowlist OAuth email domains for self-hosted installs Optional OAUTH_ALLOWED_DOMAINS (and Google-specific fallbacks) reject off-domain OAuth users, while matching domains may sign up when ALLOW_REGISTRATION=false. Google also requires the ID token hd claim. --- .../controllers/oauth-callback.controller.tsx | 53 ++++++++- .../self-hosting/environment-variables.mdx | 34 ++++++ .../docs/self-hosting/self-hosting.mdx | 10 ++ packages/auth/src/index.ts | 1 + .../auth/src/oauth-allowed-domains.test.ts | 103 ++++++++++++++++++ packages/auth/src/oauth-allowed-domains.ts | 67 ++++++++++++ self-hosting/.env.template | 2 + 7 files changed, 266 insertions(+), 4 deletions(-) create mode 100644 packages/auth/src/oauth-allowed-domains.test.ts create mode 100644 packages/auth/src/oauth-allowed-domains.ts diff --git a/apps/api/src/controllers/oauth-callback.controller.tsx b/apps/api/src/controllers/oauth-callback.controller.tsx index ff311a352..4ae152920 100644 --- a/apps/api/src/controllers/oauth-callback.controller.tsx +++ b/apps/api/src/controllers/oauth-callback.controller.tsx @@ -2,8 +2,10 @@ import { Arctic, createSession, generateSessionToken, + getOAuthAllowedDomains, github, google, + isOAuthUserAllowedByDomain, type OAuth2Tokens, setLastAuthProviderCookie, setSessionTokenCookie, @@ -51,6 +53,28 @@ interface OAuthUser { email: string; firstName: string; lastName?: string; + hostedDomain?: string; +} + +function assertOAuthUserAllowed(oauthUser: OAuthUser, provider: Provider) { + const allowedDomains = getOAuthAllowedDomains(provider); + if ( + isOAuthUserAllowedByDomain( + { + email: oauthUser.email, + provider, + hostedDomain: oauthUser.hostedDomain, + }, + allowedDomains, + ) + ) { + return; + } + + throw new LogError('OAuth email domain is not allowed', { + provider, + allowedDomains, + }); } // Shared utility functions @@ -67,6 +91,8 @@ async function handleExistingUser({ inviteId: string | undefined | null; reply: FastifyReply; }) { + assertOAuthUserAllowed(oauthUser, providerName); + const sessionToken = generateSessionToken(); const session = await createSession(sessionToken, account.userId); @@ -122,6 +148,8 @@ async function handleNewUser({ inviteId: string | undefined | null; reply: FastifyReply; }) { + assertOAuthUserAllowed(oauthUser, providerName); + const existingUser = await db.user.findFirst({ where: { email: oauthUser.email }, }); @@ -137,10 +165,25 @@ async function handleNewUser({ ); } - // Enforce the self-hosting registration policy here rather than before the - // IdP redirect — this is the first point where we know the user is new, so - // returning users are never caught by it. - if (!(await getIsRegistrationAllowed(inviteId))) { + const allowedDomains = getOAuthAllowedDomains(providerName); + const isRegistrationAllowed = await getIsRegistrationAllowed(inviteId); + const isDomainRestrictedOAuthAllowed = + allowedDomains.length > 0 && + isOAuthUserAllowedByDomain( + { + email: oauthUser.email, + provider: providerName, + hostedDomain: oauthUser.hostedDomain, + }, + allowedDomains, + ); + + // Enforce new-user registration policy here rather than before the IdP + // redirect — this is the first point where we know the user is new, so + // returning users are never caught by it. A matching OAuth allowlist is also + // enough, so self-hosted installs can permit company-domain OAuth signups + // while keeping public registration disabled. + if (!(isRegistrationAllowed || isDomainRestrictedOAuthAllowed)) { // Deliberately no `oauthUser` here — this rejects people who are not users, // so their email and name shouldn't land in application logs. The redirect // carries `correlationId` (the request id), which is what ties a user's @@ -238,6 +281,7 @@ async function fetchGoogleUser(tokens: OAuth2Tokens): Promise { email_verified: z.boolean(), given_name: z.string().optional(), family_name: z.string().optional(), + hd: z.string().optional(), }); const claimsResult = claimsSchema.safeParse(claims); @@ -257,6 +301,7 @@ async function fetchGoogleUser(tokens: OAuth2Tokens): Promise { email: claimsResult.data.email, firstName: claimsResult.data.given_name || '', lastName: claimsResult.data.family_name || '', + hostedDomain: claimsResult.data.hd, }; } diff --git a/apps/public/content/docs/self-hosting/environment-variables.mdx b/apps/public/content/docs/self-hosting/environment-variables.mdx index bb4442ebf..0e83af3a7 100644 --- a/apps/public/content/docs/self-hosting/environment-variables.mdx +++ b/apps/public/content/docs/self-hosting/environment-variables.mdx @@ -283,6 +283,40 @@ Allow user invitations. Set to `false` to disable invitation functionality. ALLOW_INVITATION=false ``` +### OAUTH_ALLOWED_DOMAINS + +**Type**: `string` +**Required**: No +**Default**: None + +Comma-separated list of email domains allowed to sign in or sign up through OAuth. When unset, OAuth behaves as today. When set: + +- OAuth callbacks reject users whose verified email domain is not on the list +- Matching-domain users can sign up even if `ALLOW_REGISTRATION=false` +- For Google, the ID token hosted-domain claim (`hd`) must also match (Workspace) + +**Example**: +```bash +OAUTH_ALLOWED_DOMAINS=example.com,example.org +``` + + +For Google-only restrictions without affecting GitHub, set `GOOGLE_ALLOWED_DOMAINS` (or `GOOGLE_ALLOWED_DOMAIN`) instead. `OAUTH_ALLOWED_DOMAINS` applies to every OAuth provider and takes precedence when both are set. + + +### GOOGLE_ALLOWED_DOMAINS + +**Type**: `string` +**Required**: No +**Default**: None + +Comma-separated Google Workspace domains allowed for Google OAuth when `OAUTH_ALLOWED_DOMAINS` is unset. Alias: `GOOGLE_ALLOWED_DOMAIN`. + +**Example**: +```bash +GOOGLE_ALLOWED_DOMAINS=example.com +``` + ## AI Features The in-app AI chat supports **OpenAI** and **Anthropic** models. Set one or both provider keys on the API service — the model picker in the chat UI automatically shows only the models whose provider has a key configured. If neither is set, the chat drawer still opens but shows setup instructions instead of suggestions. diff --git a/apps/public/content/docs/self-hosting/self-hosting.mdx b/apps/public/content/docs/self-hosting/self-hosting.mdx index c0f06671a..0fd347824 100644 --- a/apps/public/content/docs/self-hosting/self-hosting.mdx +++ b/apps/public/content/docs/self-hosting/self-hosting.mdx @@ -195,6 +195,16 @@ Invitations are enabled by default. You can also disable invitations by setting ALLOW_INVITATION=false ``` +### OAuth domain allowlist + +To restrict Google/GitHub OAuth to company email domains (while keeping `ALLOW_REGISTRATION=false`): + +```bash title=".env" +OAUTH_ALLOWED_DOMAINS=example.com +``` + +Matching-domain OAuth users can sign in and create accounts. Everyone else is rejected at the OAuth callback. For Google Workspace, OpenPanel also checks the `hd` claim on the ID token. + For a complete reference of all environment variables, see the [Environment Variables documentation](/docs/self-hosting/environment-variables). ## Helpful scripts diff --git a/packages/auth/src/index.ts b/packages/auth/src/index.ts index 1f170515a..8a12cd0d1 100644 --- a/packages/auth/src/index.ts +++ b/packages/auth/src/index.ts @@ -1,5 +1,6 @@ export * from './cookie'; export * from './oauth'; +export * from './oauth-allowed-domains'; export * from './password'; export * from './session'; export * from './totp'; diff --git a/packages/auth/src/oauth-allowed-domains.test.ts b/packages/auth/src/oauth-allowed-domains.test.ts new file mode 100644 index 000000000..84b327dba --- /dev/null +++ b/packages/auth/src/oauth-allowed-domains.test.ts @@ -0,0 +1,103 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + getEmailDomain, + getOAuthAllowedDomains, + isOAuthUserAllowedByDomain, + parseOAuthAllowedDomains, +} from './oauth-allowed-domains'; + +describe('oauth allowed domains', () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it('parses comma-separated domains', () => { + expect( + parseOAuthAllowedDomains(' Example.com, @Example.org, example.com. '), + ).toEqual(['example.com', 'example.org']); + }); + + it('reads global OAuth domains before provider-specific domains', () => { + vi.stubEnv('OAUTH_ALLOWED_DOMAINS', 'example.com'); + vi.stubEnv('GOOGLE_ALLOWED_DOMAIN', 'google.example'); + + expect(getOAuthAllowedDomains('google')).toEqual(['example.com']); + }); + + it('falls back to Google-specific domains for Google OAuth', () => { + vi.stubEnv('GOOGLE_ALLOWED_DOMAIN', 'example.com'); + + expect(getOAuthAllowedDomains('google')).toEqual(['example.com']); + expect(getOAuthAllowedDomains('github')).toEqual([]); + }); + + it('extracts email domains case-insensitively', () => { + expect(getEmailDomain('User@Example.COM')).toBe('example.com'); + expect(getEmailDomain('invalid-email')).toBeNull(); + }); + + it('allows OAuth users when no allowlist is configured', () => { + expect( + isOAuthUserAllowedByDomain({ + provider: 'github', + email: 'user@anywhere.example', + }), + ).toBe(true); + }); + + it('checks GitHub users by verified email domain', () => { + expect( + isOAuthUserAllowedByDomain( + { + provider: 'github', + email: 'user@example.com', + }, + ['example.com'], + ), + ).toBe(true); + + expect( + isOAuthUserAllowedByDomain( + { + provider: 'github', + email: 'user@other.example', + }, + ['example.com'], + ), + ).toBe(false); + }); + + it('requires Google hosted domain to match the allowlist', () => { + expect( + isOAuthUserAllowedByDomain( + { + provider: 'google', + email: 'user@example.com', + hostedDomain: 'example.com', + }, + ['example.com'], + ), + ).toBe(true); + + expect( + isOAuthUserAllowedByDomain( + { + provider: 'google', + email: 'user@example.com', + }, + ['example.com'], + ), + ).toBe(false); + + expect( + isOAuthUserAllowedByDomain( + { + provider: 'google', + email: 'user@example.com', + hostedDomain: 'other.example', + }, + ['example.com'], + ), + ).toBe(false); + }); +}); diff --git a/packages/auth/src/oauth-allowed-domains.ts b/packages/auth/src/oauth-allowed-domains.ts new file mode 100644 index 000000000..38642c719 --- /dev/null +++ b/packages/auth/src/oauth-allowed-domains.ts @@ -0,0 +1,67 @@ +export type OAuthProvider = 'github' | 'google'; + +export interface OAuthDomainCheckInput { + email: string; + provider: OAuthProvider; + hostedDomain?: string | null; +} + +function normalizeDomain(domain: string) { + return domain.trim().toLowerCase().replace(/^@/, '').replace(/\.$/, ''); +} + +export function parseOAuthAllowedDomains(value?: string | null) { + return Array.from( + new Set((value ?? '').split(',').map(normalizeDomain).filter(Boolean)), + ); +} + +export function getOAuthAllowedDomains(provider?: OAuthProvider) { + const domains = parseOAuthAllowedDomains(process.env.OAUTH_ALLOWED_DOMAINS); + if (domains.length > 0) { + return domains; + } + + if (provider === 'google') { + return parseOAuthAllowedDomains( + process.env.GOOGLE_ALLOWED_DOMAINS ?? process.env.GOOGLE_ALLOWED_DOMAIN, + ); + } + + return []; +} + +export function getEmailDomain(email: string) { + const atIndex = email.lastIndexOf('@'); + if (atIndex === -1 || atIndex === email.length - 1) { + return null; + } + return normalizeDomain(email.slice(atIndex + 1)); +} + +/** + * When an allowlist is set, OAuth users must match by verified email domain. + * Google additionally requires the ID token `hd` (Workspace hosted domain) claim. + */ +export function isOAuthUserAllowedByDomain( + input: OAuthDomainCheckInput, + allowedDomains = getOAuthAllowedDomains(input.provider), +) { + if (allowedDomains.length === 0) { + return true; + } + + const emailDomain = getEmailDomain(input.email); + if (!(emailDomain && allowedDomains.includes(emailDomain))) { + return false; + } + + if (input.provider === 'google') { + const hostedDomain = input.hostedDomain + ? normalizeDomain(input.hostedDomain) + : null; + return !!hostedDomain && allowedDomains.includes(hostedDomain); + } + + return true; +} diff --git a/self-hosting/.env.template b/self-hosting/.env.template index 22044261f..159b4cadd 100644 --- a/self-hosting/.env.template +++ b/self-hosting/.env.template @@ -4,6 +4,8 @@ BATCH_SIZE="5000" BATCH_INTERVAL="10000" ALLOW_REGISTRATION="false" ALLOW_INVITATION="true" +# Optional: comma-separated OAuth email domains allowed to sign in/sign up. +# OAUTH_ALLOWED_DOMAINS="example.com" # Will be replaced with the setup script REDIS_URL="$REDIS_URL" CLICKHOUSE_URL="$CLICKHOUSE_URL" From 3aeca794629e5c39648ce72bf4893c4f337986df Mon Sep 17 00:00:00 2001 From: grootbro Date: Tue, 8 Sep 2026 01:39:52 +0700 Subject: [PATCH 2/2] fix(auth): fall back to GOOGLE_ALLOWED_DOMAIN when plural is empty Empty GOOGLE_ALLOWED_DOMAINS must not wipe the legacy singular env and open Google OAuth to everyone. Also clear allowlist env in the unrestricted test. --- packages/auth/src/oauth-allowed-domains.test.ts | 11 +++++++++++ packages/auth/src/oauth-allowed-domains.ts | 7 +++++-- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/packages/auth/src/oauth-allowed-domains.test.ts b/packages/auth/src/oauth-allowed-domains.test.ts index 84b327dba..f7d2c4a36 100644 --- a/packages/auth/src/oauth-allowed-domains.test.ts +++ b/packages/auth/src/oauth-allowed-domains.test.ts @@ -31,12 +31,23 @@ describe('oauth allowed domains', () => { expect(getOAuthAllowedDomains('github')).toEqual([]); }); + it('falls back to GOOGLE_ALLOWED_DOMAIN when GOOGLE_ALLOWED_DOMAINS is empty', () => { + vi.stubEnv('GOOGLE_ALLOWED_DOMAINS', ''); + vi.stubEnv('GOOGLE_ALLOWED_DOMAIN', 'legacy.example'); + + expect(getOAuthAllowedDomains('google')).toEqual(['legacy.example']); + }); + it('extracts email domains case-insensitively', () => { expect(getEmailDomain('User@Example.COM')).toBe('example.com'); expect(getEmailDomain('invalid-email')).toBeNull(); }); it('allows OAuth users when no allowlist is configured', () => { + vi.stubEnv('OAUTH_ALLOWED_DOMAINS', ''); + vi.stubEnv('GOOGLE_ALLOWED_DOMAINS', ''); + vi.stubEnv('GOOGLE_ALLOWED_DOMAIN', ''); + expect( isOAuthUserAllowedByDomain({ provider: 'github', diff --git a/packages/auth/src/oauth-allowed-domains.ts b/packages/auth/src/oauth-allowed-domains.ts index 38642c719..7f80cdc76 100644 --- a/packages/auth/src/oauth-allowed-domains.ts +++ b/packages/auth/src/oauth-allowed-domains.ts @@ -23,9 +23,12 @@ export function getOAuthAllowedDomains(provider?: OAuthProvider) { } if (provider === 'google') { - return parseOAuthAllowedDomains( - process.env.GOOGLE_ALLOWED_DOMAINS ?? process.env.GOOGLE_ALLOWED_DOMAIN, + const googleDomains = parseOAuthAllowedDomains( + process.env.GOOGLE_ALLOWED_DOMAINS, ); + return googleDomains.length > 0 + ? googleDomains + : parseOAuthAllowedDomains(process.env.GOOGLE_ALLOWED_DOMAIN); } return [];