diff --git a/apps/api/src/controllers/oauth-callback.controller.tsx b/apps/api/src/controllers/oauth-callback.controller.tsx index ff311a352..ac5a1cf5a 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 @@ -137,10 +161,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 @@ -169,11 +208,14 @@ async function handleNewUser({ try { await connectUserToOrganization({ user, inviteId }); } catch (error) { - reply.log.error({ - error, - inviteId, - user, - }, 'error connecting user to organization'); + reply.log.error( + { + error, + inviteId, + user, + }, + 'error connecting user to organization' + ); } } @@ -238,6 +280,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 +300,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, }; } @@ -321,6 +365,7 @@ export async function githubCallback(req: FastifyRequest, reply: FastifyReply) { const inviteId = req.cookies.inviteId; const tokens = await github.validateAuthorizationCode(code); const githubUser = await fetchGithubUser(tokens.accessToken()); + assertOAuthUserAllowed(githubUser, 'github'); const account = await db.account.findFirst({ where: { OR: [ @@ -364,6 +409,7 @@ export async function googleCallback(req: FastifyRequest, reply: FastifyReply) { const codeVerifier = req.cookies.google_code_verifier!; const tokens = await google.validateAuthorizationCode(code, codeVerifier); const googleUser = await fetchGoogleUser(tokens); + assertOAuthUserAllowed(googleUser, 'google'); const existingUser = await db.account.findFirst({ where: { OR: [ diff --git a/apps/public/content/docs/self-hosting/environment-variables.mdx b/apps/public/content/docs/self-hosting/environment-variables.mdx index 7d7d7faa2..e2302ac9f 100644 --- a/apps/public/content/docs/self-hosting/environment-variables.mdx +++ b/apps/public/content/docs/self-hosting/environment-variables.mdx @@ -283,6 +283,25 @@ 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 providers. When set, the OAuth callback rejects users whose verified email domain is not on the allowlist. Matching OAuth users can sign up even if `ALLOW_REGISTRATION=false`, while users outside the allowlist cannot sign in or create accounts. + +For Google OAuth, OpenPanel also validates the Google ID token hosted-domain (`hd`) claim against the same allowlist. + +**Example**: +```bash +OAUTH_ALLOWED_DOMAINS=example.com,example.org +``` + + +`OAUTH_ALLOWED_DOMAINS` applies to every OAuth provider. For Google-only restrictions, use `GOOGLE_ALLOWED_DOMAINS` or `GOOGLE_ALLOWED_DOMAIN` instead. + + ## 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. @@ -1154,6 +1173,7 @@ For a basic self-hosted installation, these variables are required: - `RESEND_API_KEY` or `SMTP_HOST` - For email features (pick one) - `EMAIL_SENDER` - Email sender address - `OPENAI_API_KEY` and/or `ANTHROPIC_API_KEY` - For the in-app AI chat assistant +- `OAUTH_ALLOWED_DOMAINS` - For domain-restricted OAuth sign-in ### See Also @@ -1161,4 +1181,3 @@ For a basic self-hosted installation, these variables are required: - [Deploy with Coolify](/docs/self-hosting/deploy-coolify) - [Deploy with Dokploy](/docs/self-hosting/deploy-dokploy) - [Deploy on Kubernetes](/docs/self-hosting/deploy-kubernetes) - diff --git a/packages/auth/oauth-allowed-domains.test.ts b/packages/auth/oauth-allowed-domains.test.ts new file mode 100644 index 000000000..cee75655b --- /dev/null +++ b/packages/auth/oauth-allowed-domains.test.ts @@ -0,0 +1,103 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + getEmailDomain, + getOAuthAllowedDomains, + isOAuthUserAllowedByDomain, + parseOAuthAllowedDomains, +} from './src/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/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.ts b/packages/auth/src/oauth-allowed-domains.ts new file mode 100644 index 000000000..52d76f6d0 --- /dev/null +++ b/packages/auth/src/oauth-allowed-domains.ts @@ -0,0 +1,63 @@ +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)); +} + +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/packages/trpc/src/routers/auth.ts b/packages/trpc/src/routers/auth.ts index e765bfdbb..79176055a 100644 --- a/packages/trpc/src/routers/auth.ts +++ b/packages/trpc/src/routers/auth.ts @@ -499,7 +499,7 @@ export const authRouter = createTRPCRouter({ windowMs: 60_000, }) ) - .mutation(async ({ input, ctx }) => { + .mutation(async ({ input }) => { const { token, password } = input; const resetPassword = await db.resetPassword.findUnique({ @@ -538,7 +538,7 @@ export const authRouter = createTRPCRouter({ }) ) .input(zRequestResetPassword) - .mutation(async ({ input, ctx }) => { + .mutation(async ({ input }) => { const user = await getUserAccount({ email: input.email, provider: 'email', 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"