diff --git a/apps/public/content/docs/self-hosting/environment-variables.mdx b/apps/public/content/docs/self-hosting/environment-variables.mdx index bb4442ebf..d75c7cfac 100644 --- a/apps/public/content/docs/self-hosting/environment-variables.mdx +++ b/apps/public/content/docs/self-hosting/environment-variables.mdx @@ -508,6 +508,112 @@ If `SMTP_HOST` is set and an SMTP send attempt fails, the system will **not** au ## OAuth & Integrations +Social login (GitHub / Google) is **off by default** for self-hosted installs. Set the provider credentials below to show the matching button on `/login` and `/onboarding`. Leave them unset (or set `DISABLE_*_AUTH`) to hide a provider. + +### GITHUB_CLIENT_ID + +**Type**: `string` +**Required**: No (required to enable GitHub login) +**Default**: None + +OAuth App client ID from [GitHub Developer Settings](https://github.com/settings/developers). + +**Example**: +```bash +GITHUB_CLIENT_ID=Ov23liABCDEFG +``` + +### GITHUB_CLIENT_SECRET + +**Type**: `string` +**Required**: No (required to enable GitHub login) +**Default**: None + +OAuth App client secret from GitHub. + +**Example**: +```bash +GITHUB_CLIENT_SECRET=your-github-client-secret +``` + +### GITHUB_REDIRECT_URI + +**Type**: `string` +**Required**: No (required to enable GitHub login) +**Default**: None + +Must match the **Authorization callback URL** configured on the GitHub OAuth App. Point it at your API host: + +**Example**: +```bash +GITHUB_REDIRECT_URI=https://api.example.com/oauth/github/callback +``` + +### DISABLE_GITHUB_AUTH + +**Type**: `boolean` +**Required**: No +**Default**: `false` + +Set to `true` or `1` to hide GitHub login even when GitHub credentials are set. + +**Example**: +```bash +DISABLE_GITHUB_AUTH=true +``` + +### GOOGLE_CLIENT_ID + +**Type**: `string` +**Required**: No (required to enable Google login) +**Default**: None + +OAuth 2.0 Client ID from [Google Cloud Console](https://console.cloud.google.com/apis/credentials). The same client can also be used for Google Search Console integration. + +**Example**: +```bash +GOOGLE_CLIENT_ID=1234567890-abcdefg.apps.googleusercontent.com +``` + +### GOOGLE_CLIENT_SECRET + +**Type**: `string` +**Required**: No (required to enable Google login) +**Default**: None + +OAuth 2.0 Client secret from Google Cloud Console. + +**Example**: +```bash +GOOGLE_CLIENT_SECRET=your-google-client-secret +``` + +### GOOGLE_REDIRECT_URI + +**Type**: `string` +**Required**: No (required to enable Google login) +**Default**: None + +Must match an authorized redirect URI on the Google OAuth client. Point it at your API host: + +**Example**: +```bash +GOOGLE_REDIRECT_URI=https://api.example.com/oauth/google/callback +``` + +### DISABLE_GOOGLE_AUTH + +**Type**: `boolean` +**Required**: No +**Default**: `false` + +Set to `true` or `1` to hide Google login while keeping `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` available for Search Console (`GSC_GOOGLE_REDIRECT_URI`). + +**Example**: +```bash +DISABLE_GOOGLE_AUTH=true +``` + ### SLACK_CLIENT_ID **Type**: `string` diff --git a/apps/public/content/docs/self-hosting/self-hosting.mdx b/apps/public/content/docs/self-hosting/self-hosting.mdx index c0f06671a..e8a3b4322 100644 --- a/apps/public/content/docs/self-hosting/self-hosting.mdx +++ b/apps/public/content/docs/self-hosting/self-hosting.mdx @@ -195,6 +195,49 @@ Invitations are enabled by default. You can also disable invitations by setting ALLOW_INVITATION=false ``` +### Social login (GitHub / Google) + +Email/password works without extra setup. GitHub and Google buttons appear only when their OAuth credentials are configured. + +#### GitHub + +1. Open [GitHub → Settings → Developer settings → OAuth Apps](https://github.com/settings/developers) and create a new OAuth App. +2. Set **Homepage URL** to your dashboard URL (e.g. `https://analytics.example.com`). +3. Set **Authorization callback URL** to your API callback: + +```text +https://api.example.com/oauth/github/callback +``` + +4. Copy the Client ID and generate a Client Secret, then set: + +```bash title=".env" +GITHUB_CLIENT_ID=… +GITHUB_CLIENT_SECRET=… +GITHUB_REDIRECT_URI=https://api.example.com/oauth/github/callback +``` + +To hide GitHub login while keeping credentials around, set `DISABLE_GITHUB_AUTH=true`. + +#### Google + +1. In [Google Cloud Console → Credentials](https://console.cloud.google.com/apis/credentials), create an OAuth 2.0 Client ID (Web application). +2. Add an authorized redirect URI: + +```text +https://api.example.com/oauth/google/callback +``` + +3. Set: + +```bash title=".env" +GOOGLE_CLIENT_ID=… +GOOGLE_CLIENT_SECRET=… +GOOGLE_REDIRECT_URI=https://api.example.com/oauth/google/callback +``` + +If you use the same Google client for Search Console but do not want Google on the login page, set `DISABLE_GOOGLE_AUTH=true`. + For a complete reference of all environment variables, see the [Environment Variables documentation](/docs/self-hosting/environment-variables). ## Helpful scripts diff --git a/apps/start/src/hooks/use-oauth-providers.ts b/apps/start/src/hooks/use-oauth-providers.ts new file mode 100644 index 000000000..8be155017 --- /dev/null +++ b/apps/start/src/hooks/use-oauth-providers.ts @@ -0,0 +1,18 @@ +import { useQuery } from '@tanstack/react-query'; +import { useTRPC } from '@/integrations/trpc/react'; + +const DISABLED = { github: false, google: false } as const; + +/** Which social login buttons the server currently offers. */ +export function useOAuthProviders() { + const trpc = useTRPC(); + const query = useQuery(trpc.auth.getOAuthProviders.queryOptions()); + + return { + ...query, + providers: query.data ?? DISABLED, + hasAny: + query.data !== undefined && + (query.data.github || query.data.google), + }; +} diff --git a/apps/start/src/routes/_login.login.tsx b/apps/start/src/routes/_login.login.tsx index ef36777ed..a9a4b9218 100644 --- a/apps/start/src/routes/_login.login.tsx +++ b/apps/start/src/routes/_login.login.tsx @@ -7,6 +7,7 @@ import { SignInGithub } from '@/components/auth/sign-in-github'; import { SignInGoogle } from '@/components/auth/sign-in-google'; import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { useCookieStore } from '@/hooks/use-cookie-store'; +import { useOAuthProviders } from '@/hooks/use-oauth-providers'; import { createTitle, PAGE_TITLES } from '@/utils/title'; export const Route = createFileRoute('/_login/login')({ @@ -22,14 +23,20 @@ export const Route = createFileRoute('/_login/login')({ correlationId: z.string().optional(), inviteId: z.string().optional(), }), + loader: async ({ context }) => { + await context.queryClient.ensureQueryData( + context.trpc.auth.getOAuthProviders.queryOptions(), + ); + }, }); function LoginPage() { const { error, correlationId, inviteId } = Route.useSearch(); const [lastProvider] = useCookieStore( 'last-auth-provider', - null + null, ); + const { providers, hasAny } = useOAuthProviders(); return (
@@ -72,19 +79,25 @@ function LoginPage() { )} -
- - -
- + {hasAny && ( +
+ {providers.google && ( + + )} + {providers.github && ( + + )} +
+ )} + {hasAny && }
); diff --git a/apps/start/src/routes/_public.onboarding.tsx b/apps/start/src/routes/_public.onboarding.tsx index e854db824..5a62982d4 100644 --- a/apps/start/src/routes/_public.onboarding.tsx +++ b/apps/start/src/routes/_public.onboarding.tsx @@ -7,6 +7,7 @@ import { SignInGithub } from '@/components/auth/sign-in-github'; import { SignInGoogle } from '@/components/auth/sign-in-google'; import { SignUpEmailForm } from '@/components/auth/sign-up-email-form'; import FullPageLoadingState from '@/components/full-page-loading-state'; +import { useOAuthProviders } from '@/hooks/use-oauth-providers'; import { useTRPC } from '@/integrations/trpc/react'; import { createEntityTitle, PAGE_TITLES } from '@/utils/title'; @@ -28,12 +29,15 @@ export const Route = createFileRoute('/_public/onboarding')({ component: Component, validateSearch, loader: async ({ context, location }) => { + await context.queryClient.ensureQueryData( + context.trpc.auth.getOAuthProviders.queryOptions(), + ); const search = validateSearch.safeParse(location.search); if (search.success && search.data.inviteId) { await context.queryClient.prefetchQuery( context.trpc.organization.getInvite.queryOptions({ inviteId: search.data.inviteId, - }) + }), ); } }, @@ -43,6 +47,7 @@ export const Route = createFileRoute('/_public/onboarding')({ function Component() { const { inviteId } = Route.useSearch(); const trpc = useTRPC(); + const { providers, hasAny } = useOAuthProviders(); const { data: invite } = useQuery( trpc.organization.getInvite.queryOptions( { @@ -50,8 +55,8 @@ function Component() { }, { enabled: !!inviteId, - } - ) + }, + ), ); return (
@@ -119,15 +124,23 @@ function Component() { )}
-
- - -
-

- No credit card required · Free 30-day trial · Cancel anytime -

+ {hasAny && ( + <> +
+ {providers.github && ( + + )} + {providers.google && ( + + )} +
+

+ No credit card required · Free 30-day trial · Cancel anytime +

- + + + )}
diff --git a/packages/auth/src/index.ts b/packages/auth/src/index.ts index 1f170515a..91157b064 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-providers'; export * from './password'; export * from './session'; export * from './totp'; diff --git a/packages/auth/src/oauth-providers.test.ts b/packages/auth/src/oauth-providers.test.ts new file mode 100644 index 000000000..33bd5f008 --- /dev/null +++ b/packages/auth/src/oauth-providers.test.ts @@ -0,0 +1,94 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { + getEnabledOAuthLoginProviders, + isOAuthLoginEnabled, +} from './oauth-providers'; + +const ORIGINAL_ENV = { ...process.env }; + +beforeEach(() => { + process.env = { ...ORIGINAL_ENV }; + for (const key of [ + 'GITHUB_CLIENT_ID', + 'GITHUB_CLIENT_SECRET', + 'GITHUB_REDIRECT_URI', + 'GOOGLE_CLIENT_ID', + 'GOOGLE_CLIENT_SECRET', + 'GOOGLE_REDIRECT_URI', + 'DISABLE_GITHUB_AUTH', + 'DISABLE_GOOGLE_AUTH', + ]) { + delete process.env[key]; + } +}); + +afterEach(() => { + process.env = { ...ORIGINAL_ENV }; +}); + +function enableGithub() { + process.env.GITHUB_CLIENT_ID = 'gh-id'; + process.env.GITHUB_CLIENT_SECRET = 'gh-secret'; + process.env.GITHUB_REDIRECT_URI = + 'https://api.example.com/oauth/github/callback'; +} + +function enableGoogle() { + process.env.GOOGLE_CLIENT_ID = 'go-id'; + process.env.GOOGLE_CLIENT_SECRET = 'go-secret'; + process.env.GOOGLE_REDIRECT_URI = + 'https://api.example.com/oauth/google/callback'; +} + +describe('isOAuthLoginEnabled', () => { + it('is false when provider credentials are unset', () => { + expect(isOAuthLoginEnabled('github')).toBe(false); + expect(isOAuthLoginEnabled('google')).toBe(false); + }); + + it('is true when github client id, secret, and redirect uri are set', () => { + enableGithub(); + expect(isOAuthLoginEnabled('github')).toBe(true); + }); + + it('is true when google client id, secret, and redirect uri are set', () => { + enableGoogle(); + expect(isOAuthLoginEnabled('google')).toBe(true); + }); + + it('is false when any required github credential is missing', () => { + enableGithub(); + delete process.env.GITHUB_CLIENT_SECRET; + expect(isOAuthLoginEnabled('github')).toBe(false); + }); + + it('is false when DISABLE_GITHUB_AUTH is true even with credentials', () => { + enableGithub(); + process.env.DISABLE_GITHUB_AUTH = 'true'; + expect(isOAuthLoginEnabled('github')).toBe(false); + }); + + it('is false when DISABLE_GOOGLE_AUTH is 1 even with credentials', () => { + enableGoogle(); + process.env.DISABLE_GOOGLE_AUTH = '1'; + expect(isOAuthLoginEnabled('google')).toBe(false); + }); + + it('treats blank credential strings as unset', () => { + process.env.GITHUB_CLIENT_ID = ' '; + process.env.GITHUB_CLIENT_SECRET = 'gh-secret'; + process.env.GITHUB_REDIRECT_URI = + 'https://api.example.com/oauth/github/callback'; + expect(isOAuthLoginEnabled('github')).toBe(false); + }); +}); + +describe('getEnabledOAuthLoginProviders', () => { + it('reports each provider independently', () => { + enableGithub(); + expect(getEnabledOAuthLoginProviders()).toEqual({ + github: true, + google: false, + }); + }); +}); diff --git a/packages/auth/src/oauth-providers.ts b/packages/auth/src/oauth-providers.ts new file mode 100644 index 000000000..1a8c4f09e --- /dev/null +++ b/packages/auth/src/oauth-providers.ts @@ -0,0 +1,48 @@ +export type OAuthLoginProvider = 'google' | 'github'; + +function isDisabledFlag(value: string | undefined): boolean { + return value === 'true' || value === '1'; +} + +function hasCredential(value: string | undefined): boolean { + return Boolean(value?.trim()); +} + +/** + * Whether the login/signup UI (and signInOAuth) should offer a social provider. + * + * Enabled when client id, secret, and redirect URI are set. Self-hosters leave + * them unset to hide a provider. Use DISABLE_*_AUTH to hide login while keeping + * the same Google credentials for Search Console. + */ +export function isOAuthLoginEnabled(provider: OAuthLoginProvider): boolean { + if (provider === 'github') { + if (isDisabledFlag(process.env.DISABLE_GITHUB_AUTH)) { + return false; + } + return ( + hasCredential(process.env.GITHUB_CLIENT_ID) && + hasCredential(process.env.GITHUB_CLIENT_SECRET) && + hasCredential(process.env.GITHUB_REDIRECT_URI) + ); + } + + if (isDisabledFlag(process.env.DISABLE_GOOGLE_AUTH)) { + return false; + } + return ( + hasCredential(process.env.GOOGLE_CLIENT_ID) && + hasCredential(process.env.GOOGLE_CLIENT_SECRET) && + hasCredential(process.env.GOOGLE_REDIRECT_URI) + ); +} + +export function getEnabledOAuthLoginProviders(): Record< + OAuthLoginProvider, + boolean +> { + return { + github: isOAuthLoginEnabled('github'), + google: isOAuthLoginEnabled('google'), + }; +} diff --git a/packages/trpc/src/routers/auth.ts b/packages/trpc/src/routers/auth.ts index 5f7dec314..41b3918df 100644 --- a/packages/trpc/src/routers/auth.ts +++ b/packages/trpc/src/routers/auth.ts @@ -9,11 +9,13 @@ import { generateRecoveryCodes, generateSessionToken, generateTotpSecret, + getEnabledOAuthLoginProviders, github, google, hashPassword, hashRecoveryCodes, invalidateSession, + isOAuthLoginEnabled, setLastAuthProviderCookie, setSessionTokenCookie, validateSessionToken, @@ -41,7 +43,11 @@ import { zTotpOrRecoveryCode, } from '@openpanel/validation'; import { z } from 'zod'; -import { TRPCAccessError, TRPCNotFoundError } from '../errors'; +import { + TRPCAccessError, + TRPCBadRequestError, + TRPCNotFoundError, +} from '../errors'; import { createTRPCRouter, protectedProcedure, @@ -53,7 +59,7 @@ const TWO_FACTOR_COOKIE = '2fa_challenge'; const TWO_FACTOR_CHALLENGE_TTL_SECONDS = 5 * 60; const INVITE_COOKIE = 'inviteId'; -const zProvider = z.enum(['email', 'google', 'github']); +const zOAuthProvider = z.enum(['google', 'github']); /** * Best-effort consumption of an invite for a user that just authenticated. @@ -77,6 +83,7 @@ async function consumeInviteForUser( } export const authRouter = createTRPCRouter({ + getOAuthProviders: publicProcedure.query(() => getEnabledOAuthLoginProviders()), signOut: publicProcedure.mutation(async ({ ctx }) => { deleteSessionTokenCookie(ctx.setCookie); if (ctx.session?.session?.id) { @@ -84,7 +91,9 @@ export const authRouter = createTRPCRouter({ } }), signInOAuth: publicProcedure - .input(z.object({ provider: zProvider, inviteId: z.string().nullish() })) + .input( + z.object({ provider: zOAuthProvider, inviteId: z.string().nullish() }), + ) .mutation(async ({ input, ctx }) => { // NOTE: no registration check here. At this point we have no identity for // the caller — the IdP hasn't been hit yet — so we cannot tell a returning @@ -93,6 +102,12 @@ export const authRouter = createTRPCRouter({ // (`handleNewUser`), which is the only place we know the user is new. const { provider } = input; + if (!isOAuthLoginEnabled(provider)) { + throw new TRPCBadRequestError( + `${provider === 'github' ? 'GitHub' : 'Google'} sign-in is not enabled`, + ); + } + if (input.inviteId) { ctx.setCookie('inviteId', input.inviteId, { maxAge: 60 * 10,