Skip to content

Commit f265091

Browse files
feat(auth): implement public authentication handoff and session management
1 parent 88eee68 commit f265091

14 files changed

Lines changed: 474 additions & 70 deletions

File tree

components/public/PublicFeedbackBoard.vue

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -948,7 +948,7 @@
948948
</template>
949949

950950
<script>
951-
import { authClient } from '~/lib/auth-client'
951+
import { completePublicAuthHandoffFromUrl, fetchPublicAuthSession } from '~/lib/public-auth-handoff'
952952

953953
const ROADMAP_PREFETCH_CACHE_TTL_MS = 60_000
954954
const FEEDBACK_DETAILS_PREFETCH_CACHE_TTL_MS = 30_000
@@ -1170,7 +1170,7 @@ export default {
11701170
await this.$nextTick()
11711171
this.setupScrollObserver()
11721172
this.warmRoadmapData()
1173-
this.checkAdminStatus()
1173+
await this.checkAdminStatus()
11741174
return
11751175
}
11761176
try {
@@ -1179,7 +1179,7 @@ export default {
11791179
this.applyTheme(this.projectData?.project?.settings?.themeMode || 'system')
11801180
await this.loadFeedback()
11811181
this.warmRoadmapData()
1182-
this.checkAdminStatus()
1182+
await this.checkAdminStatus()
11831183
} catch (err) {
11841184
console.error('Error loading project:', err)
11851185
this.error = 'This feedback page could not be found.'
@@ -1606,16 +1606,31 @@ export default {
16061606
},
16071607
async checkAdminStatus() {
16081608
if (!import.meta.client) return
1609+
1610+
const handoffCompleted = await completePublicAuthHandoffFromUrl().catch(() => false)
1611+
16091612
try {
1610-
const sessionResult = await authClient.getSession()
1611-
if (!sessionResult?.data?.user) return
1612-
this.currentUser = sessionResult.data.user
1613-
this.submitForm.authorName = sessionResult.data.user.name || ''
1614-
this.submitForm.authorEmail = sessionResult.data.user.email || ''
1613+
const sessionResult = await fetchPublicAuthSession()
1614+
const currentUser = sessionResult?.user || null
1615+
1616+
this.currentUser = currentUser
1617+
this.isAdmin = false
1618+
this.submitForm.authorName = currentUser?.name || ''
1619+
this.submitForm.authorEmail = currentUser?.email || ''
1620+
1621+
if (!currentUser) {
1622+
return
1623+
}
1624+
1625+
if (handoffCompleted) {
1626+
await $fetch('/api/auth/merge-anonymous', { method: 'POST' }).catch(() => {})
1627+
}
1628+
16151629
const response = await $fetch('/api/teams/list-user')
16161630
const teams = response?.data || []
16171631
this.isAdmin = teams.some((t) => t.slug === this.teamSlug)
16181632
} catch {
1633+
this.currentUser = null
16191634
this.isAdmin = false
16201635
}
16211636
},

components/public/PublicRoadmap.vue

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -336,7 +336,7 @@
336336
</template>
337337

338338
<script>
339-
import { authClient } from '~/lib/auth-client'
339+
import { completePublicAuthHandoffFromUrl, fetchPublicAuthSession } from '~/lib/public-auth-handoff'
340340
341341
const FEEDBACK_BOARD_PREFETCH_CACHE_TTL_MS = 60_000
342342
@@ -476,7 +476,7 @@ export default {
476476
this.columnsLoading = false
477477
this.applyTheme(data.project?.settings?.themeMode || 'system')
478478
this.warmFeedbackBoardData()
479-
this.checkCurrentUser()
479+
await this.checkCurrentUser()
480480
return
481481
}
482482
try {
@@ -486,7 +486,7 @@ export default {
486486
this.columns = data.columns || []
487487
this.applyTheme(this.projectData.project.settings?.themeMode || 'system')
488488
this.warmFeedbackBoardData()
489-
this.checkCurrentUser()
489+
await this.checkCurrentUser()
490490
} catch (err) {
491491
console.error('Error loading roadmap:', err)
492492
this.error = 'This roadmap could not be found.'
@@ -538,9 +538,15 @@ export default {
538538
},
539539
async checkCurrentUser() {
540540
if (!import.meta.client) return
541+
542+
const handoffCompleted = await completePublicAuthHandoffFromUrl().catch(() => false)
543+
541544
try {
542-
const sessionResult = await authClient.getSession()
543-
this.currentUser = sessionResult?.data?.user || null
545+
const sessionResult = await fetchPublicAuthSession()
546+
this.currentUser = sessionResult?.user || null
547+
if (handoffCompleted && this.currentUser) {
548+
await $fetch('/api/auth/merge-anonymous', { method: 'POST' }).catch(() => {})
549+
}
544550
} catch {
545551
this.currentUser = null
546552
}

lib/auth-redirect.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,22 @@ export function parseRedirectUrl(rawRedirect: string): URL | null {
4343
}
4444
}
4545

46+
export function getMainAppUrl() {
47+
if (!import.meta.client) {
48+
return ''
49+
}
50+
51+
const config = useRuntimeConfig()
52+
const appDomain = normalizeHostname(String(config.public.appDomain || 'localhost'))
53+
const dashboardDomain = normalizeHostname(
54+
String(config.public.dashboardDomain || (appDomain === 'localhost' ? 'localhost' : `app.${appDomain}`))
55+
)
56+
const protocol = window.location.protocol
57+
const port = dashboardDomain === 'localhost' ? `:${window.location.port}` : ''
58+
59+
return `${protocol}//${dashboardDomain}${port}`
60+
}
61+
4662
export async function resolveSafeRedirectTarget(rawRedirect: unknown, fallback = '/dashboard'): Promise<string> {
4763
if (!rawRedirect || typeof rawRedirect !== 'string') {
4864
return fallback
@@ -100,3 +116,42 @@ export async function resolveSafeRedirectTarget(rawRedirect: unknown, fallback =
100116

101117
return fallback
102118
}
119+
120+
export async function resolvePostAuthRedirectTarget(rawRedirect: unknown, fallback = '/dashboard'): Promise<string> {
121+
const target = await resolveSafeRedirectTarget(rawRedirect, fallback)
122+
123+
if (!import.meta.client || target.startsWith('/')) {
124+
return target
125+
}
126+
127+
const parsed = parseRedirectUrl(target)
128+
if (!parsed) {
129+
return fallback
130+
}
131+
132+
const redirectHost = normalizeHostname(parsed.hostname)
133+
const config = useRuntimeConfig()
134+
const appDomain = normalizeHostname(String(config.public.appDomain || 'localhost'))
135+
const dashboardDomain = normalizeHostname(
136+
String(config.public.dashboardDomain || (appDomain === 'localhost' ? 'localhost' : `app.${appDomain}`))
137+
)
138+
const currentHost = normalizeHostname(window.location.hostname)
139+
140+
if (
141+
isAppHostedRedirectHost({
142+
redirectHost,
143+
currentHost,
144+
appDomain,
145+
dashboardDomain,
146+
})
147+
) {
148+
return parsed.toString()
149+
}
150+
151+
const mainAppUrl = getMainAppUrl()
152+
if (!mainAppUrl) {
153+
return parsed.toString()
154+
}
155+
156+
return `${mainAppUrl}/api/public/auth/handoff/start?target=${encodeURIComponent(parsed.toString())}`
157+
}

lib/public-auth-handoff.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
export const PUBLIC_AUTH_HANDOFF_QUERY_PARAM = 'authHandoff'
2+
3+
function stripPublicAuthHandoffTokenFromUrl() {
4+
if (!import.meta.client) return
5+
6+
const url = new URL(window.location.href)
7+
url.searchParams.delete(PUBLIC_AUTH_HANDOFF_QUERY_PARAM)
8+
window.history.replaceState({}, '', url.toString())
9+
}
10+
11+
export async function completePublicAuthHandoffFromUrl() {
12+
if (!import.meta.client) return false
13+
14+
const url = new URL(window.location.href)
15+
const token = url.searchParams.get(PUBLIC_AUTH_HANDOFF_QUERY_PARAM)
16+
if (!token) return false
17+
18+
try {
19+
await $fetch('/api/public/auth/handoff/consume', {
20+
method: 'POST',
21+
body: { token },
22+
})
23+
return true
24+
} finally {
25+
stripPublicAuthHandoffTokenFromUrl()
26+
}
27+
}
28+
29+
export async function fetchPublicAuthSession() {
30+
const response = await $fetch<{ data?: { session?: any; user?: any } | null }>('/api/public/auth/session')
31+
return response?.data || { session: null, user: null }
32+
}

middleware/auth.global.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { authClient } from '~/lib/auth-client'
2+
import { resolvePostAuthRedirectTarget } from '~/lib/auth-redirect'
23

34
export default defineNuxtRouteMiddleware(async (to, _from) => {
45
// Skip middleware on server-side during generation/build
@@ -63,8 +64,12 @@ export default defineNuxtRouteMiddleware(async (to, _from) => {
6364
return
6465
}
6566
const redirect = to.query.redirect
66-
if (redirect && typeof redirect === 'string' && redirect.startsWith('/')) {
67-
return navigateTo(redirect)
67+
if (redirect && typeof redirect === 'string') {
68+
const target = await resolvePostAuthRedirectTarget(redirect, '/dashboard')
69+
if (target.startsWith('http://') || target.startsWith('https://')) {
70+
return navigateTo(target, { external: true })
71+
}
72+
return navigateTo(target)
6873
}
6974
return navigateTo('/dashboard')
7075
}

pages/login/index.vue

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -178,7 +178,7 @@
178178

179179
<script>
180180
import { authClient } from '~/lib/auth-client'
181-
import { resolveSafeRedirectTarget } from '~/lib/auth-redirect'
181+
import { resolvePostAuthRedirectTarget, resolveSafeRedirectTarget } from '~/lib/auth-redirect'
182182
183183
export default {
184184
name: 'LoginPage',
@@ -203,6 +203,10 @@ export default {
203203
return resolveSafeRedirectTarget(rawRedirect, fallback)
204204
},
205205
206+
async resolvePostAuthTarget(rawRedirect, fallback = '/dashboard') {
207+
return resolvePostAuthRedirectTarget(rawRedirect, fallback)
208+
},
209+
206210
async handleSubmit() {
207211
if (!this.email || !this.password) {
208212
this.error = 'Please enter both email and password'
@@ -222,7 +226,7 @@ export default {
222226
this.error = result.error.message || 'Sign in failed'
223227
} else {
224228
$fetch('/api/auth/merge-anonymous', { method: 'POST' }).catch(() => {})
225-
const target = await this.resolveRedirectTarget(this.$route.query.redirect, '/dashboard')
229+
const target = await this.resolvePostAuthTarget(this.$route.query.redirect, '/dashboard')
226230
// Hard reload when adding an account to clear all in-memory session caches
227231
if (this.$route.query.addAccount === 'true') {
228232
window.location.href = target
@@ -250,7 +254,7 @@ export default {
250254
this.error = ''
251255
252256
try {
253-
const callbackURL = await this.resolveRedirectTarget(this.$route.query.redirect, '/dashboard')
257+
const callbackURL = await this.resolvePostAuthTarget(this.$route.query.redirect, '/dashboard')
254258
255259
const result = await authClient.signIn.magicLink({
256260
email: this.email,
@@ -275,7 +279,7 @@ export default {
275279
this.error = ''
276280
277281
try {
278-
const callbackURL = await this.resolveRedirectTarget(this.$route.query.redirect, '/dashboard')
282+
const callbackURL = await this.resolvePostAuthTarget(this.$route.query.redirect, '/dashboard')
279283
await authClient.signIn.social({
280284
provider: 'github',
281285
callbackURL,

pages/signup/index.vue

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@
113113

114114
<script>
115115
import { authClient } from '~/lib/auth-client'
116-
import { resolveSafeRedirectTarget } from '~/lib/auth-redirect'
116+
import { resolvePostAuthRedirectTarget, resolveSafeRedirectTarget } from '~/lib/auth-redirect'
117117
118118
export default {
119119
name: 'SignUpPage',
@@ -137,6 +137,10 @@ export default {
137137
return resolveSafeRedirectTarget(rawRedirect, fallback)
138138
},
139139
140+
async resolvePostAuthTarget(rawRedirect, fallback = '/dashboard') {
141+
return resolvePostAuthRedirectTarget(rawRedirect, fallback)
142+
},
143+
140144
async handleSubmit() {
141145
if (!this.name || !this.email || !this.password) {
142146
this.error = 'Please fill in all fields'
@@ -162,7 +166,7 @@ export default {
162166
this.error = result.error.message || 'Sign up failed'
163167
} else {
164168
$fetch('/api/auth/merge-anonymous', { method: 'POST' }).catch(() => {})
165-
const target = await this.resolveRedirectTarget(this.$route.query.redirect, '/dashboard')
169+
const target = await this.resolvePostAuthTarget(this.$route.query.redirect, '/dashboard')
166170
if (target.startsWith('http://') || target.startsWith('https://')) {
167171
window.location.href = target
168172
} else {
@@ -182,7 +186,7 @@ export default {
182186
this.error = ''
183187
184188
try {
185-
const callbackURL = await this.resolveRedirectTarget(this.$route.query.redirect, '/dashboard')
189+
const callbackURL = await this.resolvePostAuthTarget(this.$route.query.redirect, '/dashboard')
186190
await authClient.signIn.social({
187191
provider: 'github',
188192
callbackURL,
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { getRequestURL } from 'h3'
2+
import { z } from 'zod'
3+
import { normalizeHostname } from '~/lib/auth-redirect'
4+
import { createSuccessResponse } from '~/server/utils/response'
5+
import {
6+
findPublicAuthSession,
7+
parsePublicAuthHandoffToken,
8+
setPublicAuthSessionCookies,
9+
} from '~/server/utils/public-auth-handoff'
10+
import { validateBody } from '~/server/utils/validation'
11+
12+
const bodySchema = z.object({
13+
token: z.string().min(1),
14+
})
15+
16+
export default defineEventHandler(async (event) => {
17+
const body = await validateBody(event, bodySchema)
18+
const currentHost = normalizeHostname(getRequestURL(event).hostname)
19+
const handoff = await parsePublicAuthHandoffToken(body.token)
20+
21+
if (handoff.targetHost !== currentHost) {
22+
throw createError({
23+
statusCode: 403,
24+
statusMessage: 'Forbidden',
25+
})
26+
}
27+
28+
const authSession = await findPublicAuthSession(handoff.sessionToken)
29+
if (!authSession) {
30+
throw createError({
31+
statusCode: 401,
32+
statusMessage: 'Unauthorized',
33+
})
34+
}
35+
36+
await setPublicAuthSessionCookies(event, {
37+
dontRememberMe: handoff.dontRememberMe,
38+
session: authSession.session,
39+
})
40+
41+
return createSuccessResponse({
42+
session: {
43+
id: authSession.session.id,
44+
expiresAt: authSession.session.expiresAt,
45+
},
46+
user: {
47+
id: authSession.user.id,
48+
email: authSession.user.email,
49+
name: authSession.user.name,
50+
image: authSession.user.image || null,
51+
emailVerified: authSession.user.emailVerified,
52+
},
53+
})
54+
})

0 commit comments

Comments
 (0)