From 8fd9fdbe3bf37d69465049f7e2692b10e5047afe Mon Sep 17 00:00:00 2001 From: Ronald Nicholls Date: Wed, 21 Oct 2026 09:18:27 -0400 Subject: [PATCH] feat(auth): support optional external login and signup redirects --- .env.example | 10 +++ app/components/LoginModal.vue | 60 ++++++++++----- app/composables/useAuthConfig.ts | 23 ++++++ app/composables/useLoginModal.ts | 53 ++++++++++++- app/layouts/default.vue | 2 +- docs/README.md | 1 + docs/configuration.md | 26 +++++++ docs/sso.md | 78 ++++++++++++++++++++ i18n/locales/en.json | 5 ++ i18n/locales/zh.json | 5 ++ server/api/auth-config.get.ts | 15 +++- shared/utils/external-auth.ts | 113 ++++++++++++++++++++++++++++ tests/external-auth.test.ts | 123 +++++++++++++++++++++++++++++++ 13 files changed, 491 insertions(+), 23 deletions(-) create mode 100644 app/composables/useAuthConfig.ts create mode 100644 docs/sso.md create mode 100644 shared/utils/external-auth.ts create mode 100644 tests/external-auth.test.ts diff --git a/.env.example b/.env.example index 6ea9f659..a9983746 100644 --- a/.env.example +++ b/.env.example @@ -104,6 +104,16 @@ SYSTEM_ADMIN_EMAILS=admin@example.com # breaks signup (verification email can't be sent). # AUTH_EMAIL_VERIFY= +# Delegate ordinary sign-in and sign-up actions to an existing application. +# FeedLog adds the current page as the `return_to` query parameter. The external +# application authenticates the user, signs its normal FeedLog SSO JWT, and +# returns through `/api/sso/jwt`. +# +# HTTPS is required except for localhost/loopback development URLs. Configure +# either variable by itself to use it for both actions. +# EXTERNAL_LOGIN_URL=https://app.example.com/login +# EXTERNAL_SIGNUP_URL=https://app.example.com/signup + # ============================================================================= # OPTIONAL — AI features (Widget Agent, similar-idea merge, AI-drafted changelog) # ============================================================================= diff --git a/app/components/LoginModal.vue b/app/components/LoginModal.vue index f6469fd3..841a915b 100644 --- a/app/components/LoginModal.vue +++ b/app/components/LoginModal.vue @@ -11,20 +11,17 @@ const state = ref('sign-in') const loading = ref(false) -interface AuthMethods { - google: boolean - github: boolean - email: boolean - emailVerification: boolean -} -const { data: authCfg } = await useFetch('/api/auth-config', { - key: 'auth-config', - // If the endpoint hiccups, fall back to email-only so the modal still renders. - default: () => ({ google: false, github: false, email: true, emailVerification: false }), -}) +const loginModal = useLoginModal() +const { reason, intent } = loginModal +const { data: authCfg, status: authConfigStatus, refresh: refreshAuthConfig } = await useAuthConfig() const hasOAuth = computed(() => !!(authCfg.value?.google || authCfg.value?.github)) -const emailEnabled = computed(() => !!authCfg.value?.email) +const localAuthRequired = computed(() => reason.value === LOCAL_AUTH_REASON) +const emailEnabled = computed(() => localAuthRequired.value || !!authCfg.value?.email) const showSeparator = computed(() => hasOAuth.value && emailEnabled.value) +const authConfigUnavailable = computed(() => isAuthConfigUnavailable( + authCfg.value, + localAuthRequired.value, +)) function initialState(): ModalState { // Skip the chooser when email is the only available method. @@ -39,7 +36,6 @@ const form = reactive({ // Set when the server refused the current session. Shown on the sign-in states // only — deeper in a sub-flow the prompt is just noise. -const { reason } = useLoginModal() // Named in the prompt: readers recognise the product they came from, where a // generic mention of it means nothing. @@ -96,6 +92,19 @@ function switchTo(target: ModalState) { state.value = target } +async function showExternalOrLocal(target: 'sign-in' | 'sign-up-email') { + const intent = target === 'sign-up-email' ? 'signup' : 'login' + if (await loginModal.redirectExternal(intent)) return + switchTo(target) +} + +async function retryAuthConfig() { + await refreshAuthConfig() + if (!authCfg.value) return + if (await loginModal.redirectExternal(intent.value)) return + state.value = intent.value === 'signup' ? 'sign-up-email' : initialState() +} + // --- Social OAuth (shared popup logic) --- // // callbackURL is where better-auth 302s after the OAuth flow finishes @@ -344,8 +353,25 @@ const showPassword = ref(false) diff --git a/app/composables/useAuthConfig.ts b/app/composables/useAuthConfig.ts new file mode 100644 index 00000000..83cac1cd --- /dev/null +++ b/app/composables/useAuthConfig.ts @@ -0,0 +1,23 @@ +export interface PublicAuthConfig { + mcp?: boolean + google: boolean + github: boolean + email: boolean + emailVerification: boolean + emailProvider?: boolean + externalLoginUrl: string | null + externalSignupUrl: string | null +} + +export function isAuthConfigUnavailable( + config: PublicAuthConfig | null | undefined, + localAuthRequired = false, +): boolean { + return !localAuthRequired && !config +} + +export function useAuthConfig() { + return useFetch('/api/auth-config', { + key: 'auth-config', + }) +} diff --git a/app/composables/useLoginModal.ts b/app/composables/useLoginModal.ts index 554fbea5..4b9d77dc 100644 --- a/app/composables/useLoginModal.ts +++ b/app/composables/useLoginModal.ts @@ -1,15 +1,60 @@ +import { + buildExternalAuthRedirectUrl, + selectExternalAuthUrl, + type ExternalAuthIntent, +} from '#layers/feedlog/shared/utils/external-auth' + // `reason` holds an i18n key rather than rendered text: the modal is mounted // elsewhere and may render in a different locale than the caller. export function useLoginModal() { const isOpen = useState('login-modal', () => false) const reason = useState('login-modal-reason', () => null) + const intent = useState('login-modal-intent', () => 'login') + const { data: authConfig, status, refresh } = useAuthConfig() + + async function redirectExternal(requestedIntent: ExternalAuthIntent): Promise { + if (!import.meta.client || reason.value === LOCAL_AUTH_REASON) return false + // Remember which action failed so the retry affordance can resume it. + intent.value = requestedIntent + if (status.value !== 'success') { + try { + await refresh() + } + catch { /* The missing config below opens the retry state. */ } + } + if (!authConfig.value) { + isOpen.value = true + return true + } + + const externalUrl = selectExternalAuthUrl({ + login: authConfig.value?.externalLoginUrl ?? null, + signup: authConfig.value?.externalSignupUrl ?? null, + }, requestedIntent) + if (!externalUrl) return false + + const destination = buildExternalAuthRedirectUrl( + externalUrl, + window.location.origin, + window.location.pathname + window.location.search, + ) + if (!destination) return false + window.location.assign(destination) + return true + } + + async function open(reasonKey?: string) { + reason.value = reasonKey ?? null + if (reasonKey !== LOCAL_AUTH_REASON && await redirectExternal('login')) return + isOpen.value = true + } + return { isOpen, reason, - open: (reasonKey?: string) => { - reason.value = reasonKey ?? null - isOpen.value = true - }, + intent, + open, + redirectExternal, } } diff --git a/app/layouts/default.vue b/app/layouts/default.vue index 929e97e8..57199605 100644 --- a/app/layouts/default.vue +++ b/app/layouts/default.vue @@ -185,7 +185,7 @@ watch(() => route.path, () => { mobileNavOpen.value = false })