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:
parent
0471f8a729
commit
649043aceb
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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(""))
|
||||
})
|
||||
}
|
||||
|
|
@ -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", ""))
|
||||
})
|
||||
}
|
||||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
Loading…
Reference in New Issue