diff --git a/frontend/tests/e2e/user/oauth-authorize.spec.ts b/frontend/tests/e2e/user/oauth-authorize.spec.ts
new file mode 100644
index 000000000..908e9ae3f
--- /dev/null
+++ b/frontend/tests/e2e/user/oauth-authorize.spec.ts
@@ -0,0 +1,80 @@
+import {createHash, randomBytes} from 'crypto'
+import {test, expect} from '../../support/fixtures'
+import {UserFactory} from '../../factories/user'
+import {setupApiUrl} from '../../support/authenticateUser'
+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}) => {
+ await setupApiUrl(page)
+
+ // 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 as a frontend route with OAuth query params.
+ // The OAuthAuthorize.vue component reads these and POSTs to the API.
+ const authorizeParams = new URLSearchParams({
+ response_type: 'code',
+ client_id: 'vikunja',
+ redirect_uri: 'vikunja-flutter://callback',
+ code_challenge: codeChallenge,
+ code_challenge_method: 'S256',
+ state,
+ })
+
+ // Navigate to the OAuth authorize frontend route.
+ // The user is not logged in, so the router guard saves the route
+ // and redirects to /login.
+ await page.goto(`/oauth/authorize?${authorizeParams}`)
+ await expect(page).toHaveURL(/\/login/)
+
+ // Register the response listener BEFORE clicking Login, because after
+ // login redirectIfSaved() navigates back to /oauth/authorize and the
+ // component immediately POSTs to the API.
+ const authorizeResponsePromise = page.waitForResponse(
+ response => response.url().includes('/api/v1/oauth/authorize') && response.request().method() === 'POST',
+ {timeout: 15000},
+ )
+
+ // 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()
+
+ // Wait for the authorize API call that fires after login redirect
+ const authorizeResponse = await authorizeResponsePromise
+ const authorizeBody = await authorizeResponse.json()
+ expect(authorizeBody.code).toBeTruthy()
+ expect(authorizeBody.redirect_uri).toBe('vikunja-flutter://callback')
+ expect(authorizeBody.state).toBe(state)
+
+ const code = authorizeBody.code
+
+ // Exchange the authorization code for tokens
+ const tokenResponse = await apiContext.post('oauth/token', {
+ data: {
+ grant_type: 'authorization_code',
+ code,
+ client_id: 'vikunja',
+ redirect_uri: 'vikunja-flutter://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)
+ })
+})
diff --git a/frontend/tests/e2e/user/session-refresh.spec.ts b/frontend/tests/e2e/user/session-refresh.spec.ts
index 4195da718..325f9ee26 100644
--- a/frontend/tests/e2e/user/session-refresh.spec.ts
+++ b/frontend/tests/e2e/user/session-refresh.spec.ts
@@ -1,14 +1,10 @@
import {test, expect} from '../../support/fixtures'
import {UserFactory} from '../../factories/user'
+import {setupApiUrl} from '../../support/authenticateUser'
import {TEST_PASSWORD} from '../../support/constants'
async function loginViaBrowser(page, username: string) {
- // Set the API URL so the frontend knows where to send requests.
- const apiUrl = process.env.API_URL || 'http://127.0.0.1:3456/api/v1'
- await page.addInitScript(({apiUrl}) => {
- window.localStorage.setItem('API_URL', apiUrl)
- window.API_URL = apiUrl
- }, {apiUrl})
+ await setupApiUrl(page)
await page.goto('/login')
await page.locator('input[id=username]').fill(username)
diff --git a/frontend/tests/support/authenticateUser.ts b/frontend/tests/support/authenticateUser.ts
index 50307b182..73d603221 100644
--- a/frontend/tests/support/authenticateUser.ts
+++ b/frontend/tests/support/authenticateUser.ts
@@ -2,6 +2,19 @@ import type {Page, APIRequestContext} from '@playwright/test'
import {UserFactory} from '../factories/user'
import {TEST_PASSWORD} from './constants'
+/**
+ * Sets up the API URL in the page's localStorage and window so the frontend
+ * knows where to send requests. Call this before navigating to any page.
+ */
+export async function setupApiUrl(page: Page) {
+ // Use 127.0.0.1 instead of localhost to match the frontend's origin for CORS
+ const apiUrl = process.env.API_URL || 'http://127.0.0.1:3456/api/v1'
+ await page.addInitScript(({apiUrl}) => {
+ window.localStorage.setItem('API_URL', apiUrl)
+ window.API_URL = apiUrl
+ }, {apiUrl})
+}
+
/**
* This authenticates a user and puts the token in local storage which allows us to perform authenticated requests.
* Returns the user and token for use in tests that need to make authenticated API calls.
@@ -28,15 +41,10 @@ export async function login(page: Page | null, apiContext: APIRequestContext, us
// Set token and API_URL before navigating (only if page is provided)
if (page) {
- // Use 127.0.0.1 instead of localhost to match the frontend's origin for CORS
- const apiUrl = process.env.API_URL || 'http://127.0.0.1:3456/api/v1'
- await page.addInitScript(({token, apiUrl}) => {
- // Set both localStorage AND window.API_URL
- // The app uses window.API_URL for initialization (in base.ts loadApp)
+ await setupApiUrl(page)
+ await page.addInitScript(({token}) => {
window.localStorage.setItem('token', token)
- window.localStorage.setItem('API_URL', apiUrl)
- window.API_URL = apiUrl
- }, {token, apiUrl})
+ }, {token})
}
return {user, token}
diff --git a/pkg/modules/auth/oauth2server/client_test.go b/pkg/modules/auth/oauth2server/client_test.go
new file mode 100644
index 000000000..328e1fb55
--- /dev/null
+++ b/pkg/modules/auth/oauth2server/client_test.go
@@ -0,0 +1,50 @@
+// 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 .
+
+package oauth2server
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestValidateRedirectURI(t *testing.T) {
+ t.Run("accepts vikunja-flutter scheme", func(t *testing.T) {
+ assert.True(t, ValidateRedirectURI("vikunja-flutter://callback"))
+ })
+ t.Run("accepts vikunja-desktop scheme", func(t *testing.T) {
+ assert.True(t, ValidateRedirectURI("vikunja-desktop://auth"))
+ })
+ t.Run("rejects https scheme", func(t *testing.T) {
+ assert.False(t, ValidateRedirectURI("https://evil.com/callback"))
+ })
+ t.Run("rejects http scheme", func(t *testing.T) {
+ assert.False(t, ValidateRedirectURI("http://localhost/callback"))
+ })
+ t.Run("rejects javascript scheme", func(t *testing.T) {
+ assert.False(t, ValidateRedirectURI("javascript:alert(1)"))
+ })
+ t.Run("rejects data scheme", func(t *testing.T) {
+ assert.False(t, ValidateRedirectURI("data:text/html,"))
+ })
+ t.Run("rejects non-vikunja custom scheme", func(t *testing.T) {
+ assert.False(t, ValidateRedirectURI("myapp://callback"))
+ })
+ t.Run("rejects empty URI", func(t *testing.T) {
+ assert.False(t, ValidateRedirectURI(""))
+ })
+}
diff --git a/pkg/modules/auth/oauth2server/pkce_test.go b/pkg/modules/auth/oauth2server/pkce_test.go
new file mode 100644
index 000000000..d7c55cc76
--- /dev/null
+++ b/pkg/modules/auth/oauth2server/pkce_test.go
@@ -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 .
+
+package oauth2server
+
+import (
+ "crypto/sha256"
+ "encoding/base64"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestVerifyPKCE(t *testing.T) {
+ t.Run("valid S256 verifier", func(t *testing.T) {
+ verifier := "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
+ h := sha256.Sum256([]byte(verifier))
+ challenge := base64.RawURLEncoding.EncodeToString(h[:])
+
+ assert.True(t, VerifyPKCE(verifier, challenge, "S256"))
+ })
+
+ t.Run("wrong verifier", func(t *testing.T) {
+ verifier := "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
+ h := sha256.Sum256([]byte(verifier))
+ challenge := base64.RawURLEncoding.EncodeToString(h[:])
+
+ assert.False(t, VerifyPKCE("wrong-verifier", challenge, "S256"))
+ })
+
+ t.Run("unsupported method", func(t *testing.T) {
+ assert.False(t, VerifyPKCE("verifier", "challenge", "plain"))
+ })
+
+ t.Run("empty method", func(t *testing.T) {
+ assert.False(t, VerifyPKCE("verifier", "challenge", ""))
+ })
+}
diff --git a/pkg/webtests/oauth2_test.go b/pkg/webtests/oauth2_test.go
new file mode 100644
index 000000000..b1ec97eb3
--- /dev/null
+++ b/pkg/webtests/oauth2_test.go
@@ -0,0 +1,308 @@
+// 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 .
+
+package webtests
+
+import (
+ "bytes"
+ "crypto/sha256"
+ "encoding/base64"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "code.vikunja.io/api/pkg/modules/auth"
+ "code.vikunja.io/api/pkg/modules/auth/oauth2server"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// authorizeRequestBody builds a JSON body for the authorize endpoint.
+func authorizeRequestBody(responseType, clientID, redirectURI, codeChallenge, codeChallengeMethod, state string) []byte {
+ body, _ := json.Marshal(map[string]string{ //nolint:errchkjson
+ "response_type": responseType,
+ "client_id": clientID,
+ "redirect_uri": redirectURI,
+ "code_challenge": codeChallenge,
+ "code_challenge_method": codeChallengeMethod,
+ "state": state,
+ })
+ return body
+}
+
+// doAuthorize performs a POST to /api/v1/oauth/authorize with the given JWT token and returns the recorder.
+func doAuthorize(e http.Handler, token string, body []byte) *httptest.ResponseRecorder {
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodPost, "/api/v1/oauth/authorize", bytes.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("Authorization", "Bearer "+token)
+ e.ServeHTTP(rec, req)
+ return rec
+}
+
+// doTokenRequest performs a JSON POST to /api/v1/oauth/token and returns the recorder.
+func doTokenRequest(e http.Handler, params map[string]string) *httptest.ResponseRecorder {
+ body, _ := json.Marshal(params) //nolint:errchkjson
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodPost, "/api/v1/oauth/token", bytes.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+ e.ServeHTTP(rec, req)
+ return rec
+}
+
+func TestOAuth2AuthorizeEndpoint(t *testing.T) {
+ t.Run("rejects unauthenticated request", func(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+
+ body := authorizeRequestBody("code", "vikunja", "vikunja-flutter://callback", "abc123", "S256", "teststate")
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodPost, "/api/v1/oauth/authorize", bytes.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+ e.ServeHTTP(rec, req)
+ assert.Equal(t, http.StatusUnauthorized, rec.Code)
+ })
+
+ t.Run("issues code for authenticated user", func(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+
+ token, err := auth.NewUserJWTAuthtoken(&testuser1, "test-session-id")
+ require.NoError(t, err)
+
+ body := authorizeRequestBody("code", "vikunja", "vikunja-flutter://callback", "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM", "S256", "teststate")
+ rec := doAuthorize(e, token, body)
+
+ require.Equal(t, http.StatusOK, rec.Code)
+
+ var resp oauth2server.AuthorizeResponse
+ err = json.Unmarshal(rec.Body.Bytes(), &resp)
+ require.NoError(t, err)
+ assert.NotEmpty(t, resp.Code)
+ assert.Equal(t, "vikunja-flutter://callback", resp.RedirectURI)
+ assert.Equal(t, "teststate", resp.State)
+ })
+
+ t.Run("rejects invalid redirect_uri", func(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+
+ token, err := auth.NewUserJWTAuthtoken(&testuser1, "test-session-id")
+ require.NoError(t, err)
+
+ body := authorizeRequestBody("code", "vikunja", "https://evil.com/callback", "test", "S256", "")
+ rec := doAuthorize(e, token, body)
+ assert.Equal(t, http.StatusBadRequest, rec.Code)
+ })
+
+ t.Run("rejects missing PKCE", func(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+
+ token, err := auth.NewUserJWTAuthtoken(&testuser1, "test-session-id")
+ require.NoError(t, err)
+
+ body := authorizeRequestBody("code", "vikunja", "vikunja-flutter://callback", "", "", "")
+ rec := doAuthorize(e, token, body)
+ assert.Equal(t, http.StatusBadRequest, rec.Code)
+ })
+}
+
+// getAuthorizationCode performs the authorize step and returns the code from the JSON response.
+func getAuthorizationCode(t *testing.T, e http.Handler, codeChallenge, state string) string {
+ t.Helper()
+
+ token, err := auth.NewUserJWTAuthtoken(&testuser1, "test-session-id")
+ require.NoError(t, err)
+
+ body := authorizeRequestBody("code", "vikunja", "vikunja-flutter://callback", codeChallenge, "S256", state)
+ rec := doAuthorize(e, token, body)
+ require.Equal(t, http.StatusOK, rec.Code)
+
+ var resp oauth2server.AuthorizeResponse
+ err = json.Unmarshal(rec.Body.Bytes(), &resp)
+ require.NoError(t, err)
+ require.NotEmpty(t, resp.Code)
+
+ return resp.Code
+}
+
+func TestOAuth2TokenEndpoint(t *testing.T) {
+ t.Run("full authorization code flow with PKCE", func(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+
+ codeVerifier := "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
+ h := sha256.Sum256([]byte(codeVerifier))
+ codeChallenge := base64.RawURLEncoding.EncodeToString(h[:])
+
+ code := getAuthorizationCode(t, e, codeChallenge, "xyz")
+
+ rec := doTokenRequest(e, map[string]string{
+ "grant_type": "authorization_code",
+ "code": code,
+ "client_id": "vikunja",
+ "redirect_uri": "vikunja-flutter://callback",
+ "code_verifier": codeVerifier,
+ })
+
+ require.Equal(t, http.StatusOK, rec.Code)
+
+ var tokenResp oauth2server.TokenResponse
+ err = json.Unmarshal(rec.Body.Bytes(), &tokenResp)
+ require.NoError(t, err)
+ assert.NotEmpty(t, tokenResp.AccessToken)
+ assert.Equal(t, "bearer", tokenResp.TokenType)
+ assert.NotEmpty(t, tokenResp.RefreshToken)
+ assert.Positive(t, tokenResp.ExpiresIn)
+ })
+
+ t.Run("code is single-use", func(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+
+ codeVerifier := "test-verifier-for-single-use-check"
+ h := sha256.Sum256([]byte(codeVerifier))
+ codeChallenge := base64.RawURLEncoding.EncodeToString(h[:])
+
+ code := getAuthorizationCode(t, e, codeChallenge, "")
+
+ tokenParams := map[string]string{
+ "grant_type": "authorization_code",
+ "code": code,
+ "client_id": "vikunja",
+ "redirect_uri": "vikunja-flutter://callback",
+ "code_verifier": codeVerifier,
+ }
+
+ // First exchange succeeds
+ rec := doTokenRequest(e, tokenParams)
+ require.Equal(t, http.StatusOK, rec.Code)
+
+ // Second exchange fails
+ rec2 := doTokenRequest(e, tokenParams)
+ assert.Equal(t, http.StatusBadRequest, rec2.Code)
+ })
+
+ t.Run("rejects wrong PKCE verifier", func(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+
+ codeVerifier := "correct-verifier"
+ h := sha256.Sum256([]byte(codeVerifier))
+ codeChallenge := base64.RawURLEncoding.EncodeToString(h[:])
+
+ code := getAuthorizationCode(t, e, codeChallenge, "")
+
+ rec := doTokenRequest(e, map[string]string{
+ "grant_type": "authorization_code",
+ "code": code,
+ "client_id": "vikunja",
+ "redirect_uri": "vikunja-flutter://callback",
+ "code_verifier": "wrong-verifier",
+ })
+ assert.Equal(t, http.StatusBadRequest, rec.Code)
+ })
+
+ t.Run("rejects invalid grant_type", func(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+
+ rec := doTokenRequest(e, map[string]string{
+ "grant_type": "password",
+ "client_id": "vikunja",
+ })
+ assert.Equal(t, http.StatusBadRequest, rec.Code)
+ })
+
+ t.Run("refresh token flow", func(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+
+ codeVerifier := "refresh-flow-test-verifier"
+ h := sha256.Sum256([]byte(codeVerifier))
+ codeChallenge := base64.RawURLEncoding.EncodeToString(h[:])
+
+ code := getAuthorizationCode(t, e, codeChallenge, "")
+
+ rec := doTokenRequest(e, map[string]string{
+ "grant_type": "authorization_code",
+ "code": code,
+ "client_id": "vikunja",
+ "redirect_uri": "vikunja-flutter://callback",
+ "code_verifier": codeVerifier,
+ })
+ require.Equal(t, http.StatusOK, rec.Code)
+
+ var tokenResp oauth2server.TokenResponse
+ _ = json.Unmarshal(rec.Body.Bytes(), &tokenResp)
+
+ // Use the refresh token to get new tokens
+ rec2 := doTokenRequest(e, map[string]string{
+ "grant_type": "refresh_token",
+ "refresh_token": tokenResp.RefreshToken,
+ "client_id": "vikunja",
+ })
+
+ require.Equal(t, http.StatusOK, rec2.Code)
+
+ var refreshResp oauth2server.TokenResponse
+ err = json.Unmarshal(rec2.Body.Bytes(), &refreshResp)
+ require.NoError(t, err)
+ assert.NotEmpty(t, refreshResp.AccessToken)
+ assert.NotEmpty(t, refreshResp.RefreshToken)
+ assert.NotEqual(t, tokenResp.RefreshToken, refreshResp.RefreshToken)
+ })
+
+ t.Run("refresh token rotation prevents replay", func(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+
+ codeVerifier := "replay-test-verifier"
+ h := sha256.Sum256([]byte(codeVerifier))
+ codeChallenge := base64.RawURLEncoding.EncodeToString(h[:])
+
+ code := getAuthorizationCode(t, e, codeChallenge, "")
+
+ rec := doTokenRequest(e, map[string]string{
+ "grant_type": "authorization_code",
+ "code": code,
+ "client_id": "vikunja",
+ "redirect_uri": "vikunja-flutter://callback",
+ "code_verifier": codeVerifier,
+ })
+
+ var tokenResp oauth2server.TokenResponse
+ _ = json.Unmarshal(rec.Body.Bytes(), &tokenResp)
+ oldRefreshToken := tokenResp.RefreshToken
+
+ refreshParams := map[string]string{
+ "grant_type": "refresh_token",
+ "refresh_token": oldRefreshToken,
+ "client_id": "vikunja",
+ }
+
+ // First refresh succeeds
+ rec2 := doTokenRequest(e, refreshParams)
+ require.Equal(t, http.StatusOK, rec2.Code)
+
+ // Replay the same old refresh token — should fail
+ rec3 := doTokenRequest(e, refreshParams)
+ assert.Equal(t, http.StatusUnauthorized, rec3.Code)
+ })
+}