diff --git a/apps/api/src/controllers/oauth-callback.controller.tsx b/apps/api/src/controllers/oauth-callback.controller.tsx index 46d789150..bec4f8b4b 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, @@ -46,6 +48,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 @@ -150,11 +174,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' + ); } } @@ -219,6 +246,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); @@ -238,6 +266,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, }; } @@ -302,6 +331,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: [ @@ -345,6 +375,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..b04fb81d4 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 this is set, OAuth sign-in can start even if `ALLOW_REGISTRATION=false`; the OAuth callback still rejects users whose verified email domain is not on the allowlist. + +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..380c59172 --- /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 hasOAuthAllowedDomains(provider?: OAuthProvider) { + return getOAuthAllowedDomains(provider).length > 0; +} + +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 1442d7630..0e4550585 100644 --- a/packages/trpc/src/routers/auth.ts +++ b/packages/trpc/src/routers/auth.ts @@ -13,6 +13,7 @@ import { google, hashPassword, hashRecoveryCodes, + hasOAuthAllowedDomains, invalidateSession, setLastAuthProviderCookie, setSessionTokenCookie, @@ -117,16 +118,19 @@ export const authRouter = createTRPCRouter({ signInOAuth: publicProcedure .input(z.object({ provider: zProvider, inviteId: z.string().nullish() })) .mutation(async ({ input, ctx }) => { + const { provider } = input; const isRegistrationAllowed = await getIsRegistrationAllowed( input.inviteId ); + const isDomainRestrictedOAuthAllowed = + provider === 'google' || provider === 'github' + ? hasOAuthAllowedDomains(provider) + : false; - if (!isRegistrationAllowed) { + if (!(isRegistrationAllowed || isDomainRestrictedOAuthAllowed)) { throw new TRPCAccessError('Registrations are not allowed'); } - const { provider } = input; - if (input.inviteId) { ctx.setCookie('inviteId', input.inviteId, { maxAge: 60 * 10, @@ -533,7 +537,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({ @@ -572,7 +576,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"