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
71 changes: 60 additions & 11 deletions src/pages/AuthCallback.vue
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
<template>
<q-page class="flex flex-center">
<q-spinner size="50px" />
</q-page>
<div class="wiz-page">
<div class="auth-loading">
<q-spinner size="32px" color="primary" />
<span>Signing in…</span>
</div>
</div>
</template>

<script setup>
Expand All @@ -10,29 +13,75 @@ import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '../stores/auth.js'
import { exchangeGitlabCode } from '../services/gitlab.js'

const STATE_KEY = 'github_oauth_state'

const route = useRoute()
const router = useRouter()
const auth = useAuthStore()

onMounted(async () => {
if (route.path.includes('github')) {
const token = route.query.token
if (token) {
await auth.loginWithToken('github', token)
router.push('/repos')
} else {
const state = route.query.state

// Popup mode: send result to opener and close.
// Use '*' as targetOrigin — the state parameter is the CSRF guard,
// so this is safe even when the parent is on a different origin (local dev).
if (window.opener) {
window.opener.postMessage(
token
? { type: 'github-oauth-callback', token, state }
: { type: 'github-oauth-callback', error: 'no_token' },
'*',
)
window.close()
return
}

// Fallback: popup was blocked, running as a full-page redirect
const storedState = sessionStorage.getItem(STATE_KEY)
sessionStorage.removeItem(STATE_KEY)

if (!token) {
router.push('/login?error=no_token')
return
}

if (!state || state !== storedState) {
router.push('/login?error=invalid_state')
return
}

await auth.loginWithToken('github', token)
router.push('/repos')
} else if (route.path.includes('gitlab')) {
const code = route.query.code
if (code) {
const gitlabHost = sessionStorage.getItem('gitlab_host') || 'gitlab.com'
if (!code) {
router.push('/login?error=no_code')
return
}
try {
const { access_token } = await exchangeGitlabCode(code)
if (!access_token) throw new Error('no token')
const gitlabHost = sessionStorage.getItem('gitlab_host') || 'gitlab.com'
await auth.loginWithToken('gitlab', access_token, gitlabHost)
router.push('/repos')
} else {
router.push('/login?error=no_code')
} catch {
router.push('/login?error=no_token')
}
}
})
</script>

<style lang="scss" scoped>
.auth-loading {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
font-size: 12px;
color: #444;
}
</style>
79 changes: 70 additions & 9 deletions src/pages/LoginPage.vue
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
<div class="wiz-page__body">

<!-- Error banner -->
<div v-if="errorMessage" class="login-error">⚠ {{ errorMessage }}</div>
<div v-if="errorMessage || oauthError" class="login-error">⚠ {{ errorMessage || oauthError }}</div>

<!-- ── Provider section ─────────────────────────────────────── -->
<div class="provider-section">
Expand Down Expand Up @@ -110,9 +110,11 @@ const router = useRouter()
const nav = useWizardNav()
const auth = useAuthStore()
const gitlabHost = ref('gitlab.com')
const dropdownOpen = ref(false)
const dropdownRef = ref(null)
const dropdownOpen = ref(false)
const dropdownRef = ref(null)
const pendingGitlab = ref(false)
const oauthError = ref(null)
const popupCleanup = ref(null)

const providers = [
{ id: 'github', name: 'GitHub', icon: 'fab fa-github' },
Expand All @@ -132,10 +134,24 @@ const signedInProviders = computed(() => {
const showHostBox = computed(() => pendingGitlab.value || auth.provider === 'gitlab')

onMounted(() => {
// If the worker redirected an OAuth popup back here with an error,
// forward it to the parent window and close the popup.
if (window.opener && route.query.error) {
window.opener.postMessage(
{ type: 'github-oauth-callback', error: route.query.error },
'*',
)
window.close()
return
}

updateNav()
document.addEventListener('click', onDocClick)
})
onBeforeUnmount(() => document.removeEventListener('click', onDocClick))
onBeforeUnmount(() => {
document.removeEventListener('click', onDocClick)
popupCleanup.value?.()
})
watch(() => auth.isLoggedIn, updateNav)

function updateNav() {
Expand All @@ -155,8 +171,9 @@ function updateNav() {
const errorMessage = computed(() => {
if (!route.query.error) return null
const map = {
no_token: 'Authentication failed: no token received.',
no_code: 'Authentication failed: no code received.',
no_token: 'Authentication failed: no token received.',
no_code: 'Authentication failed: no code received.',
invalid_state: 'Authentication failed: state mismatch — possible CSRF attack.',
}
return map[route.query.error] ?? `Authentication error: ${route.query.error}`
})
Expand All @@ -182,11 +199,55 @@ async function signIn(providerId) {
}

function loginGithub() {
oauthError.value = null
const state = crypto.randomUUID()
sessionStorage.setItem('github_oauth_state', state)

const workerUrl = process.env.WORKER_URL
const clientId = process.env.GITHUB_CLIENT_ID
const redirectUri = `${workerUrl}/auth/github/callback`
const params = new URLSearchParams({ client_id: clientId, scope: 'repo user', redirect_uri: redirectUri })
window.location.href = `https://github.com/login/oauth/authorize?${params}`
const redirectUri = `${workerUrl}/callback/github`
const params = new URLSearchParams({ client_id: clientId, scope: 'repo user', redirect_uri: redirectUri, state })
const authUrl = `https://github.com/login/oauth/authorize?${params}`

const popup = window.open(authUrl, 'github-oauth', 'width=600,height=700,popup=1')

if (!popup) {
// popup was blocked — fall back to full-page redirect
window.location.href = authUrl
return
}

function onMessage(event) {
if (event.data?.type !== 'github-oauth-callback') return
cleanup()

const { token, state: returnedState, error } = event.data
const storedState = sessionStorage.getItem('github_oauth_state')
sessionStorage.removeItem('github_oauth_state')

if (error) {
oauthError.value = `Authentication failed: ${error.replaceAll('_', ' ')}.`
return
}

if (!token || returnedState !== storedState) {
oauthError.value = 'Authentication failed: invalid response from GitHub.'
return
}

auth.loginWithToken('github', token)
}

const timer = setInterval(() => { if (popup.closed) cleanup() }, 500)

function cleanup() {
clearInterval(timer)
window.removeEventListener('message', onMessage)
popupCleanup.value = null
}

popupCleanup.value = cleanup
window.addEventListener('message', onMessage)
}

async function loginGitlab() {
Expand Down
Loading