-
Notifications
You must be signed in to change notification settings - Fork 446
Add OAuth allowed domain restrictions #428
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -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 | ||||||
| ``` | ||||||
|
|
||||||
| <Callout> | ||||||
| `OAUTH_ALLOWED_DOMAINS` applies to every OAuth provider. For Google-only restrictions, use `GOOGLE_ALLOWED_DOMAINS` or `GOOGLE_ALLOWED_DOMAIN` instead. | ||||||
| </Callout> | ||||||
|
|
||||||
| ## 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,11 +1173,11 @@ 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 | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win Include sign-up behavior in the quick reference.
Proposed documentation update-- `OAUTH_ALLOWED_DOMAINS` - For domain-restricted OAuth sign-in
+- `OAUTH_ALLOWED_DOMAINS` - For domain-restricted OAuth sign-in and sign-up📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||
|
|
||||||
| ### See Also | ||||||
|
|
||||||
| - [Deploy with Docker Compose](/docs/self-hosting/deploy-docker-compose) | ||||||
| - [Deploy with Coolify](/docs/self-hosting/deploy-coolify) | ||||||
| - [Deploy with Dokploy](/docs/self-hosting/deploy-dokploy) | ||||||
| - [Deploy on Kubernetes](/docs/self-hosting/deploy-kubernetes) | ||||||
|
|
||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,5 +1,6 @@ | ||
| export * from './cookie'; | ||
| export * from './oauth'; | ||
| export * from './oauth-allowed-domains'; | ||
| export * from './password'; | ||
| export * from './session'; | ||
| export * from './totp'; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win
Document allowlist precedence explicitly.
When
OAUTH_ALLOWED_DOMAINScontains any domain, it takes precedence overGOOGLE_ALLOWED_DOMAINSandGOOGLE_ALLOWED_DOMAIN. State this in the callout. Otherwise, an operator can configure a narrower Google-specific list and assume that it further restricts Google OAuth.Proposed documentation update
<Callout> `OAUTH_ALLOWED_DOMAINS` applies to every OAuth provider. For Google-only restrictions, use `GOOGLE_ALLOWED_DOMAINS` or `GOOGLE_ALLOWED_DOMAIN` instead. +When both global and Google-specific variables are set, `OAUTH_ALLOWED_DOMAINS` takes precedence. </Callout>📝 Committable suggestion
🤖 Prompt for AI Agents