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
53 changes: 49 additions & 4 deletions apps/api/src/controllers/oauth-callback.controller.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@ import {
Arctic,
createSession,
generateSessionToken,
getOAuthAllowedDomains,
github,
google,
isOAuthUserAllowedByDomain,
type OAuth2Tokens,
setLastAuthProviderCookie,
setSessionTokenCookie,
Expand Down Expand Up @@ -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
Expand All @@ -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);

Expand Down Expand Up @@ -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 },
});
Expand All @@ -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
Expand Down Expand Up @@ -238,6 +281,7 @@ async function fetchGoogleUser(tokens: OAuth2Tokens): Promise<OAuthUser> {
email_verified: z.boolean(),
given_name: z.string().optional(),
family_name: z.string().optional(),
hd: z.string().optional(),
});

const claimsResult = claimsSchema.safeParse(claims);
Expand All @@ -257,6 +301,7 @@ async function fetchGoogleUser(tokens: OAuth2Tokens): Promise<OAuthUser> {
email: claimsResult.data.email,
firstName: claimsResult.data.given_name || '',
lastName: claimsResult.data.family_name || '',
hostedDomain: claimsResult.data.hd,
};
}

Expand Down
34 changes: 34 additions & 0 deletions apps/public/content/docs/self-hosting/environment-variables.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
```

<Callout>
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.
</Callout>

### 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.
Expand Down
10 changes: 10 additions & 0 deletions apps/public/content/docs/self-hosting/self-hosting.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions packages/auth/src/index.ts
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';
114 changes: 114 additions & 0 deletions packages/auth/src/oauth-allowed-domains.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
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('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', () => {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
vi.stubEnv('OAUTH_ALLOWED_DOMAINS', '');
vi.stubEnv('GOOGLE_ALLOWED_DOMAINS', '');
vi.stubEnv('GOOGLE_ALLOWED_DOMAIN', '');

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);
});
});
70 changes: 70 additions & 0 deletions packages/auth/src/oauth-allowed-domains.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
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') {
const googleDomains = parseOAuthAllowedDomains(
process.env.GOOGLE_ALLOWED_DOMAINS,
);
return googleDomains.length > 0
? googleDomains
: parseOAuthAllowedDomains(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;
}
2 changes: 2 additions & 0 deletions self-hosting/.env.template
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down