Compare commits

...

6 Commits

Author SHA1 Message Date
Benjamin Lu
8cf0d779b9 fix: gate Google free-tier copy in webviews 2026-05-04 13:51:30 -07:00
Benjamin Lu
8e57bb1d7d fix: keep Google SSO blocked reason internal 2026-05-04 13:50:54 -07:00
Benjamin Lu
550ee93ba7 Merge branch 'main' into feat/disable-google-sso-embedded-webview 2026-05-04 13:35:59 -07:00
bymyself
92b24ae2a4 refactor: model SSO availability as named reason instead of boolean
Replace isWebView boolean with googleSsoBlockedReason of type
GoogleSsoBlockedReason ('embedded-webview' | null). Call sites now read
as 'if blocked, here's why' rather than an opaque flag, and the union
can be extended (e.g. 'unauthorized-host') as new conditions arise.

Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/10699#discussion_r3007520873
2026-05-03 01:34:00 -07:00
bymyself
daea25915a refactor: move webviewDetection to src/base
Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/10699#discussion_r3005799656
2026-05-03 01:30:41 -07:00
bymyself
e898a6e72f feat: hide Google SSO button in embedded webviews
Google blocks OAuth in embedded webviews with a 403
disallowed_useragent error. Add isEmbeddedWebView() utility
that detects Android WebView, iOS WKWebView, and social app
in-app browsers via UA pattern matching and JS bridge
detection.

Hide the Google SSO button in all 3 auth views when in a
webview context. GitHub SSO and email/password auth remain
unaffected.

- Fixes Comfy-Org/ComfyUI_frontend#7017
2026-03-28 23:28:42 -07:00
6 changed files with 217 additions and 6 deletions

View File

@@ -0,0 +1,119 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { isEmbeddedWebView } from '@/base/webviewDetection'
describe('isEmbeddedWebView', () => {
afterEach(() => {
vi.unstubAllGlobals()
})
describe('Android WebView', () => {
it('detects Android WebView with wv token', () => {
const ua =
'Mozilla/5.0 (Linux; Android 13; SM-G991B; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/120.0.0.0 Mobile Safari/537.36'
expect(isEmbeddedWebView(ua)).toBe(true)
})
it('does not flag regular Chrome on Android', () => {
const ua =
'Mozilla/5.0 (Linux; Android 13; SM-G991B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36'
expect(isEmbeddedWebView(ua)).toBe(false)
})
})
describe('iOS WKWebView', () => {
it('detects iOS WKWebView (AppleWebKit without Safari/)', () => {
const ua =
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148'
expect(isEmbeddedWebView(ua)).toBe(true)
})
it('does not flag regular Safari on iOS', () => {
const ua =
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1'
expect(isEmbeddedWebView(ua)).toBe(false)
})
it('does not flag Chrome on iOS (CriOS)', () => {
const ua =
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/120.0.0.0 Mobile/15E148'
expect(isEmbeddedWebView(ua)).toBe(false)
})
it('does not flag Firefox on iOS (FxiOS)', () => {
const ua =
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) FxiOS/120.0 Mobile/15E148'
expect(isEmbeddedWebView(ua)).toBe(false)
})
})
describe('social app in-app browsers', () => {
it('detects Facebook (FBAN)', () => {
const ua =
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 [FBAN/FBIOS;FBAV/400.0]'
expect(isEmbeddedWebView(ua)).toBe(true)
})
it('detects Instagram', () => {
const ua =
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 Instagram 300.0'
expect(isEmbeddedWebView(ua)).toBe(true)
})
it('detects TikTok', () => {
const ua =
'Mozilla/5.0 (Linux; Android 13) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36 TikTok/30.0'
expect(isEmbeddedWebView(ua)).toBe(true)
})
it('detects Line', () => {
const ua =
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 Line/13.0'
expect(isEmbeddedWebView(ua)).toBe(true)
})
it('detects Snapchat', () => {
const ua =
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 Snapchat/12.0'
expect(isEmbeddedWebView(ua)).toBe(true)
})
})
describe('regular desktop browsers', () => {
it('does not flag Chrome desktop', () => {
const ua =
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
expect(isEmbeddedWebView(ua)).toBe(false)
})
it('does not flag Firefox desktop', () => {
const ua =
'Mozilla/5.0 (X11; Linux x86_64; rv:120.0) Gecko/20100101 Firefox/120.0'
expect(isEmbeddedWebView(ua)).toBe(false)
})
it('does not flag Safari desktop', () => {
const ua =
'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15'
expect(isEmbeddedWebView(ua)).toBe(false)
})
})
describe('edge cases', () => {
it('handles empty string', () => {
expect(isEmbeddedWebView('')).toBe(false)
})
})
describe('JS bridge detection', () => {
it('detects webkit.messageHandlers bridge', () => {
vi.stubGlobal('webkit', { messageHandlers: {} })
expect(isEmbeddedWebView('')).toBe(true)
})
it('detects ReactNativeWebView bridge', () => {
vi.stubGlobal('ReactNativeWebView', { postMessage: vi.fn() })
expect(isEmbeddedWebView('')).toBe(true)
})
})
})

View File

@@ -0,0 +1,72 @@
/**
* Detects whether the app is running inside an embedded webview.
*
* Google blocks OAuth via `signInWithPopup` in embedded webviews,
* returning a 403 `disallowed_useragent` error (policy since 2021).
* This utility is used to hide the Google SSO button in those contexts.
*
* Detection covers:
* • Android WebView (`wv` token in UA)
* • iOS WKWebView (has `AppleWebKit` but lacks `Safari/`)
* • Social app in-app browsers (Facebook, Instagram, TikTok, etc.)
* • JS bridge objects (`window.webkit.messageHandlers`, `ReactNativeWebView`)
*/
const SOCIAL_APP_PATTERNS =
/FBAN|FBAV|Instagram|Line\/|Snapchat|TikTok|musical_ly/i
function isAndroidWebView(ua: string): boolean {
return /\bwv\b/.test(ua) && /Android/.test(ua)
}
function isIOSWebView(ua: string): boolean {
if (!/AppleWebKit/i.test(ua)) return false
if (/Safari\//i.test(ua)) return false
if (/CriOS|FxiOS|OPiOS|EdgiOS/i.test(ua)) return false
return true
}
function isSocialAppBrowser(ua: string): boolean {
return SOCIAL_APP_PATTERNS.test(ua)
}
function hasWebViewBridge(): boolean {
try {
const win = globalThis as Record<string, unknown>
if (
typeof win.webkit === 'object' &&
win.webkit !== null &&
typeof (win.webkit as Record<string, unknown>).messageHandlers ===
'object'
) {
return true
}
if (win.ReactNativeWebView != null) return true
} catch {
// Access to bridge objects may throw in sandboxed contexts
}
return false
}
export function isEmbeddedWebView(ua: string = navigator.userAgent): boolean {
if (isSocialAppBrowser(ua)) return true
if (isAndroidWebView(ua)) return true
if (isIOSWebView(ua)) return true
if (hasWebViewBridge()) return true
return false
}
/**
* Reason why Google SSO is blocked in the current environment, or `null` if it
* is available. Modeled as a discriminated string so call sites read as
* "if blocked, here's why" rather than an opaque boolean. Extend this union
* (e.g. `'unauthorized-host'`) as new blocking conditions are detected.
*/
type GoogleSsoBlockedReason = 'embedded-webview' | null
export function getGoogleSsoBlockedReason(
ua: string = navigator.userAgent
): GoogleSsoBlockedReason {
if (isEmbeddedWebView(ua)) return 'embedded-webview'
return null
}

View File

@@ -49,6 +49,7 @@
<div class="flex flex-col gap-6">
<template v-if="ssoAllowed">
<Button
v-if="!googleSsoBlockedReason"
type="button"
class="h-10"
variant="secondary"
@@ -157,6 +158,7 @@ import type { SignInData, SignUpData } from '@/schemas/signInSchema'
import { isCloud } from '@/platform/distribution/types'
import { isHostWhitelisted, normalizeHost } from '@/utils/hostWhitelist'
import { isInChina } from '@/utils/networkUtil'
import { getGoogleSsoBlockedReason } from '@/base/webviewDetection'
import ApiKeyForm from './signin/ApiKeyForm.vue'
import SignInForm from './signin/SignInForm.vue'
@@ -172,6 +174,7 @@ const isSecureContext = window.isSecureContext
const isSignIn = ref(true)
const showApiKeyForm = ref(false)
const ssoAllowed = isHostWhitelisted(normalizeHost(window.location.hostname))
const googleSsoBlockedReason = getGoogleSsoBlockedReason()
const comfyPlatformBaseUrl = computed(() =>
configValueOrDefault(
remoteConfig.value,

View File

@@ -2188,7 +2188,8 @@
"freeTierBadge": "Eligible for Free Tier",
"freeTierDescription": "Sign up with Google to get {credits} free credits every month. No card needed.",
"freeTierDescriptionGeneric": "Sign up with Google to get free credits every month. No card needed.",
"backToSocialLogin": "Sign up with Google or Github instead"
"backToSocialLogin": "Sign up with Google or Github instead",
"backToGithubLogin": "Sign up with Github instead"
},
"signup": {
"title": "Create an account",

View File

@@ -7,7 +7,7 @@
{{ t('auth.login.title') }}
</h1>
<i18n-t
v-if="isFreeTierEnabled"
v-if="isFreeTierEnabled && !googleSsoBlockedReason"
keypath="auth.login.signUpFreeTierPromo"
tag="p"
class="my-0 text-base text-muted"
@@ -39,7 +39,12 @@
<template v-if="!showEmailForm">
<!-- OAuth Buttons (primary) -->
<div class="flex flex-col gap-4">
<Button type="button" class="h-10 w-full" @click="signInWithGoogle">
<Button
v-if="!googleSsoBlockedReason"
type="button"
class="h-10 w-full"
@click="signInWithGoogle"
>
<i class="pi pi-google mr-2"></i>
{{ t('auth.login.loginWithGoogle') }}
</Button>
@@ -116,6 +121,7 @@ import { useFreeTierOnboarding } from '@/platform/cloud/onboarding/composables/u
import { getSafePreviousFullPath } from '@/platform/cloud/onboarding/utils/previousFullPath'
import { useToastStore } from '@/platform/updates/common/toastStore'
import type { SignInData } from '@/schemas/signInSchema'
import { getGoogleSsoBlockedReason } from '@/base/webviewDetection'
const { t } = useI18n()
const router = useRouter()
@@ -126,6 +132,7 @@ const authError = ref('')
const toastStore = useToastStore()
const showEmailForm = ref(false)
const { isFreeTierEnabled, freeTierCredits } = useFreeTierOnboarding()
const googleSsoBlockedReason = getGoogleSsoBlockedReason()
function switchToEmailForm() {
showEmailForm.value = true

View File

@@ -23,7 +23,10 @@
</Message>
<template v-if="!showEmailForm">
<p v-if="isFreeTierEnabled" class="mb-4 text-sm text-muted-foreground">
<p
v-if="isFreeTierEnabled && !googleSsoBlockedReason"
class="mb-4 text-sm text-muted-foreground"
>
{{
freeTierCredits
? t('auth.login.freeTierDescription', {
@@ -35,7 +38,7 @@
<!-- OAuth Buttons (primary) -->
<div class="flex flex-col gap-4">
<div class="relative">
<div v-if="!googleSsoBlockedReason" class="relative">
<Button type="button" class="h-10 w-full" @click="signInWithGoogle">
<i class="pi pi-google mr-2"></i>
{{ t('auth.signup.signUpWithGoogle') }}
@@ -86,7 +89,11 @@
class="text-sm underline"
@click="switchToSocialLogin"
>
{{ t('auth.login.backToSocialLogin') }}
{{
googleSsoBlockedReason
? t('auth.login.backToGithubLogin')
: t('auth.login.backToSocialLogin')
}}
</Button>
</div>
</template>
@@ -141,6 +148,7 @@ import { useTelemetry } from '@/platform/telemetry'
import { useToastStore } from '@/platform/updates/common/toastStore'
import type { SignUpData } from '@/schemas/signInSchema'
import { isInChina } from '@/utils/networkUtil'
import { getGoogleSsoBlockedReason } from '@/base/webviewDetection'
const { t } = useI18n()
const router = useRouter()
@@ -158,6 +166,7 @@ const {
switchToEmailForm,
switchToSocialLogin
} = useFreeTierOnboarding()
const googleSsoBlockedReason = getGoogleSsoBlockedReason()
const navigateToLogin = async () => {
await router.push({ name: 'cloud-login', query: route.query })