test: add tests for OAuth 2.0 authorization flow

Add web tests covering the authorize endpoint, token exchange, PKCE
verification, single-use codes, and refresh token rotation. Add unit
tests for redirect URI validation and PKCE. Add E2E test for the full
browser-based authorization code flow with login redirect.

Extract setupApiUrl helper for E2E tests to avoid duplication.
This commit is contained in:
kolaente 2026-03-26 16:32:40 +01:00 committed by kolaente
parent 0471f8a729
commit 649043aceb
6 changed files with 507 additions and 14 deletions

View File

@ -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)
})
})

View File

@ -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)

View File

@ -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}

View File

@ -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 <https://www.gnu.org/licenses/>.
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,<script>alert(1)</script>"))
})
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(""))
})
}

View File

@ -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 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", ""))
})
}

308
pkg/webtests/oauth2_test.go Normal file
View File

@ -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 <https://www.gnu.org/licenses/>.
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)
})
}