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
41 changes: 36 additions & 5 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 @@ -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
Expand Down Expand Up @@ -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'
);
}
}

Expand Down Expand Up @@ -219,6 +246,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 @@ -238,6 +266,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 Expand Up @@ -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: [
Expand Down Expand Up @@ -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: [
Expand Down
21 changes: 20 additions & 1 deletion apps/public/content/docs/self-hosting/environment-variables.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
```

<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.
Expand Down Expand Up @@ -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

### 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)

103 changes: 103 additions & 0 deletions packages/auth/oauth-allowed-domains.test.ts
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);
});
});
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';
67 changes: 67 additions & 0 deletions packages/auth/src/oauth-allowed-domains.ts
Original file line number Diff line number Diff line change
@@ -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;
}
14 changes: 9 additions & 5 deletions packages/trpc/src/routers/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
google,
hashPassword,
hashRecoveryCodes,
hasOAuthAllowedDomains,
invalidateSession,
setLastAuthProviderCookie,
setSessionTokenCookie,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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',
Expand Down
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