Back to Sanity

Complex Authentication Flow Patterns

.agents/skills/playwright-best-practices/advanced/authentication-flows.md

5.20.010.4 KB
Original Source

Complex Authentication Flow Patterns

Table of Contents

  1. Email Verification Flows
  2. Password Reset
  3. Session Timeout
  4. Remember Me Persistence
  5. Logout Patterns
  6. Tips
  7. Related

When to use: Testing email verification, password reset, session timeout/expiration, or remember-me functionality. For basic auth setup (storage state, OAuth mocking, MFA, role-based access), see authentication.md.


Email Verification Flows

Capturing Verification Tokens

Intercept API responses to capture verification tokens for testing:

typescript
test('completes registration with email verification', async ({page}) => {
  let capturedToken = ''

  await page.route('**/api/auth/register', async (route) => {
    const response = await route.fetch()
    const body = await response.json()
    capturedToken = body.verificationToken
    await route.fulfill({response})
  })

  await page.goto('/register')
  await page.getByLabel('Name').fill('New User')
  await page.getByLabel('Email').fill('[email protected]')
  await page.getByLabel('Password', {exact: true}).fill('SecurePass!')
  await page.getByLabel('Confirm password').fill('SecurePass!')
  await page.getByRole('button', {name: 'Create account'}).click()

  await expect(page.getByText('Check your inbox')).toBeVisible()

  expect(capturedToken).toBeTruthy()
  await page.goto(`/verify?token=${capturedToken}`)

  await expect(page.getByText('Email confirmed')).toBeVisible()
})

Fully Mocked Verification

typescript
test('verifies email with mocked endpoints', async ({page}) => {
  const mockToken = 'test-verification-abc123'

  await page.route('**/api/auth/register', async (route) => {
    await route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify({message: 'Verification sent', verificationToken: mockToken}),
    })
  })

  await page.route(`**/api/auth/verify?token=${mockToken}`, async (route) => {
    await route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify({verified: true}),
    })
  })

  await page.goto('/register')
  await page.getByLabel('Email').fill('[email protected]')
  await page.getByLabel('Password', {exact: true}).fill('Password123!')
  await page.getByRole('button', {name: 'Sign up'}).click()

  await expect(page.getByText('Check your inbox')).toBeVisible()

  await page.goto(`/verify?token=${mockToken}`)
  await expect(page.getByText('Email confirmed')).toBeVisible()
})

Password Reset

Complete Reset Flow

typescript
test('resets password through email link', async ({page}) => {
  let resetToken = ''

  await page.route('**/api/auth/forgot-password', async (route) => {
    const response = await route.fetch()
    const body = await response.json()
    resetToken = body.resetToken
    await route.fulfill({response})
  })

  await page.goto('/forgot-password')
  await page.getByLabel('Email').fill('[email protected]')
  await page.getByRole('button', {name: 'Send link'}).click()

  await expect(page.getByText('Reset email sent')).toBeVisible()

  expect(resetToken).toBeTruthy()
  await page.goto(`/reset-password?token=${resetToken}`)

  await page.getByLabel('New password', {exact: true}).fill('NewPassword456!')
  await page.getByLabel('Confirm password').fill('NewPassword456!')
  await page.getByRole('button', {name: 'Update password'}).click()

  await expect(page.getByText('Password updated')).toBeVisible()
})

Expired Token Handling

typescript
test('shows error for expired reset token', async ({page}) => {
  await page.goto('/reset-password?token=expired-token')

  await page.getByLabel('New password', {exact: true}).fill('NewPass!')
  await page.getByLabel('Confirm password').fill('NewPass!')
  await page.getByRole('button', {name: 'Update password'}).click()

  await expect(page.getByRole('alert')).toContainText(/expired|invalid/i)
})

Password Strength Validation

typescript
test('enforces password requirements on reset', async ({page}) => {
  await page.goto('/reset-password?token=valid-token')

  await page.getByLabel('New password', {exact: true}).fill('weak')
  await page.getByLabel('Confirm password').fill('weak')
  await page.getByRole('button', {name: 'Update password'}).click()

  await expect(page.getByText(/at least 8 characters/i)).toBeVisible()
})

Session Timeout

Detecting Expired Sessions

typescript
test('redirects to signin after session expires', async ({page, context}) => {
  await page.goto('/signin')
  await page.getByLabel('Email').fill('[email protected]')
  await page.getByLabel('Password').fill('Password!')
  await page.getByRole('button', {name: 'Sign in'}).click()
  await expect(page).toHaveURL('/home')

  const cookies = await context.cookies()
  const sessionCookie = cookies.find((c) => c.name.includes('session'))

  if (sessionCookie) {
    await context.clearCookies({name: sessionCookie.name})
  }

  await page.goto('/profile')
  await expect(page).toHaveURL(/\/signin/)
  await expect(page.getByText(/session.*expired|sign in again/i)).toBeVisible()
})

Session Extension Warning

typescript
test('shows warning before session expires', async ({page}) => {
  await page.route('**/api/auth/session', async (route) => {
    await route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify({valid: true, expiresIn: 60}),
    })
  })

  await page.goto('/home')

  await expect(page.getByText(/session.*expir/i)).toBeVisible({timeout: 10000})
  await expect(page.getByRole('button', {name: /extend|stay signed in/i})).toBeVisible()
})

Session Extension Action

typescript
test('extends session when user clicks extend', async ({page}) => {
  let sessionExtended = false

  await page.route('**/api/auth/session', async (route) => {
    await route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify({valid: true, expiresIn: 60}),
    })
  })

  await page.route('**/api/auth/refresh', async (route) => {
    sessionExtended = true
    await route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify({valid: true, expiresIn: 3600}),
    })
  })

  await page.goto('/home')

  await expect(page.getByRole('button', {name: /extend|stay signed in/i})).toBeVisible({
    timeout: 10000,
  })
  await page.getByRole('button', {name: /extend|stay signed in/i}).click()

  expect(sessionExtended).toBe(true)
  await expect(page.getByText(/session.*expir/i)).not.toBeVisible()
})

Remember Me Persistence

Persistent Session

typescript
test('persists session with remember me enabled', async ({browser}) => {
  const ctx1 = await browser.newContext()
  const page1 = await ctx1.newPage()

  await page1.goto('/signin')
  await page1.getByLabel('Email').fill('[email protected]')
  await page1.getByLabel('Password').fill('Password!')
  await page1.getByLabel('Keep me signed in').check()
  await page1.getByRole('button', {name: 'Sign in'}).click()

  await expect(page1).toHaveURL('/home')

  const state = await ctx1.storageState()
  await ctx1.close()

  const ctx2 = await browser.newContext({storageState: state})
  const page2 = await ctx2.newPage()

  await page2.goto('/home')
  await expect(page2).toHaveURL('/home')
  await expect(page2.getByText('Welcome')).toBeVisible()

  await ctx2.close()
})

Session-Only Login

typescript
test('session-only login does not persist across browser restarts', async ({browser}) => {
  const ctx1 = await browser.newContext()
  const page1 = await ctx1.newPage()

  await page1.goto('/signin')
  await page1.getByLabel('Email').fill('[email protected]')
  await page1.getByLabel('Password').fill('Password!')
  // Leave "Remember me" unchecked
  await expect(page1.getByLabel('Keep me signed in')).not.toBeChecked()
  await page1.getByRole('button', {name: 'Sign in'}).click()

  await expect(page1).toHaveURL('/home')

  // Only keep persistent cookies (filter out session cookies)
  const cookies = await ctx1.cookies()
  await ctx1.close()

  const persistentCookies = cookies.filter((c) => c.expires > 0)
  const ctx2 = await browser.newContext()
  await ctx2.addCookies(persistentCookies)
  const page2 = await ctx2.newPage()

  await page2.goto('/home')

  // Should redirect to login since session was not persisted
  await expect(page2).toHaveURL(/\/signin/)

  await ctx2.close()
})

Logout Patterns

Standard Logout with Session Cleanup

typescript
test.use({storageState: '.auth/user.json'})

test('logs out and clears session', async ({page, context}) => {
  await page.goto('/home')

  await page.getByRole('button', {name: /account|menu/i}).click()
  await page.getByRole('menuitem', {name: 'Sign out'}).click()

  await expect(page).toHaveURL('/signin')

  const cookies = await context.cookies()
  const sessionCookies = cookies.filter(
    (c) => c.name.includes('session') || c.name.includes('token'),
  )
  expect(sessionCookies).toHaveLength(0)

  await page.goto('/home')
  await expect(page).toHaveURL(/\/signin/)
})

Logout from All Devices

typescript
test('logs out from all devices', async ({page}) => {
  let logoutAllCalled = false

  await page.route('**/api/auth/logout-all', async (route) => {
    logoutAllCalled = true
    await route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify({message: 'Logged out everywhere'}),
    })
  })

  await page.goto('/settings/security')

  await page.getByRole('button', {name: 'Sign out everywhere'}).click()
  await page.getByRole('dialog').getByRole('button', {name: 'Confirm'}).click()

  expect(logoutAllCalled).toBe(true)
  await expect(page).toHaveURL(/\/signin/)
})

Tips

  1. Configure shorter session timeouts in test environments — Enables testing timeout behavior without slow tests
  2. Test token expiration edge cases — Expired tokens, invalid tokens, already-used tokens
  3. Verify cleanup on logout — Check both cookies and localStorage are cleared
  4. Test the full flow end-to-end — Password reset should verify login with new password works