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