From f2424d6a324daf0838e32b3b30b995d2b53ff335 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Raimondas=20Rimkevi=C4=8Dius?= Date: Tue, 28 Apr 2026 20:25:37 +0300 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20fix=20GitHub=20auth=20flow=20?= =?UTF-8?q?=E2=80=94=20state=20CSRF,=20q-page=20removal,=20error=20codes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/AuthCallback.vue | 55 ++++++++++++++++++++++++++++++-------- src/pages/LoginPage.vue | 9 ++++--- 2 files changed, 50 insertions(+), 14 deletions(-) diff --git a/src/pages/AuthCallback.vue b/src/pages/AuthCallback.vue index 4daae8d..bb552ca 100644 --- a/src/pages/AuthCallback.vue +++ b/src/pages/AuthCallback.vue @@ -1,7 +1,10 @@ + + diff --git a/src/pages/LoginPage.vue b/src/pages/LoginPage.vue index 4be0504..6200ff1 100644 --- a/src/pages/LoginPage.vue +++ b/src/pages/LoginPage.vue @@ -155,8 +155,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}` }) @@ -182,10 +183,12 @@ async function signIn(providerId) { } function loginGithub() { + 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 }) + const params = new URLSearchParams({ client_id: clientId, scope: 'repo user', redirect_uri: redirectUri, state }) window.location.href = `https://github.com/login/oauth/authorize?${params}` } From 9b0ef24d0b4aa70088b19774101fe430b0d86694 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Raimondas=20Rimkevi=C4=8Dius?= Date: Tue, 28 Apr 2026 21:01:52 +0300 Subject: [PATCH 2/4] feat: open GitHub OAuth in popup, post token back via postMessage --- src/pages/AuthCallback.vue | 18 +++++++++++-- src/pages/LoginPage.vue | 55 ++++++++++++++++++++++++++++++++++---- 2 files changed, 66 insertions(+), 7 deletions(-) diff --git a/src/pages/AuthCallback.vue b/src/pages/AuthCallback.vue index bb552ca..475af1f 100644 --- a/src/pages/AuthCallback.vue +++ b/src/pages/AuthCallback.vue @@ -22,7 +22,21 @@ const auth = useAuthStore() onMounted(async () => { if (route.path.includes('github')) { const token = route.query.token - const returnedState = route.query.state + const state = route.query.state + + // Popup mode: send result to opener and close + if (window.opener) { + window.opener.postMessage( + token + ? { type: 'github-oauth-callback', token, state } + : { type: 'github-oauth-callback', error: 'no_token' }, + window.location.origin, + ) + window.close() + return + } + + // Fallback: popup was blocked, running as a full-page redirect const storedState = sessionStorage.getItem(STATE_KEY) sessionStorage.removeItem(STATE_KEY) @@ -31,7 +45,7 @@ onMounted(async () => { return } - if (!returnedState || returnedState !== storedState) { + if (!state || state !== storedState) { router.push('/login?error=invalid_state') return } diff --git a/src/pages/LoginPage.vue b/src/pages/LoginPage.vue index 6200ff1..653dd3f 100644 --- a/src/pages/LoginPage.vue +++ b/src/pages/LoginPage.vue @@ -14,7 +14,7 @@
- +
@@ -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' }, @@ -135,7 +137,10 @@ onMounted(() => { updateNav() document.addEventListener('click', onDocClick) }) -onBeforeUnmount(() => document.removeEventListener('click', onDocClick)) +onBeforeUnmount(() => { + document.removeEventListener('click', onDocClick) + popupCleanup.value?.() +}) watch(() => auth.isLoggedIn, updateNav) function updateNav() { @@ -183,13 +188,53 @@ 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, state }) - window.location.href = `https://github.com/login/oauth/authorize?${params}` + 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.origin !== window.location.origin) return + 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 || !token || returnedState !== storedState) { + oauthError.value = error === 'no_token' + ? 'Authentication failed: no token received.' + : '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() { From 962303a231d5e1b95e718d606b26f3576bca1c07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Raimondas=20Rimkevi=C4=8Dius?= Date: Tue, 28 Apr 2026 21:53:32 +0300 Subject: [PATCH 3/4] feat: update GitHub callback path to /callback/github --- src/pages/LoginPage.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/LoginPage.vue b/src/pages/LoginPage.vue index 653dd3f..3896ae8 100644 --- a/src/pages/LoginPage.vue +++ b/src/pages/LoginPage.vue @@ -194,7 +194,7 @@ function loginGithub() { const workerUrl = process.env.WORKER_URL const clientId = process.env.GITHUB_CLIENT_ID - const redirectUri = `${workerUrl}/auth/github/callback` + 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}` From f5cf0daa1d84ac902e339e1c6302717853282ae9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Raimondas=20Rimkevi=C4=8Dius?= Date: Tue, 28 Apr 2026 22:41:45 +0300 Subject: [PATCH 4/4] fix: cross-origin postMessage, popup error propagation --- src/pages/AuthCallback.vue | 6 ++++-- src/pages/LoginPage.vue | 23 ++++++++++++++++++----- 2 files changed, 22 insertions(+), 7 deletions(-) diff --git a/src/pages/AuthCallback.vue b/src/pages/AuthCallback.vue index 475af1f..dc7cca3 100644 --- a/src/pages/AuthCallback.vue +++ b/src/pages/AuthCallback.vue @@ -24,13 +24,15 @@ onMounted(async () => { const token = route.query.token const state = route.query.state - // Popup mode: send result to opener and close + // 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.location.origin, + '*', ) window.close() return diff --git a/src/pages/LoginPage.vue b/src/pages/LoginPage.vue index 3896ae8..7616cfa 100644 --- a/src/pages/LoginPage.vue +++ b/src/pages/LoginPage.vue @@ -134,6 +134,17 @@ 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) }) @@ -207,7 +218,6 @@ function loginGithub() { } function onMessage(event) { - if (event.origin !== window.location.origin) return if (event.data?.type !== 'github-oauth-callback') return cleanup() @@ -215,10 +225,13 @@ function loginGithub() { const storedState = sessionStorage.getItem('github_oauth_state') sessionStorage.removeItem('github_oauth_state') - if (error || !token || returnedState !== storedState) { - oauthError.value = error === 'no_token' - ? 'Authentication failed: no token received.' - : 'Authentication failed: invalid response from GitHub.' + if (error) { + oauthError.value = `Authentication failed: ${error.replaceAll('_', ' ')}.` + return + } + + if (!token || returnedState !== storedState) { + oauthError.value = 'Authentication failed: invalid response from GitHub.' return }