vikunja/frontend/tests/e2e/user/oauth-authorize.spec.ts

157 lines
6.5 KiB
TypeScript

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 redirects to /login while
// carrying the authorize destination in a copyable #redirect= hash (not a
// query param, to keep the OAuth params out of access logs).
await page.goto(`/oauth/authorize?${authorizeParams}`)
await expect(page).toHaveURL(/\/login#redirect=/)
// The decoded #redirect= destination must carry the full authorize URL, including the
// OAuth params — checking only for the path would pass even if the query were dropped.
const redirectHash = decodeURIComponent(new URL(page.url()).hash)
expect(redirectHash).toContain('/oauth/authorize')
expect(redirectHash).toContain('response_type=code')
expect(redirectHash).toContain('client_id=vikunja')
expect(redirectHash).toContain(`code_challenge=${codeChallenge}`)
expect(redirectHash).toContain(`state=${state}`)
// 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)
})
// The primary #2654 scenario: the native client opened a different default browser that is
// already signed in to Vikunja. Opening the copied /login#redirect=<oauth.authorize> URL must
// run the OAuth flow with the existing session instead of short-circuiting to home.
test('Already-authenticated browser opening the copied login redirect runs the authorize flow', async ({authenticatedPage, apiContext, currentUser}) => {
const page = authenticatedPage
const codeVerifier = randomBytes(32).toString('base64url')
const codeChallenge = createHash('sha256').update(codeVerifier).digest('base64url')
const state = randomBytes(16).toString('base64url')
const authorizeParams = new URLSearchParams({
response_type: 'code',
client_id: 'vikunja',
redirect_uri: 'vikunja-flutter://callback',
code_challenge: codeChallenge,
code_challenge_method: 'S256',
state,
})
// The component POSTs as soon as it mounts with the existing session, so register the
// listener before navigating.
const authorizeResponsePromise = page.waitForResponse(
response => response.url().includes('/api/v1/oauth/authorize') && response.request().method() === 'POST',
{timeout: 15000},
)
// Open the copyable login URL exactly as it would be pasted from another browser
// (#redirect= is REDIRECT_HASH_PREFIX from @/constants/redirectHash, inlined here because
// the e2e runner has no @ alias).
const redirectDestination = `/oauth/authorize?${authorizeParams}`
await page.goto(`/login#redirect=${encodeURIComponent(redirectDestination)}`)
// The authed guard must send us straight to /oauth/authorize, not home.
await expect(page).toHaveURL(/\/oauth\/authorize/)
const landed = new URL(page.url())
expect(landed.pathname).toBe('/oauth/authorize')
expect(landed.searchParams.get('response_type')).toBe('code')
expect(landed.searchParams.get('client_id')).toBe('vikunja')
expect(landed.searchParams.get('code_challenge')).toBe(codeChallenge)
expect(landed.searchParams.get('state')).toBe(state)
// The PKCE flow completes with the existing session — no second login.
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 tokenResponse = await apiContext.post('oauth/token', {
data: {
grant_type: 'authorization_code',
code: authorizeBody.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)
})
})