Mục lục
Mục Tiêu Bài Học
Sau khi hoàn thành bài này, bạn sẽ:
- Hiểu tại sao SSO/OAuth không thể test E2E trực tiếp trong hầu hết trường hợp.
- Áp dụng được 3 strategy: mock callback, inject session, mock provider endpoint.
- Viết route intercept cho OAuth callback và token exchange endpoint.
- Biết URL provider của Google, GitHub, Auth0, Okta để mock đúng chỗ.
- Tránh 4 pitfall phổ biến khi mock SSO/OAuth.
Tại Sao SSO/OAuth Khó Test E2E
OAuth flow chuẩn (Authorization Code) gồm các bước:
- App redirect user đến provider (ví dụ
accounts.google.com/o/oauth2/auth?...). - User đăng nhập tại provider — màn hình provider, ngoài tầm kiểm soát của Playwright.
- Provider redirect về app với
codeparameter (/auth/callback?code=...). - App exchange
code→access_tokenqua server-side request đến provider token endpoint. - App tạo session và redirect user vào dashboard.
Bước 2 là nguyên nhân chính khiến E2E test trực tiếp thất bại:
- CAPTCHA — provider phát hiện automation (user-agent, headless fingerprint) và chặn.
- 2FA — tài khoản Google/GitHub cá nhân thường bật 2FA; tài khoản test tắt 2FA thì nhạy cảm về bảo mật.
- Rate limit — provider giới hạn số lần login từ cùng IP; CI runner dùng shared IP dễ bị block.
- External redirect — trình duyệt rời khỏi domain app, Playwright mất context nếu không xử lý đúng.
- Credential sensitive — hardcode Google/GitHub password vào CI config tạo rủi ro.
Kết quả: cần chiến lược mock thay vì gọi provider thật trong phần lớn test. Bài 108 về session refresh đã đề cập cách giữ session sống; bài này tập trung vào cách tạo session từ đầu khi provider là external OAuth.
3 Strategy Tổng Quan
| Strategy | Cơ chế | Test được gì | Phù hợp khi |
|---|---|---|---|
| 1. Mock OAuth callback | Intercept request callback về app, mock response | App logic xử lý callback, token parse, redirect sau login | Cần test callback handler phía app |
| 2. Inject session | Bypass OAuth hoàn toàn, set localStorage/cookie trực tiếp | Feature sau khi đã login (dashboard, profile, ...) | Không cần test OAuth flow, chỉ cần trạng thái logged-in |
| 3. Mock provider endpoint | Intercept cả request đến provider URL, giả lập consent screen | Toàn bộ OAuth flow từ click "Login with Google" đến dashboard | Cần test full flow UI, integration giả lập |
Ba strategy này không loại trừ nhau. Thực tế, một test suite thường kết hợp: strategy 2 cho phần lớn feature test (nhanh, ổn định), strategy 1 hoặc 3 cho một số integration test riêng, và test provider thật (không mock) chỉ trong smoke test environment riêng biệt.
Strategy 1 — Mock OAuth Callback
Strategy này intercept request của callback URL mà provider sẽ redirect về. App nhận callback, xử lý code, exchange token — test kiểm tra đúng behavior của app khi callback đến.
Ý tưởng: thay vì để provider thật redirect về, dùng page.route() mock request đó và trả về response giả lập ngay tại callback URL.
// Intercept request callback của OAuth provider
// App mong đợi callback với ?code= → exchange token → tạo session
await page.route('**/auth/callback*', async (route) => {
// Giả lập provider đã redirect về với code hợp lệ
// App sẽ xử lý code này phía server
await route.fulfill({
status: 302,
headers: { location: '/dashboard?token=mock-jwt' },
});
});
// Trigger OAuth flow như user bình thường
await page.goto('/login');
await page.getByRole('button', { name: 'Login with Google' }).click();
// Lúc này provider thật chưa được gọi vì callback đã bị intercept
// (cần kết hợp với mock provider URL — xem strategy 3)
// Hoặc navigate trực tiếp đến callback URL để test app handler
await page.goto('/auth/callback?code=mock-code&state=mock-state');
await expect(page).toHaveURL('/dashboard');
Cách đơn giản hơn là navigate thẳng đến callback URL — bỏ qua bước provider hoàn toàn, chỉ test phần app xử lý callback:
test('app handles OAuth callback và redirect đến dashboard', async ({ page }) => {
// Mock token exchange: khi app gọi provider để lấy token từ code
await page.route('**/api/auth/token', async (route) => {
await route.fulfill({
json: {
access_token: 'mock-access-token',
token_type: 'Bearer',
expires_in: 3600,
},
});
});
// Navigate thẳng đến callback URL với code giả
await page.goto('/auth/callback?code=mock-code&state=mock-state');
// Kiểm tra app xử lý đúng: redirect về dashboard sau khi exchange token
await expect(page).toHaveURL('/dashboard');
await expect(page.getByText('Welcome')).toBeVisible();
});
Strategy này test được: app parse code và state từ query string đúng không, gọi token exchange endpoint đúng không, lưu token vào đâu, redirect về đâu sau khi xử lý thành công. Không test được: UI provider (consent screen), flow khi user từ chối consent, redirect ban đầu đến đúng provider URL.
Strategy 2 — Inject Session, Bypass OAuth
Strategy này bỏ qua OAuth flow hoàn toàn. Thay vì chạy login flow, test inject trực tiếp session data vào localStorage (hoặc cookie) rồi lưu lại dưới dạng storageState. Đây là cách nhanh nhất và ổn định nhất cho phần lớn feature test.
// tests/auth.setup.ts
import { test as setup } from '@playwright/test';
const AUTH_STATE = 'playwright/.auth/sso-user.json';
setup('mock SSO session', async ({ page }) => {
await page.goto('/');
// Inject auth data vào localStorage — cấu trúc phụ thuộc app của bạn
await page.evaluate(() => {
localStorage.setItem('auth', JSON.stringify({
token: 'mock-token',
user: {
email: '[email protected]',
name: 'Test User',
provider: 'google',
id: 'google-uid-123',
},
expiresAt: Date.now() + 3600_000,
}));
});
// Lưu state để dùng lại trong các test
await page.context().storageState({ path: AUTH_STATE });
});
Sau khi có file sso-user.json, khai báo trong config để các test dùng state này:
// playwright.config.ts
{
name: 'sso-tests',
use: {
storageState: 'playwright/.auth/sso-user.json',
},
dependencies: ['setup'],
testMatch: /sso\..*\.spec\.ts/,
}
Điều kiện để strategy này hoạt động: app phải đọc auth state từ localStorage (hoặc cookie) khi load — nếu app yêu cầu server validate token trong mỗi request, token giả sẽ bị reject. Trong trường hợp đó, cần mock API validation endpoint hoặc dùng token hợp lệ từ test environment.
Nếu app dùng cookie-based session thay vì localStorage:
setup('mock SSO session via cookie', async ({ page, context }) => {
// Set cookie trực tiếp lên context
await context.addCookies([
{
name: 'session',
value: 'mock-session-id',
domain: 'localhost',
path: '/',
httpOnly: true,
secure: false,
},
]);
await page.goto('/dashboard');
// Xác nhận app nhận session hợp lệ
await expect(page).toHaveURL('/dashboard');
await page.context().storageState({ path: AUTH_STATE });
});
Strategy 3 — Mock Toàn Bộ Provider Endpoint
Strategy này mock cả URL của provider (ví dụ accounts.google.com) để Playwright không thực sự ra ngoài internet. Khi app redirect đến provider, route intercept catch request đó và trả về redirect giả về callback URL của app.
const appURL = 'http://localhost:3000';
// Mock consent screen của Google: intercept authorization endpoint
await page.route('https://accounts.google.com/**', async (route) => {
const url = route.request().url();
if (url.includes('/o/oauth2/auth') || url.includes('/o/oauth2/v2/auth')) {
// Giả lập provider đã consent và redirect về app
await route.fulfill({
status: 302,
headers: {
location: `${appURL}/auth/callback?code=mock-code&state=mock-state`,
},
});
} else {
// Pass-through cho các request khác đến Google (ví dụ fonts)
await route.continue();
}
});
// Mock token exchange: app gọi Google để exchange code → token
await page.route('**/auth/callback*', async (route) => {
// Cho app callback handler tự chạy (không mock ở đây)
// App handler sẽ exchange code với token endpoint → xem mục 7
await route.continue();
});
// User click "Login with Google"
await page.goto('/login');
await page.getByRole('button', { name: 'Login with Google' }).click();
// Flow: click → redirect accounts.google.com → intercept → redirect /auth/callback → app xử lý
await expect(page).toHaveURL('/dashboard');
Lưu ý về page.route() với URL provider bên ngoài: mặc định Playwright chỉ intercept request từ trang hiện tại. Nếu app dùng backend redirect (server-side), request đến Google không đi qua browser — Playwright không catch được. Strategy 3 chỉ hoạt động với front-end redirect (JavaScript window.location hoặc link href).
Với app dùng server-side redirect, cần mock ở tầng backend (ví dụ mock server nhận callback, hoặc dùng MSW — Mock Service Worker). Phạm vi bài này tập trung vào front-end route intercept.
Mock Token Exchange Endpoint
Sau khi callback về app với code, app thường gọi một trong hai chỗ để exchange token:
- Provider token endpoint trực tiếp (client-side app):
https://oauth2.googleapis.com/token,https://github.com/login/oauth/access_token. - Backend API của chính app (server-side exchange):
/api/auth/tokenhoặc/api/auth/callback.
Mock endpoint phía app (phổ biến hơn với NextAuth, Auth.js, Passport.js):
await page.route('**/api/auth/token', async (route) => {
await route.fulfill({
json: {
access_token: 'mock-access-token',
token_type: 'Bearer',
expires_in: 3600,
// id_token chứa user info — bài 110 sẽ đi sâu vào JWT structure
id_token: 'mock-id-token',
scope: 'openid email profile',
},
});
});
Mock provider token endpoint trực tiếp (ít gặp hơn):
// Mock Google token endpoint
await page.route('https://oauth2.googleapis.com/token', async (route) => {
await route.fulfill({
json: {
access_token: 'mock-google-access-token',
id_token: 'mock-id-token',
token_type: 'Bearer',
expires_in: 3600,
},
});
});
// Mock GitHub token endpoint
await page.route('https://github.com/login/oauth/access_token', async (route) => {
await route.fulfill({
// GitHub trả về form-encoded hoặc JSON tùy header Accept
body: 'access_token=mock-github-token&token_type=bearer&scope=user',
headers: { 'content-type': 'application/x-www-form-urlencoded' },
});
});
Sau khi exchange token, app thường gọi thêm user info endpoint để lấy profile. Cần mock cả endpoint này nếu app không có sẵn user data từ id_token:
// Mock Google userinfo endpoint
await page.route('https://www.googleapis.com/oauth2/v3/userinfo', async (route) => {
await route.fulfill({
json: {
sub: 'google-uid-123',
email: '[email protected]',
name: 'Test User',
picture: 'https://example.com/avatar.jpg',
email_verified: true,
},
});
});
Provider-Specific URL
Mỗi provider có URL riêng cho authorization, token exchange, và user info. Đây là danh sách endpoint chính để mock:
| Provider | Authorization | Token Exchange | User Info |
|---|---|---|---|
accounts.google.com/o/oauth2/v2/auth |
oauth2.googleapis.com/token |
www.googleapis.com/oauth2/v3/userinfo |
|
| GitHub | github.com/login/oauth/authorize |
github.com/login/oauth/access_token |
api.github.com/user |
| Auth0 | {tenant}.auth0.com/authorize |
{tenant}.auth0.com/oauth/token |
{tenant}.auth0.com/userinfo |
| Okta | {domain}/oauth2/default/v1/authorize |
{domain}/oauth2/default/v1/token |
{domain}/oauth2/default/v1/userinfo |
Auth0 và Okta dùng tenant URL — URL thay đổi theo từng tenant (mỗi app/team có subdomain riêng). Pattern mock phải dùng wildcard:
// Auth0 — mock theo tenant
const AUTH0_DOMAIN = process.env.AUTH0_DOMAIN || 'myapp.auth0.com';
await page.route(`https://${AUTH0_DOMAIN}/**`, async (route) => {
const url = route.request().url();
if (url.includes('/authorize')) {
await route.fulfill({
status: 302,
headers: {
location: `${appURL}/auth/callback?code=mock-code&state=mock-state`,
},
});
} else if (url.includes('/oauth/token')) {
await route.fulfill({
json: {
access_token: 'mock-access-token',
id_token: 'mock-id-token',
token_type: 'Bearer',
expires_in: 86400,
},
});
} else {
await route.continue();
}
});
// Okta — pattern tương tự, URL khác
const OKTA_DOMAIN = process.env.OKTA_DOMAIN || 'myapp.okta.com';
await page.route(`https://${OKTA_DOMAIN}/**`, async (route) => {
const url = route.request().url();
if (url.includes('/v1/authorize')) {
await route.fulfill({
status: 302,
headers: {
location: `${appURL}/auth/callback?code=mock-code`,
},
});
} else if (url.includes('/v1/token')) {
await route.fulfill({
json: {
access_token: 'mock-access-token',
token_type: 'Bearer',
expires_in: 3600,
},
});
} else {
await route.continue();
}
});
Lấy AUTH0_DOMAIN và OKTA_DOMAIN từ environment variable — không hardcode URL tenant vào test code vì URL thay đổi theo môi trường (dev/staging/production).
Chọn Strategy Nào?
Quyết định dựa trên những gì cần kiểm tra:
Dùng Strategy 2 (Inject Session) khi
- Mục tiêu test là feature sau login: dashboard, profile, checkout, CRUD.
- Không cần kiểm tra OAuth flow — chỉ cần trạng thái "đã đăng nhập".
- Cần test chạy nhanh và ổn định nhất.
- Đây là strategy mặc định cho phần lớn feature test (kết hợp với
storageState).
Dùng Strategy 1 (Mock Callback) khi
- Cần test callback handler phía app: parse
code, gọi token exchange, lưu token, redirect. - Cần test xử lý error trong callback (invalid state, expired code, provider error).
- Không cần test UI provider.
Dùng Strategy 3 (Mock Provider Endpoint) khi
- Cần test full flow UI từ click "Login with Google" đến dashboard.
- Cần kiểm tra app redirect đúng provider URL với đúng parameters.
- App dùng front-end redirect (không phải server-side redirect).
Test Provider Thật (không mock) khi
- Smoke test OAuth integration — xác nhận provider config còn hoạt động.
- Chạy trong environment riêng biệt, tách khỏi regression suite.
- Dùng dedicated test account (không có 2FA, không dùng tài khoản cá nhân).
- Chấp nhận test chậm và flaky hơn so với mock.
Limitation
- Mock không test real OAuth integration — nếu Google thay đổi response format (thêm field bắt buộc, đổi field name), mock vẫn pass nhưng production sẽ vỡ. Cần test thật trong smoke suite riêng để phát hiện breaking change từ provider.
- Token validation server-side có thể reject mock token — nếu backend verify chữ ký JWT của Google (sử dụng Google public key), mock
id_tokenkhông có chữ ký hợp lệ sẽ bị reject. Cần mock cả validation endpoint hoặc dùng real test token. Bài 110 sẽ đề cập tạo JWT mock có cấu trúc đúng. - Server-side redirect không bị intercept —
page.route()chỉ intercept request đi qua browser. Nếu backend tạo redirect đến Google (HTTP 302 từ server), Playwright không catch được request đó qua route. Cần mock ở tầng backend (test server) hoặc dùng MSW. - PKCE và state validation — OAuth PKCE flow tạo
code_verifiervàcode_challenge;stateparameter dùng để chống CSRF. Mock callback với giá trị ngẫu nhiên có thể fail nếu app validatestatekhớp với session. Phải lấy đúng giá trịstatetừ request gốc hoặc disable validation trong test environment.
4 Pitfall
Pitfall 1 — Mock token bị server reject vì signature invalid
App verify chữ ký JWT của provider (Google, Auth0, Okta đều ký id_token bằng RS256). Mock id_token là string tùy ý → server call jwks endpoint để lấy public key → verify fail → 401.
// Không đủ nếu server verify JWT signature
await route.fulfill({
json: { id_token: 'fake.jwt.token' }, // ← server reject
});
Giải pháp: mock cả jwks endpoint của provider (trả về public key tương ứng với mock token), hoặc cấu hình test environment skip signature verification, hoặc dùng real token từ test tenant của provider (Auth0 và Okta có test tenant). Bài 110 đề cập createMockJWT với key tương ứng.
Pitfall 2 — Quên mock token exchange endpoint
Mock callback URL nhưng quên mock endpoint app dùng để exchange code → token. App nhận callback, gọi token endpoint thật → fail vì code mock không hợp lệ với provider thật.
// Mock callback nhưng quên token exchange
await page.route('**/auth/callback*', async (route) => {
await route.continue(); // cho app chạy callback handler
});
// Thiếu: mock '**/api/auth/token' hoặc provider token endpoint
// → app gọi thật: Google trả error "invalid_grant" → app crash
Luôn trace toàn bộ network request trong callback flow (dùng page.on('request')) để biết app gọi đến đâu sau khi nhận callback, rồi mock tất cả các endpoint đó.
Pitfall 3 — Provider redirect URL hardcode, break khi provider đổi
// Hardcode URL — dễ vỡ
await page.route('https://accounts.google.com/o/oauth2/auth', async (route) => {
// ...
});
// Google dùng cả /o/oauth2/v2/auth — miss request này
Dùng wildcard pattern cho subdomain và path để bắt mọi biến thể:
// Wildcard pattern — bắt cả /auth và /v2/auth
await page.route('https://accounts.google.com/**', async (route) => {
if (route.request().url().includes('/oauth2/')) {
// xử lý
} else {
await route.continue();
}
});
Pitfall 4 — Mock OAuth cho cảm giác an toàn giả (false confidence)
Toàn bộ test suite dùng mock → pass hoàn toàn. Deploy lên production, Google login fail vì Client ID sai, Redirect URI không được đăng ký, hoặc app không handle đúng error response từ provider (scope không được grant, account bị suspended).
Mock chỉ test behavior của app khi nhận expected response. Nó không test cấu hình OAuth application, provider-side config, hay edge case phía provider. Duy trì tối thiểu một smoke test chạy real OAuth (dùng dedicated test account, môi trường riêng) để phát hiện config drift.
Quiz
Câu 1. App dùng NextAuth.js với Google provider, backend xử lý callback và exchange token server-side. Strategy nào trong bài này KHÔNG hoạt động và tại sao?
Đáp án
Strategy 3 (mock provider endpoint qua page.route()) không hoạt động. NextAuth.js xử lý OAuth flow server-side: redirect đến Google do server tạo ra (HTTP 302 từ /api/auth/signin/google), không phải JavaScript phía browser. page.route() chỉ intercept request đi qua browser context — không bắt được server-to-server request. Cần dùng strategy 1 (navigate thẳng đến /api/auth/callback/google?code=...&state=...) hoặc strategy 2 (inject session vào cookie).
Câu 2. Tại sao mock token exchange endpoint cần trả đúng content-type khi mock GitHub?
Đáp án
GitHub token endpoint mặc định trả về application/x-www-form-urlencoded (body dạng access_token=xxx&token_type=bearer), không phải JSON. Nếu app gửi header Accept: application/json thì GitHub trả JSON; nếu không, app nhận form-encoded. Mock phải trả đúng format mà app mong đợi — nếu trả JSON khi app parse form-encoded (hoặc ngược lại), parse sẽ fail và token không được lưu đúng. Phải kiểm tra trong code app xem nó parse response kiểu nào.
Câu 3. Strategy 2 (inject session) dùng localStorage. App của bạn lưu auth state trong cookie HttpOnly. Cần thay đổi gì?
Đáp án
Cookie HttpOnly không thể set qua JavaScript (page.evaluate() không thể đọc hay ghi HttpOnly cookie). Phải dùng context.addCookies() để set cookie trực tiếp lên browser context, hoặc gọi API endpoint trả về Set-Cookie header với HttpOnly (route fulfill với headers: { 'set-cookie': 'session=mock-id; HttpOnly; Path=/' }). Sau đó lưu state bằng page.context().storageState() — storageState() capture được cả HttpOnly cookies.
Câu 4. OAuth state parameter dùng để làm gì và tại sao mock callback với state=mock-state có thể fail?
Đáp án
state là giá trị ngẫu nhiên do app tạo ra trước khi redirect đến provider, lưu vào session/cookie. Khi provider redirect về callback, app so sánh state trong query string với giá trị đã lưu — nếu không khớp, app reject request (chống CSRF). Mock callback với state=mock-state sẽ fail nếu app không tìm thấy mock-state trong session. Giải pháp: (1) intercept request đến provider để lấy state thật, rồi dùng lại giá trị đó trong mock callback; hoặc (2) cấu hình app test environment tắt state validation.
Câu 5. Test suite dùng strategy 2 pass 100% nhưng khi deploy, Google OAuth bị lỗi "redirect_uri_mismatch". Mock có phát hiện được lỗi này không? Cần làm gì để phát hiện sớm?
Đáp án
Không. Strategy 2 bypass OAuth flow hoàn toàn — không bao giờ gọi đến Google. Lỗi redirect_uri_mismatch xảy ra phía Google khi redirect_uri trong request không match URI đã đăng ký trong Google Cloud Console. Mock không thể phát hiện lỗi config này. Cần: (1) smoke test E2E với Google provider thật trong environment riêng (staging/pre-prod); (2) kiểm tra redirect_uri được tạo đúng trong unit/integration test; (3) IaC hoặc config as code để verify Redirect URI được đăng ký đúng trong Google Cloud project tương ứng với mỗi environment.
Bài Tiếp Theo
Bài 110 đi sâu vào JWT: cách tạo mock JWT có cấu trúc hợp lệ (header, payload, signature), inject vào test để vượt qua server-side validation, và các pattern test JWT expiry.
Tài liệu tham khảo
- Playwright API — page.route()
- Playwright API — route.fulfill()
- Playwright API — BrowserContext.addCookies()
- Playwright Docs — Authentication
- RFC 6749 — OAuth 2.0 Authorization Framework
- Google Identity — OAuth 2.0 for Web Server Applications
- GitHub Docs — Authorizing OAuth apps
- Auth0 Docs — Authorization Code Flow
