feat(auth): sign in with Google behind NEXT_PUBLIC_GOOGLE_AUTH_ENABLED (#1441)

Adds a 'Continue with Google' button to login and register, gated by
NEXT_PUBLIC_GOOGLE_AUTH_ENABLED so it ships dark until the Google
provider is configured in Supabase. The OAuth round-trip reuses the
existing /auth/callback PKCE exchange, which already owns MFA routing,
invite acceptance and silent-team creation. A flow=oauth marker on the
redirect lets the callback tag failures (including provider consent
denials) so the login page shows Google-specific error copy instead of
the email-confirmation framing.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-08-06 21:26:09 +02:00
committed by GitHub
co-authored by Jakob Wennberg Claude Fable 5
parent 0bb0b89353
commit 28b58aedc4
9 changed files with 186 additions and 11 deletions
+6
View File
@@ -37,6 +37,12 @@ CRON_SECRET=generate-a-random-secret
# Hosted keeps this unset: public signup stays open there.
# AUTH_SIGNUPS_DISABLED=false
# Sign in with Google. Requires the Google provider to be configured in
# Supabase/GoTrue first (Google Cloud OAuth client + redirect URI):
# https://supabase.com/docs/guides/auth/social-login/auth-google
# The button stays hidden until this is true.
# NEXT_PUBLIC_GOOGLE_AUTH_ENABLED=true
# ── Optional: extension features (core runs without these) ─
# AI features: Claude via AWS Bedrock (document extraction + AI assistant).
# Needs an AWS account with Bedrock model access to Claude. Plain
@@ -84,6 +84,34 @@ describe('GET /auth/callback: recovery flow', () => {
'http://localhost:3000/login?error=auth_error&flow=signup'
)
})
it('tags a failed OAuth code exchange (flow=oauth marker) with flow=oauth', async () => {
exchangeCodeForSession.mockResolvedValue({
error: { message: 'code verifier missing' },
})
const request = new NextRequest('http://localhost:3000/auth/callback?code=xyz&flow=oauth')
const response = await GET(request)
expect(response.status).toBe(307)
expect(response.headers.get('location')).toBe(
'http://localhost:3000/login?error=auth_error&flow=oauth'
)
})
it('tags a provider denial (no code, only ?error from the provider) with flow=oauth', async () => {
const request = new NextRequest(
'http://localhost:3000/auth/callback?flow=oauth&error=access_denied'
)
const response = await GET(request)
expect(response.status).toBe(307)
expect(response.headers.get('location')).toBe(
'http://localhost:3000/login?error=auth_error&flow=oauth'
)
expect(exchangeCodeForSession).not.toHaveBeenCalled()
expect(verifyOtp).not.toHaveBeenCalled()
})
})
describe('GET /auth/callback: admin invite flow (type=invite)', () => {
+8 -2
View File
@@ -239,10 +239,16 @@ export async function GET(request: NextRequest) {
// flow hint so the login page can show the right copy: a failed signup
// confirmation must not be framed as a failed password reset. On the PKCE
// (?code=) path there is no `type`, so recovery is identified by the
// next=/reset-password marker that resetPasswordForEmail sets; everything
// next=/reset-password marker that resetPasswordForEmail sets, and OAuth
// by the flow=oauth marker that GoogleAuthButton puts in redirectTo
// (provider denials arrive here with ?error and no code); everything
// else defaults to the signup/confirmation framing.
const failedFlow =
type === 'recovery' || next === '/reset-password' ? 'recovery' : 'signup'
searchParams.get('flow') === 'oauth'
? 'oauth'
: type === 'recovery' || next === '/reset-password'
? 'recovery'
: 'signup'
const loginUrl = new URL('/login', origin)
loginUrl.searchParams.set('error', 'auth_error')
loginUrl.searchParams.set('flow', failedFlow)
+23 -4
View File
@@ -23,6 +23,8 @@ import {
} from '@/lib/auth/consume-invite-cookie'
import { AuthPageSkeleton } from '@/components/auth/AuthPageSkeleton'
import { AuthFormError } from '@/components/auth/AuthFormError'
import { GoogleAuthButton } from '@/components/auth/GoogleAuthButton'
import { isGoogleAuthEnabled } from '@/lib/auth/google-oauth'
import { classifyAuthError, type AuthErrorKind } from '@/lib/auth/classify-auth-error'
import { resetAnalyticsIdentity } from '@/lib/analytics/reset'
import {
@@ -61,7 +63,7 @@ function LoginPageContent() {
const [bankIdNoAccount, setBankIdNoAccount] = useState<{ givenName?: string; surname?: string } | null>(null)
// Auth failures render inline next to the form (see AuthFormError), never
// as a toast: `kind` drives field highlighting and the recovery action.
const [formError, setFormError] = useState<{ kind: AuthErrorKind | 'bankid'; message: string } | null>(null)
const [formError, setFormError] = useState<{ kind: AuthErrorKind | 'bankid' | 'oauth'; message: string } | null>(null)
const passwordInputRef = useRef<HTMLInputElement>(null)
const { toast } = useToast()
const router = useRouter()
@@ -79,6 +81,7 @@ function LoginPageContent() {
const nextPath = safeReturnTo(searchParams.get('next'), '/')
const supabase = createClient()
const bankIdEnabled = isBankIdEnabled()
const googleAuthEnabled = isGoogleAuthEnabled()
const tAuth = useTranslations('auth')
const tCommon = useTranslations('common')
const tInvite = useTranslations('invite')
@@ -470,7 +473,16 @@ function LoginPageContent() {
)}
{callbackError === 'auth_error' && (
<div className="mb-5 rounded-lg border border-destructive/30 bg-destructive/5 p-4">
{callbackFlow === 'recovery' ? (
{callbackFlow === 'oauth' ? (
<>
<p className="text-sm font-medium text-destructive">
{tAuth('callback_error_title_oauth')}
</p>
<p className="mt-1 text-sm text-destructive/90">
{tAuth('callback_error_body_oauth')}
</p>
</>
) : callbackFlow === 'recovery' ? (
<>
<p className="text-sm font-medium text-destructive">
{tAuth('callback_error_title')}
@@ -499,9 +511,9 @@ function LoginPageContent() {
)}
</div>
)}
{bankIdEnabled && (
{(bankIdEnabled || googleAuthEnabled) && (
<>
{bankIdNoAccount ? (
{bankIdEnabled && (bankIdNoAccount ? (
<div className="mb-5 rounded-lg border border-amber-200 bg-amber-50 p-4 dark:border-amber-900 dark:bg-amber-950/30">
<p className="text-sm font-medium text-amber-800 dark:text-amber-200">
{tAuth('bankid_no_account_greeting', { name: bankIdNoAccount.givenName ?? '' })}
@@ -522,6 +534,13 @@ function LoginPageContent() {
<div className="mb-5">
<BankIdAuth mode="login" onComplete={handleBankIdComplete} />
</div>
))}
{googleAuthEnabled && !(isBankIdReauth && !showPasswordLogin) && (
<div className="mb-5">
<GoogleAuthButton
onError={(message) => setFormError({ kind: 'oauth', message })}
/>
</div>
)}
{isBankIdReauth && !showPasswordLogin ? (
<Button
+17 -5
View File
@@ -23,6 +23,8 @@ import {
} from '@/lib/auth/consume-invite-cookie'
import { AuthPageSkeleton } from '@/components/auth/AuthPageSkeleton'
import { AuthFormError } from '@/components/auth/AuthFormError'
import { GoogleAuthButton } from '@/components/auth/GoogleAuthButton'
import { isGoogleAuthEnabled } from '@/lib/auth/google-oauth'
import { classifyAuthError, type AuthErrorKind } from '@/lib/auth/classify-auth-error'
import { cn } from '@/lib/utils'
@@ -67,7 +69,7 @@ function RegisterPageContent() {
// Signup failures render inline next to the form (see AuthFormError), never
// as a toast. Field-level problems attach to their field; everything else
// goes to the form-level alert above the form.
const [formError, setFormError] = useState<{ kind: AuthErrorKind | 'bankid'; message: string } | null>(null)
const [formError, setFormError] = useState<{ kind: AuthErrorKind | 'bankid' | 'oauth'; message: string } | null>(null)
const [passwordError, setPasswordError] = useState<string | null>(null)
const [confirmError, setConfirmError] = useState<string | null>(null)
const passwordInputRef = useRef<HTMLInputElement>(null)
@@ -76,6 +78,7 @@ function RegisterPageContent() {
const router = useRouter()
const supabase = createClient()
const bankIdEnabled = isBankIdEnabled()
const googleAuthEnabled = isGoogleAuthEnabled()
const t = useTranslations('register')
const tInvite = useTranslations('invite')
const errorLocale = useLocale() as ErrorLocale
@@ -459,11 +462,20 @@ function RegisterPageContent() {
</div>
<div className="rounded-lg border bg-card p-6">
{bankIdEnabled && !bankIdUser && (
{(bankIdEnabled || googleAuthEnabled) && !bankIdUser && (
<>
<div className="mb-5">
<BankIdAuth mode="signup" onComplete={handleBankIdComplete} />
</div>
{bankIdEnabled && (
<div className="mb-5">
<BankIdAuth mode="signup" onComplete={handleBankIdComplete} />
</div>
)}
{googleAuthEnabled && (
<div className="mb-5">
<GoogleAuthButton
onError={(message) => setFormError({ kind: 'oauth', message })}
/>
</div>
)}
<div className="relative mb-5">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t" />
+85
View File
@@ -0,0 +1,85 @@
'use client'
import { useState } from 'react'
import { useLocale, useTranslations } from 'next-intl'
import { createClient } from '@/lib/supabase/client'
import { Button } from '@/components/ui/button'
import { Loader2 } from 'lucide-react'
import { getErrorMessage, type ErrorLocale } from '@/lib/errors/get-error-message'
/** Official Google "G" mark, drawn inline (no external assets on auth pages). */
function GoogleMark() {
return (
<svg viewBox="0 0 48 48" aria-hidden className="h-4 w-4">
<path
fill="#EA4335"
d="M24 9.5c3.54 0 6.71 1.22 9.21 3.6l6.85-6.85C35.9 2.38 30.47 0 24 0 14.62 0 6.51 5.38 2.56 13.22l7.98 6.19C12.43 13.72 17.74 9.5 24 9.5z"
/>
<path
fill="#4285F4"
d="M46.98 24.55c0-1.57-.15-3.09-.38-4.55H24v9.02h12.94c-.58 2.96-2.26 5.48-4.78 7.18l7.73 6c4.51-4.18 7.09-10.36 7.09-17.65z"
/>
<path
fill="#FBBC05"
d="M10.53 28.59c-.48-1.45-.76-2.99-.76-4.59s.27-3.14.76-4.59l-7.98-6.19C.92 16.46 0 20.12 0 24c0 3.88.92 7.54 2.56 10.78l7.97-6.19z"
/>
<path
fill="#34A853"
d="M24 48c6.48 0 11.93-2.13 15.89-5.81l-7.73-6c-2.15 1.45-4.92 2.3-8.16 2.3-6.26 0-11.57-4.22-13.47-9.91l-7.98 6.19C6.51 42.62 14.62 48 24 48z"
/>
</svg>
)
}
/**
* "Continue with Google" for the login and register pages.
*
* Kicks off the Supabase OAuth redirect; the round-trip lands in
* /auth/callback (PKCE code exchange), which owns MFA routing, invite
* acceptance and silent-team creation for OAuth sign-ins and sign-ups alike.
* The flow=oauth marker lets the callback tag failures so the login page
* shows Google-specific copy instead of the email-confirmation framing.
*/
export function GoogleAuthButton({ onError }: { onError: (message: string) => void }) {
const [isRedirecting, setIsRedirecting] = useState(false)
const supabase = createClient()
const tAuth = useTranslations('auth')
const errorLocale = useLocale() as ErrorLocale
const handleClick = async () => {
setIsRedirecting(true)
try {
const { error } = await supabase.auth.signInWithOAuth({
provider: 'google',
options: {
redirectTo: `${window.location.origin}/auth/callback?flow=oauth`,
},
})
if (error) {
onError(getErrorMessage(error, { context: 'auth', locale: errorLocale }))
setIsRedirecting(false)
}
// On success the browser navigates away; keep the spinner until then.
} catch (error) {
onError(getErrorMessage(error, { context: 'auth', locale: errorLocale }))
setIsRedirecting(false)
}
}
return (
<Button
type="button"
variant="outline"
className="w-full h-11"
onClick={handleClick}
disabled={isRedirecting}
>
{isRedirecting ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<span className="mr-2 flex items-center"><GoogleMark /></span>
)}
{tAuth('continue_with_google')}
</Button>
)
}
+13
View File
@@ -0,0 +1,13 @@
/**
* Google OAuth feature flag.
*
* Signing in with Google requires the Google provider to be configured in
* Supabase (GoTrue) with a Google Cloud OAuth client; the flag ships the UI
* dark until that is done:
* https://supabase.com/docs/guides/auth/social-login/auth-google
* Unlike BankID this is not hosted-only: self-hosted installations can
* configure their own Google OAuth client.
*/
export function isGoogleAuthEnabled(): boolean {
return process.env.NEXT_PUBLIC_GOOGLE_AUTH_ENABLED === 'true'
}
+3
View File
@@ -274,6 +274,9 @@
"login_error_rate_limited": "Too many sign-in attempts. Wait a moment and try again.",
"login_error_user_banned": "This account has been suspended. Contact support if you think this is a mistake.",
"login_error_reset_link": "Reset your password",
"continue_with_google": "Continue with Google",
"callback_error_title_oauth": "Google sign-in didn't work",
"callback_error_body_oauth": "The Google sign-in could not be completed. Try again, or sign in with email and password.",
"session_idle": "You were inactive. Sign in again.",
"session_absolute": "Your session expired for security reasons.",
"use_password_instead": "Sign in with email instead",
+3
View File
@@ -274,6 +274,9 @@
"login_error_rate_limited": "För många inloggningsförsök. Vänta en stund och försök igen.",
"login_error_user_banned": "Kontot är avstängt. Kontakta supporten om du tror att det är fel.",
"login_error_reset_link": "Återställ lösenordet",
"continue_with_google": "Fortsätt med Google",
"callback_error_title_oauth": "Google-inloggningen fungerade inte",
"callback_error_body_oauth": "Inloggningen med Google kunde inte slutföras. Försök igen, eller logga in med e-post och lösenord.",
"session_idle": "Du har varit inaktiv. Logga in igen.",
"session_absolute": "Sessionen har upphört av säkerhetsskäl.",
"use_password_instead": "Logga in med e-post i stället",