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
10 changes: 10 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<FeedLog URL>/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)
# =============================================================================
Expand Down
60 changes: 43 additions & 17 deletions app/components/LoginModal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -11,20 +11,17 @@ const state = ref<ModalState>('sign-in')

const loading = ref(false)

interface AuthMethods {
google: boolean
github: boolean
email: boolean
emailVerification: boolean
}
const { data: authCfg } = await useFetch<AuthMethods>('/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.
Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -344,8 +353,25 @@ const showPassword = ref(false)
<template>
<Dialog v-if="open" :open="true" @update:open="open = $event">
<DialogContent class="sm:max-w-sm">
<template v-if="authConfigUnavailable">
<DialogHeader class="text-center space-y-2">
<AppLogo :size="48" class="mx-auto" />
<DialogTitle class="font-heading text-xl">{{ $t('auth.configUnavailable.title') }}</DialogTitle>
<DialogDescription>{{ $t('auth.configUnavailable.description') }}</DialogDescription>
</DialogHeader>
<Button
class="w-full"
size="lg"
:disabled="authConfigStatus === 'pending'"
@click="retryAuthConfig"
>
<Spinner v-if="authConfigStatus === 'pending'" class="mr-2 size-4" />
{{ $t('auth.configUnavailable.retry') }}
</Button>
</template>

<!-- State 1: Sign In (OAuth first) -->
<template v-if="state === 'sign-in'">
<template v-else-if="state === 'sign-in'">
<DialogHeader class="text-center space-y-2">
<AppLogo :size="48" class="mx-auto" />
<DialogTitle class="font-heading text-xl">{{ signInTitle }}</DialogTitle>
Expand Down Expand Up @@ -396,7 +422,7 @@ const showPassword = ref(false)

<p v-if="emailEnabled" class="text-center text-sm text-muted-foreground">
{{ $t('auth.signIn.noAccount') }}
<button class="text-primary hover:underline font-medium" @click="switchTo('sign-up-email')">{{ $t('common.signUp') }}</button>
<button class="text-primary hover:underline font-medium" @click="showExternalOrLocal('sign-up-email')">{{ $t('common.signUp') }}</button>
</p>
</template>

Expand Down Expand Up @@ -464,7 +490,7 @@ const showPassword = ref(false)

<p class="text-center text-sm text-muted-foreground">
{{ $t('auth.signIn.noAccount') }}
<button class="text-primary hover:underline font-medium" @click="switchTo('sign-up-email')">{{ $t('common.signUp') }}</button>
<button class="text-primary hover:underline font-medium" @click="showExternalOrLocal('sign-up-email')">{{ $t('common.signUp') }}</button>
</p>
</template>

Expand Down Expand Up @@ -524,7 +550,7 @@ const showPassword = ref(false)

<p class="text-center text-sm text-muted-foreground">
{{ $t('auth.signUp.haveAccount') }}
<button class="text-primary hover:underline font-medium" @click="switchTo('sign-in')">{{ $t('common.signIn') }}</button>
<button class="text-primary hover:underline font-medium" @click="showExternalOrLocal('sign-in')">{{ $t('common.signIn') }}</button>
</p>
</template>

Expand Down
23 changes: 23 additions & 0 deletions app/composables/useAuthConfig.ts
Original file line number Diff line number Diff line change
@@ -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<PublicAuthConfig>('/api/auth-config', {
key: 'auth-config',
})
}
53 changes: 49 additions & 4 deletions app/composables/useLoginModal.ts
Original file line number Diff line number Diff line change
@@ -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<string | null>('login-modal-reason', () => null)
const intent = useState<ExternalAuthIntent>('login-modal-intent', () => 'login')
const { data: authConfig, status, refresh } = useAuthConfig()

async function redirectExternal(requestedIntent: ExternalAuthIntent): Promise<boolean> {
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,
}
}

Expand Down
2 changes: 1 addition & 1 deletion app/layouts/default.vue
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,7 @@ watch(() => route.path, () => { mobileNavOpen.value = false })
<button
v-if="!user"
class="flex items-center gap-2 font-heading font-semibold text-sm px-4 py-2 rounded-lg bg-primary text-primary-foreground hover:bg-primary/90 transition-all"
@click="showLoginModal = true"
@click="openLoginModal()"
>
<Icon name="lucide:log-in" size="16" />
<span class="hidden sm:inline">{{ $t('common.signIn') }}</span>
Expand Down
1 change: 1 addition & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ Choose the guide that matches where you want to run FeedLog:
else — OAuth, AI, file uploads, email — is optional.

- **[Configuration reference](./configuration.md)** — every environment variable, grouped by purpose, with defaults and gotchas.
- **[JWT SSO and external authentication](./sso.md)** — delegate login/signup and return authenticated users through FeedLog's JWT SSO endpoint.
- [`.env.example`](../.env.example) — the same list as an annotated `.env` template you can copy and edit.
- **[Widget configuration](./widget.md)** — launcher defaults, closing behavior, configuration APIs and host-dialog integration.

Expand Down
26 changes: 26 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,32 @@ Force-toggle "verify email before sign-in".
| `true` | Force-enabled (verification email is sent on signup; requires a working email provider, otherwise signup fails) |
| `false` | Force-disabled |

### `EXTERNAL_LOGIN_URL` / `EXTERNAL_SIGNUP_URL` &nbsp;⬜ optional

Delegate ordinary FeedLog sign-in and sign-up actions to an existing
application. FeedLog appends the visitor's current FeedLog page in a
`return_to` query parameter so the application can send the visitor back through
the existing JWT SSO endpoint after authentication.

```bash
EXTERNAL_LOGIN_URL=https://app.example.com/login
EXTERNAL_SIGNUP_URL=https://app.example.com/signup
```

Both values are optional. If only one is set, FeedLog uses it for both login and
signup. If neither is set, the existing Better Auth modal and all local methods
behave exactly as before. Configured query parameters on these URLs are kept,
except that FeedLog owns and replaces `return_to`.

External URLs must use HTTPS. Plain HTTP is accepted only for `localhost`,
`127.0.0.1`, and `[::1]` development URLs. URLs with embedded credentials,
credential-shaped query parameters, unsupported schemes, or the `/api/sso/jwt`
path are ignored. Password/profile operations and other flows that explicitly
require local credentials continue to open the local Better Auth modal.

See [JWT SSO and external authentication](./sso.md) for the return flow and a
server-side example.

---

## Blob storage
Expand Down
78 changes: 78 additions & 0 deletions docs/sso.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
# JWT SSO and external authentication

FeedLog accepts an identity asserted by a trusted application at
`GET /api/sso/jwt`. The organization SSO secret is managed in FeedLog's
dashboard. Keep that secret on the trusted application's server; never expose it
to browser JavaScript.

## Delegate FeedLog login and signup

Set one or both external authentication URLs:

```bash
EXTERNAL_LOGIN_URL=https://app.example.com/login
EXTERNAL_SIGNUP_URL=https://app.example.com/signup
```

An ordinary login action then navigates to a URL like:

```text
https://app.example.com/login?return_to=https%3A%2F%2Ffeedback.example.com%2Fp%2Froadmap%3Fb%3Dmobile
```

FeedLog derives `return_to` from its current page; it never accepts an external
destination from a browser query parameter. It preserves ordinary page query
parameters, removes credential-shaped parameters and fragments, and requires a
validated configured HTTPS endpoint (with a loopback-only HTTP exception for
local development).

After authenticating the visitor, the external application's **server** signs a
short-lived JWT and redirects to FeedLog's existing SSO endpoint. FeedLog
validates `return_to` again and only allows a destination on its own host.

## Example external application handler

This simplified Express-style example uses `jose`. In a real application,
replace `requireUser` with the application's existing login/session middleware.

```ts
import { SignJWT } from 'jose'

const feedlogOrigin = new URL(process.env.FEEDLOG_URL!)
const ssoSecret = new TextEncoder().encode(process.env.FEEDLOG_SSO_SECRET!)

app.get('/login', requireUser, async (req, res) => {
// Never trust return_to merely because FeedLog normally supplies it.
let returnTo = new URL('/', feedlogOrigin)
try {
const candidate = new URL(String(req.query.return_to ?? '/'), feedlogOrigin)
if (candidate.origin === feedlogOrigin.origin) returnTo = candidate
}
catch {
// Keep the safe root fallback.
}

const jwt = await new SignJWT({
email: req.user.email,
name: req.user.name,
picture: req.user.avatarUrl,
})
.setProtectedHeader({ alg: 'HS256' })
.setExpirationTime('5m')
.sign(ssoSecret)

const handoff = new URL('/api/sso/jwt', feedlogOrigin)
handoff.searchParams.set('jwt', jwt)
handoff.searchParams.set('return_to', returnTo.pathname + returnTo.search + returnTo.hash)
res.redirect(handoff.toString())
})
```

The signup endpoint follows the same pattern after account creation. The external
application owns authentication and JWT signing; the redirect feature adds no
new authentication protocol and does not change FeedLog's JWT verification or
session behavior.

Use a short JWT lifetime, transmit it only over HTTPS, avoid logging request
query strings on the SSO route, and rotate the organization SSO secret if it is
ever exposed.
5 changes: 5 additions & 0 deletions i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,11 @@
"checkEmail": "Check your email",
"localAuthRequired": "You're signed in through {org}. This action needs a direct sign-in.",
"localAuthTitle": "Sign in directly",
"configUnavailable": {
"title": "Sign-in is temporarily unavailable",
"description": "FeedLog could not load its sign-in settings. Please try again.",
"retry": "Try again"
},
"backToSignIn": "← Back to sign in",
"signingIn": "Signing in...",
"fields": {
Expand Down
5 changes: 5 additions & 0 deletions i18n/locales/zh.json
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,11 @@
"checkEmail": "查收邮件",
"localAuthRequired": "你通过 {org} 登录,此操作需要直接登录。",
"localAuthTitle": "直接登录",
"configUnavailable": {
"title": "登录暂时不可用",
"description": "FeedLog 无法加载登录设置,请重试。",
"retry": "重试"
},
"backToSignIn": "← 返回登录",
"signingIn": "登录中……",
"fields": {
Expand Down
15 changes: 14 additions & 1 deletion server/api/auth-config.get.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,18 @@
// Sits at /api/auth-config (not /api/auth/config) to avoid shadowing the
// better-auth catch-all route at /api/auth/[...all].
//
import { resolveExternalAuthUrls } from '#layers/feedlog/shared/utils/external-auth'

// `authConfig` is auto-imported by Nitro from server/utils/better-auth.ts.
export default defineEventHandler(() => authConfig)
export default defineEventHandler(() => {
const external = resolveExternalAuthUrls(
process.env.EXTERNAL_LOGIN_URL,
process.env.EXTERNAL_SIGNUP_URL,
)

return {
...authConfig,
externalLoginUrl: external.login,
externalSignupUrl: external.signup,
}
})
Loading
Loading