Skip to content
Draft
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
20 changes: 19 additions & 1 deletion frontend/src/views/user/Login.vue
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@
<script setup lang="ts">
import {computed, onBeforeMount, ref} from 'vue'
import {useI18n} from 'vue-i18n'
import {useRouter} from 'vue-router'
import {useRoute, useRouter} from 'vue-router'
import {useDebounceFn} from '@vueuse/core'

import Message from '@/components/misc/Message.vue'
Expand All @@ -144,6 +144,7 @@ import {useTitle} from '@/composables/useTitle'
const {t} = useI18n({useScope: 'global'})
useTitle(() => t('user.auth.login'))

const route = useRoute()
const router = useRouter()
const authStore = useAuthStore()
const configStore = useConfigStore()
Expand Down Expand Up @@ -217,6 +218,23 @@ async function submit() {
try {
await authStore.login(credentials)
authStore.setNeedsTotpPasscode(false)

// Check for OAuth redirect parameter (used when returning from OAuth authorize)
const redirectParam = route.query.redirect as string
if (redirectParam) {
try {
const redirectUrl = new URL(redirectParam)
const appUrl = new URL(window.location.origin)
// Only allow redirects to same origin to prevent open redirect attacks
if (redirectUrl.origin === appUrl.origin) {
window.location.href = redirectParam
return
}
} catch {
// Invalid URL, fall through to normal redirect
}
}

redirectIfSaved()
} catch (e) {
if (e.response?.data.code === 1017 && !credentials.totpPasscode) {
Expand Down
19 changes: 18 additions & 1 deletion frontend/src/views/user/OpenIdAuth.vue
Original file line number Diff line number Diff line change
Expand Up @@ -80,8 +80,25 @@ async function authenticateWithCode() {
provider: route.params.provider,
code: route.query.code,
})

// Check for OAuth redirect parameter (used when returning from OAuth authorize)
const redirectParam = route.query.redirect as string
if (redirectParam) {
try {
const redirectUrl = new URL(redirectParam)
const appUrl = new URL(window.location.origin)
// Only allow redirects to same origin to prevent open redirect attacks
if (redirectUrl.origin === appUrl.origin) {
window.location.href = redirectParam
return
}
} catch {
// Invalid URL, fall through to normal redirect
}
}

redirectIfSaved()
} catch(e) {
} catch (e) {
errorMessage.value = getErrorText(e)
} finally {
localStorage.removeItem('authenticating')
Expand Down
135 changes: 135 additions & 0 deletions frontend/tests/e2e/user/oauth-authorize.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import {createHash, randomBytes} from 'crypto'
import {test, expect} from '../../support/fixtures'
import {UserFactory} from '../../factories/user'
import {TEST_PASSWORD} from '../../support/constants'

test.describe('OAuth 2.0 Authorization Flow', () => {
let username: string

test.beforeEach(async ({apiContext}) => {
const [user] = await UserFactory.create(1)
username = user.username
})

test('Full browser authorization code flow with PKCE', async ({page, apiContext}) => {
const apiUrl = (process.env.API_URL || 'http://127.0.0.1:3456/api/v1').replace(/\/+$/, '')
const frontendBase = (process.env.BASE_URL || 'http://127.0.0.1:4173').replace(/\/+$/, '')

// Set the API URL so the frontend knows where to send requests
await page.addInitScript(({apiUrl}) => {
window.localStorage.setItem('API_URL', apiUrl)
window.API_URL = apiUrl
}, {apiUrl})

// Generate PKCE code_verifier and code_challenge (S256)
const codeVerifier = randomBytes(32).toString('base64url')
const codeChallenge = createHash('sha256').update(codeVerifier).digest('base64url')
const state = randomBytes(16).toString('base64url')

// Build the authorize URL on the frontend's origin so the same-origin
// check in Login.vue passes when it reads the redirect query param.
// In production the API and frontend share an origin; in the E2E test
// they run on different ports, so we route-intercept the request below.
const authorizeParams = new URLSearchParams({
response_type: 'code',
client_id: 'vikunja-flutter',
redirect_uri: 'vikunja://callback',
code_challenge: codeChallenge,
code_challenge_method: 'S256',
state,
})
const authorizeUrl = `${frontendBase}/api/v1/oauth/authorize?${authorizeParams}`

// Capture the JWT from the login API response so the route handler
// can forward it to the real authorize endpoint.
let jwt = ''
const jwtReady = new Promise<void>(resolve => {
page.on('response', async response => {
if (
response.url().includes('/login') &&
response.request().method() === 'POST' &&
response.ok()
) {
try {
const body = await response.json()
jwt = body.token
} catch { /* ignore parse errors */ }
resolve()
}
})
})

// Intercept authorize requests on the frontend origin and proxy them
// to the real API server with the JWT Authorization header.
// This is necessary because the E2E test runs the API and frontend
// on separate ports, while in production they share an origin.
let capturedLocation = ''
let resolveAuthorize: () => void
const authorizeHandled = new Promise<void>(resolve => {
resolveAuthorize = resolve
})

await page.route('**/api/v1/oauth/authorize**', async route => {
// Wait for the JWT to be available from the login response
await jwtReady

const requestUrl = new URL(route.request().url())
const apiResponse = await apiContext.get(
`${apiUrl}/oauth/authorize${requestUrl.search}`,
{
headers: {'Authorization': `Bearer ${jwt}`},
maxRedirects: 0,
},
)

capturedLocation = apiResponse.headers()['location'] || ''

await route.fulfill({
status: apiResponse.status(),
headers: apiResponse.headers(),
})

resolveAuthorize()
})

// Navigate to the frontend login page with the OAuth redirect parameter
await page.goto(`/login?redirect=${encodeURIComponent(authorizeUrl)}`)
await expect(page).toHaveURL(/\/login/)

// Log in via the browser UI
await page.locator('input[id=username]').fill(username)
await page.locator('input[id=password]').fill(TEST_PASSWORD)
await page.locator('.button').filter({hasText: 'Login'}).click()

// After login, Login.vue reads route.query.redirect, validates
// same-origin, and does window.location.href = authorizeURL.
// The route handler intercepts this, proxies to the real API,
// and the API responds with 302 → vikunja://callback?code=...
await authorizeHandled

// Verify the API returned a redirect to vikunja://callback with code and state
expect(capturedLocation).toContain('vikunja://callback')
const callbackUrl = new URL(capturedLocation)
const code = callbackUrl.searchParams.get('code')
expect(code).toBeTruthy()
expect(callbackUrl.searchParams.get('state')).toBe(state)

// Exchange the authorization code for tokens
const tokenResponse = await apiContext.post('oauth/token', {
form: {
grant_type: 'authorization_code',
code: code!,
client_id: 'vikunja-flutter',
redirect_uri: 'vikunja://callback',
code_verifier: codeVerifier,
},
})

expect(tokenResponse.ok()).toBe(true)
const tokenBody = await tokenResponse.json()
expect(tokenBody.access_token).toBeTruthy()
expect(tokenBody.refresh_token).toBeTruthy()
expect(tokenBody.token_type).toBe('bearer')
expect(tokenBody.expires_in).toBeGreaterThan(0)
})
})
2 changes: 2 additions & 0 deletions pkg/db/fixtures/oauth_codes.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
[]

51 changes: 51 additions & 0 deletions pkg/migration/20260226172819.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-present Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.

package migration

import (
"src.techknowlogick.com/xormigrate"
"xorm.io/xorm"
)

type oauthCodes20260226172819 struct {
ID int64 `xorm:"autoincr not null unique pk"`
UserID int64 `xorm:"bigint not null"`
Code string `xorm:"varchar(128) not null unique index"`
ExpiresAt string `xorm:"not null"`
ClientID string `xorm:"varchar(255) not null"`
RedirectURI string `xorm:"text not null"`
CodeChallenge string `xorm:"varchar(128) not null"`
CodeChallengeMethod string `xorm:"varchar(10) not null"`
Created string `xorm:"created not null"`
}

func (oauthCodes20260226172819) TableName() string {
return "oauth_codes"
}

func init() {
migrations = append(migrations, &xormigrate.Migration{
ID: "20260226172819",
Description: "add oauth_codes table for OAuth 2.0 authorization codes",
Migrate: func(tx *xorm.Engine) error {
return tx.Sync(oauthCodes20260226172819{})
},
Rollback: func(tx *xorm.Engine) error {
return tx.DropTables(oauthCodes20260226172819{})
},
})
}
Loading
Loading