Implement OAuth callback with token handling and profile fetch (F1-002)

Complete the AuthCallbackPage to handle OAuth redirects by parsing tokens
from URL fragment, fetching user profile, and redirecting based on starter
deck status. Includes open-redirect protection and comprehensive tests.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Cal Corum 2026-01-30 12:39:18 -06:00
parent 09844cbf3f
commit f687909f91
4 changed files with 665 additions and 27 deletions

View File

@ -7,8 +7,8 @@
"projectName": "Mantimon TCG - Frontend",
"description": "Vue 3 + Phaser 3 frontend for pocket.manticorum.com - real-time multiplayer TCG with campaign mode",
"totalPhases": 8,
"completedPhases": 0,
"status": "Phase F0 in progress"
"completedPhases": 1,
"status": "Phase F1 in progress"
},
"techStack": {
@ -124,7 +124,8 @@
{
"id": "PHASE_F0",
"name": "Project Foundation",
"status": "in_progress",
"status": "COMPLETE",
"completedDate": "2026-01-30",
"description": "Scaffolding, tooling, core infrastructure, API client setup",
"estimatedDays": "3-5",
"dependencies": [],
@ -240,7 +241,7 @@
{
"id": "PHASE_F1",
"name": "Authentication",
"status": "NOT_STARTED",
"status": "in_progress",
"description": "OAuth login flow, token management, protected routes",
"estimatedDays": "2-3",
"dependencies": ["PHASE_F0"],

View File

@ -6,8 +6,8 @@
"created": "2026-01-30",
"lastUpdated": "2026-01-30",
"totalTasks": 10,
"completedTasks": 0,
"status": "not_started",
"completedTasks": 2,
"status": "in_progress",
"description": "Complete OAuth authentication flow including login, callback handling, starter deck selection, profile management, and app initialization."
},
"dependencies": {
@ -36,8 +36,8 @@
"description": "Replace username/password form with OAuth provider buttons",
"category": "pages",
"priority": 1,
"completed": false,
"tested": false,
"completed": true,
"tested": true,
"dependencies": [],
"files": [
{"path": "src/pages/LoginPage.vue", "status": "modify"}
@ -64,8 +64,8 @@
"description": "Handle OAuth callback and extract tokens from URL fragment",
"category": "pages",
"priority": 2,
"completed": false,
"tested": false,
"completed": true,
"tested": true,
"dependencies": ["F1-001"],
"files": [
{"path": "src/pages/AuthCallbackPage.vue", "status": "modify"}

View File

@ -0,0 +1,437 @@
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { createRouter, createWebHistory } from 'vue-router'
import { setActivePinia, createPinia } from 'pinia'
import AuthCallbackPage from './AuthCallbackPage.vue'
import { useAuthStore } from '@/stores/auth'
// Mock the API client
vi.mock('@/api/client', () => ({
apiClient: {
get: vi.fn(),
},
}))
import { apiClient } from '@/api/client'
describe('AuthCallbackPage', () => {
let router: ReturnType<typeof createRouter>
let mockLocation: { hash: string; pathname: string }
beforeEach(() => {
setActivePinia(createPinia())
// Mock window.location
mockLocation = { hash: '', pathname: '/auth/callback' }
Object.defineProperty(window, 'location', {
value: mockLocation,
writable: true,
})
// Mock history.replaceState
vi.spyOn(window.history, 'replaceState').mockImplementation(() => {})
// Reset mocks
vi.mocked(apiClient.get).mockReset()
router = createRouter({
history: createWebHistory(),
routes: [
{ path: '/auth/callback', name: 'AuthCallback', component: AuthCallbackPage },
{ path: '/login', name: 'Login', component: { template: '<div>Login</div>' } },
{ path: '/', name: 'Dashboard', component: { template: '<div>Dashboard</div>' } },
{ path: '/starter', name: 'StarterSelection', component: { template: '<div>Starter</div>' } },
],
})
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('token parsing', () => {
it('extracts tokens from URL hash fragment', async () => {
/**
* Test that tokens are correctly parsed from the URL hash.
*
* The OAuth provider redirects back with tokens in the fragment
* (after #) for security. We must parse access_token, refresh_token,
* and expires_in from this fragment.
*/
mockLocation.hash = '#access_token=abc123&refresh_token=xyz789&expires_in=3600'
vi.mocked(apiClient.get).mockResolvedValue({
id: 'user-1',
display_name: 'Test User',
avatar_url: null,
has_starter_deck: true,
})
await router.push('/auth/callback')
await router.isReady()
mount(AuthCallbackPage, {
global: { plugins: [router] },
})
await flushPromises()
const auth = useAuthStore()
expect(auth.accessToken).toBe('abc123')
expect(auth.refreshToken).toBe('xyz789')
expect(auth.expiresAt).toBeGreaterThan(Date.now())
})
it('shows error when tokens are missing', async () => {
/**
* Test error handling for malformed OAuth response.
*
* If the URL fragment is missing required tokens, we should
* show an error rather than crash or behave unexpectedly.
*/
mockLocation.hash = '#access_token=abc123' // Missing refresh_token and expires_in
await router.push('/auth/callback')
await router.isReady()
const wrapper = mount(AuthCallbackPage, {
global: { plugins: [router] },
})
await flushPromises()
expect(wrapper.text()).toContain('Authentication Failed')
expect(wrapper.text()).toContain('Missing tokens')
})
it('shows error when hash is empty', async () => {
/**
* Test error handling when no hash fragment is present.
*
* If the OAuth provider redirects without tokens (empty hash),
* we should show an appropriate error.
*/
mockLocation.hash = ''
await router.push('/auth/callback')
await router.isReady()
const wrapper = mount(AuthCallbackPage, {
global: { plugins: [router] },
})
await flushPromises()
expect(wrapper.text()).toContain('Authentication Failed')
})
})
describe('error handling from OAuth', () => {
it('redirects to login with error from query params', async () => {
/**
* Test that OAuth errors are forwarded to the login page.
*
* When OAuth fails, the backend redirects to callback with
* error details in query params. We should forward these
* to the login page for display.
*/
await router.push('/auth/callback?error=access_denied&message=User%20cancelled')
await router.isReady()
mount(AuthCallbackPage, {
global: { plugins: [router] },
})
await flushPromises()
expect(router.currentRoute.value.name).toBe('Login')
expect(router.currentRoute.value.query.error).toBe('access_denied')
expect(router.currentRoute.value.query.message).toBe('User cancelled')
})
it('uses default error message when message param is missing', async () => {
/**
* Test fallback error message.
*
* If the backend only provides an error code without a message,
* we should use a sensible default message.
*/
await router.push('/auth/callback?error=unknown_error')
await router.isReady()
mount(AuthCallbackPage, {
global: { plugins: [router] },
})
await flushPromises()
expect(router.currentRoute.value.name).toBe('Login')
expect(router.currentRoute.value.query.message).toBe('Authentication failed. Please try again.')
})
})
describe('user profile handling', () => {
it('fetches user profile after storing tokens', async () => {
/**
* Test that user profile is fetched after successful token storage.
*
* After storing tokens, we need to fetch the user's profile
* to know their display name, avatar, and starter deck status.
*/
mockLocation.hash = '#access_token=abc123&refresh_token=xyz789&expires_in=3600'
vi.mocked(apiClient.get).mockResolvedValue({
id: 'user-1',
display_name: 'Test User',
avatar_url: 'https://example.com/avatar.png',
has_starter_deck: true,
})
await router.push('/auth/callback')
await router.isReady()
mount(AuthCallbackPage, {
global: { plugins: [router] },
})
await flushPromises()
expect(apiClient.get).toHaveBeenCalledWith('/api/users/me')
const auth = useAuthStore()
expect(auth.user).toEqual({
id: 'user-1',
displayName: 'Test User',
avatarUrl: 'https://example.com/avatar.png',
hasStarterDeck: true,
})
})
it('redirects to starter selection if user has no starter deck', async () => {
/**
* Test redirect flow for new users without starter decks.
*
* New users must select a starter deck before accessing the main app.
* If has_starter_deck is false, redirect to the selection page.
*/
mockLocation.hash = '#access_token=abc123&refresh_token=xyz789&expires_in=3600'
vi.mocked(apiClient.get).mockResolvedValue({
id: 'user-1',
display_name: 'New User',
avatar_url: null,
has_starter_deck: false,
})
await router.push('/auth/callback')
await router.isReady()
mount(AuthCallbackPage, {
global: { plugins: [router] },
})
await flushPromises()
expect(router.currentRoute.value.name).toBe('StarterSelection')
})
it('redirects to dashboard if user has starter deck', async () => {
/**
* Test redirect flow for existing users with starter decks.
*
* Users who already have a starter deck should go directly
* to the dashboard after successful login.
*/
mockLocation.hash = '#access_token=abc123&refresh_token=xyz789&expires_in=3600'
vi.mocked(apiClient.get).mockResolvedValue({
id: 'user-1',
display_name: 'Existing User',
avatar_url: null,
has_starter_deck: true,
})
await router.push('/auth/callback')
await router.isReady()
mount(AuthCallbackPage, {
global: { plugins: [router] },
})
await flushPromises()
expect(router.currentRoute.value.name).toBe('Dashboard')
})
it('redirects to intended destination after login', async () => {
/**
* Test redirect to originally requested page.
*
* If a user was redirected to login while trying to access
* a protected route, they should return there after auth.
*/
mockLocation.hash = '#access_token=abc123&refresh_token=xyz789&expires_in=3600'
vi.mocked(apiClient.get).mockResolvedValue({
id: 'user-1',
display_name: 'Test User',
avatar_url: null,
has_starter_deck: true,
})
await router.push('/auth/callback?redirect=/decks')
await router.isReady()
mount(AuthCallbackPage, {
global: { plugins: [router] },
})
await flushPromises()
expect(router.currentRoute.value.path).toBe('/decks')
})
})
describe('API errors', () => {
it('shows error when profile fetch fails', async () => {
/**
* Test error handling for profile fetch failures.
*
* If we successfully get tokens but fail to fetch the profile
* (network error, server error, etc.), show an error state.
*/
mockLocation.hash = '#access_token=abc123&refresh_token=xyz789&expires_in=3600'
vi.mocked(apiClient.get).mockRejectedValue(new Error('Network error'))
await router.push('/auth/callback')
await router.isReady()
const wrapper = mount(AuthCallbackPage, {
global: { plugins: [router] },
})
await flushPromises()
expect(wrapper.text()).toContain('Authentication Failed')
expect(wrapper.text()).toContain('Network error')
})
})
describe('security', () => {
it('rejects protocol-relative redirect URLs', async () => {
/**
* Test that protocol-relative URLs are rejected as redirects.
*
* An attacker could try to use //evil.com as a redirect, which
* passes a naive startsWith('/') check but redirects to another domain.
*/
mockLocation.hash = '#access_token=abc123&refresh_token=xyz789&expires_in=3600'
vi.mocked(apiClient.get).mockResolvedValue({
id: 'user-1',
display_name: 'Test User',
avatar_url: null,
has_starter_deck: true,
})
await router.push('/auth/callback?redirect=//evil.com')
await router.isReady()
mount(AuthCallbackPage, {
global: { plugins: [router] },
})
await flushPromises()
// Should go to dashboard, not the malicious redirect
expect(router.currentRoute.value.name).toBe('Dashboard')
})
it('clears tokens from URL after parsing', async () => {
/**
* Test that tokens are removed from browser history.
*
* Tokens in the URL should be cleared after parsing to prevent
* them from appearing in browser history or being logged.
*/
mockLocation.hash = '#access_token=abc123&refresh_token=xyz789&expires_in=3600'
vi.mocked(apiClient.get).mockResolvedValue({
id: 'user-1',
display_name: 'Test User',
avatar_url: null,
has_starter_deck: true,
})
await router.push('/auth/callback')
await router.isReady()
mount(AuthCallbackPage, {
global: { plugins: [router] },
})
await flushPromises()
expect(window.history.replaceState).toHaveBeenCalledWith(
null,
'',
'/auth/callback'
)
})
})
describe('UI states', () => {
it('shows processing state while handling callback', async () => {
/**
* Test loading state during authentication.
*
* Users should see a loading indicator while tokens are being
* processed and profile is being fetched.
*/
mockLocation.hash = '#access_token=abc123&refresh_token=xyz789&expires_in=3600'
// Don't resolve the promise immediately
vi.mocked(apiClient.get).mockImplementation(() => new Promise(() => {}))
await router.push('/auth/callback')
await router.isReady()
const wrapper = mount(AuthCallbackPage, {
global: { plugins: [router] },
})
expect(wrapper.text()).toContain('Completing login')
expect(wrapper.text()).toContain('Please wait')
})
it('shows back to login button on error', async () => {
/**
* Test error recovery UI.
*
* When authentication fails, users should have a clear
* way to return to the login page and try again.
*/
mockLocation.hash = '' // No tokens = error
await router.push('/auth/callback')
await router.isReady()
const wrapper = mount(AuthCallbackPage, {
global: { plugins: [router] },
})
await flushPromises()
const button = wrapper.find('button')
expect(button.exists()).toBe(true)
expect(button.text()).toContain('Back to Login')
await button.trigger('click')
await flushPromises()
expect(router.currentRoute.value.name).toBe('Login')
})
})
})

View File

@ -3,35 +3,235 @@
* OAuth callback page.
*
* Handles the redirect from OAuth providers (Google, Discord).
* Extracts tokens from URL fragment and stores them in auth store.
* Extracts tokens from URL fragment, stores them, fetches user
* profile, and redirects to the appropriate destination.
*
* Success URL format: /auth/callback#access_token={token}&refresh_token={token}&expires_in={seconds}
* Error URL format: /auth/callback?error={code}&message={message}
*/
import { onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ref, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { apiClient } from '@/api/client'
import type { User } from '@/stores/auth'
interface UserResponse {
id: string
display_name: string
avatar_url: string | null
has_starter_deck: boolean
}
const router = useRouter()
const route = useRoute()
const auth = useAuthStore()
const status = ref<'processing' | 'error'>('processing')
const errorMessage = ref<string | null>(null)
/**
* Parse tokens from URL hash fragment.
*
* The backend redirects with tokens in the fragment (after #) for security,
* since fragments are not sent to servers in HTTP requests.
*/
function parseTokensFromHash(): { accessToken: string; refreshToken: string; expiresIn: number } | null {
const hash = window.location.hash.substring(1) // Remove leading #
if (!hash) return null
const params = new URLSearchParams(hash)
const accessToken = params.get('access_token')
const refreshToken = params.get('refresh_token')
const expiresIn = params.get('expires_in')
if (!accessToken || !refreshToken || !expiresIn) {
return null
}
return {
accessToken,
refreshToken,
expiresIn: parseInt(expiresIn, 10),
}
}
/**
* Check for error in URL query params.
*/
function parseErrorFromQuery(): { error: string; message: string } | null {
const error = route.query.error as string | undefined
const message = route.query.message as string | undefined
if (error) {
return {
error,
message: message || 'Authentication failed. Please try again.',
}
}
return null
}
/**
* Fetch user profile from API.
*/
async function fetchUserProfile(): Promise<UserResponse> {
return apiClient.get<UserResponse>('/api/users/me')
}
/**
* Transform API response to auth store User type.
*/
function transformUser(response: UserResponse): User {
return {
id: response.id,
displayName: response.display_name,
avatarUrl: response.avatar_url,
hasStarterDeck: response.has_starter_deck,
}
}
/**
* Handle the OAuth callback process.
*/
async function handleCallback(): Promise<void> {
// First, check for errors from failed OAuth
const errorInfo = parseErrorFromQuery()
if (errorInfo) {
// Redirect back to login with error message
router.push({
name: 'Login',
query: { error: errorInfo.error, message: errorInfo.message },
})
return
}
// Parse tokens from URL fragment
const tokens = parseTokensFromHash()
if (!tokens) {
status.value = 'error'
errorMessage.value = 'Invalid authentication response. Missing tokens.'
return
}
try {
// Store tokens in auth store
auth.setTokens({
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken,
expiresAt: Date.now() + tokens.expiresIn * 1000,
})
// Clear the hash from URL for security (tokens shouldn't linger in browser history)
window.history.replaceState(null, '', window.location.pathname)
// Fetch user profile
const userResponse = await fetchUserProfile()
const user = transformUser(userResponse)
auth.setUser(user)
// Redirect based on starter deck status
if (!user.hasStarterDeck) {
router.push({ name: 'StarterSelection' })
} else {
// Check if there was an intended destination before login
const redirect = route.query.redirect as string | undefined
if (redirect && redirect.startsWith('/') && !redirect.startsWith('//')) {
router.push(redirect)
} else {
router.push({ name: 'Dashboard' })
}
}
} catch (error) {
status.value = 'error'
if (error instanceof Error) {
errorMessage.value = error.message
} else {
errorMessage.value = 'Failed to complete authentication. Please try again.'
}
}
}
onMounted(() => {
// TODO: Extract tokens from URL hash fragment
// TODO: Store tokens in auth store
// TODO: Fetch user profile
// TODO: Redirect to intended destination or home
// Placeholder: redirect to home after brief delay
setTimeout(() => {
router.push('/')
}, 1000)
handleCallback()
})
function goToLogin(): void {
router.push({ name: 'Login' })
}
</script>
<template>
<div class="flex min-h-screen items-center justify-center">
<div class="text-center">
<div class="mb-4 text-lg">
<div class="flex flex-col items-center justify-center min-h-[50vh] p-4">
<!-- Processing state -->
<div
v-if="status === 'processing'"
class="text-center"
>
<div class="mb-4">
<svg
class="animate-spin h-8 w-8 text-primary mx-auto"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
/>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
</div>
<div class="text-lg font-medium">
Completing login...
</div>
<div class="text-sm text-gray-500">
Please wait
<div class="text-sm text-gray-400 mt-2">
Please wait while we set up your session
</div>
</div>
<!-- Error state -->
<div
v-else-if="status === 'error'"
class="text-center max-w-md"
>
<div class="mb-4">
<svg
class="h-12 w-12 text-error mx-auto"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
</div>
<div class="text-lg font-medium text-error mb-2">
Authentication Failed
</div>
<div class="text-sm text-gray-400 mb-6">
{{ errorMessage }}
</div>
<button
class="px-6 py-2 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors"
@click="goToLogin"
>
Back to Login
</button>
</div>
</div>
</template>