|
| 1 | +import { test, expect } from '../../fixtures'; |
| 2 | +import { getLatestOtp } from '../../utils/otp'; |
| 3 | +import type { Page, APIRequestContext } from '@playwright/test'; |
| 4 | +import type { LoginPage } from '../../pages/LoginPage'; |
| 5 | + |
| 6 | +// Seeded by database/seeds/TestSeeder.php ('oauth2_test_app') - confidential |
| 7 | +// web client (token_endpoint_auth_method: client_secret_basic) with 'profile' |
| 8 | +// among its granted API scopes and this redirect_uri. |
| 9 | +const CLIENT_ID = '.-_~87D8/Vcvr6fvQbH4HyNgwTlfSyQ3x.openstack.client'; |
| 10 | +const CLIENT_SECRET = 'ITc/6Y5N7kOtGKhgITc/6Y5N7kOtGKhgITc/6Y5N7kOtGKhgITc/6Y5N7kOtGKhg'; |
| 11 | +const REDIRECT_URI = 'https://www.test.com/oauth2'; |
| 12 | + |
| 13 | +const VERIFY_URL = '**/auth/login/2fa/verify**'; |
| 14 | + |
| 15 | +// MFA-enforced super-admins seeded by CI (idp:create-super-admin) - one per |
| 16 | +// test so they don't share (and race against) the same |
| 17 | +// two_factor.rate_limit.max_otp_requests window, and so granting consent or |
| 18 | +// trusting a device in one test doesn't change another test's starting state. |
| 19 | +const MFA_USER_PASSWORD = '1Qaz2wsx!'; |
| 20 | +const MFA_USER_EMAIL = 'mfa-oauth2@test.com'; |
| 21 | +const MFA_USER_EMAIL_CONSENT = 'mfa-oauth2-consent@test.com'; |
| 22 | +const MFA_USER_EMAIL_TRUST = 'mfa-oauth2-trust@test.com'; |
| 23 | + |
| 24 | +function authorizeUrl(): string { |
| 25 | + const params = new URLSearchParams({ |
| 26 | + client_id: CLIENT_ID, |
| 27 | + redirect_uri: REDIRECT_URI, |
| 28 | + response_type: 'code', |
| 29 | + scope: 'profile', |
| 30 | + }); |
| 31 | + return `/oauth2/auth?${params}`; |
| 32 | +} |
| 33 | + |
| 34 | +/** |
| 35 | + * Rebuilds `absoluteUrl` (typically server-generated from config('app.url')) |
| 36 | + * against the browser's CURRENT origin. The IDP always builds absolute |
| 37 | + * redirect/action URLs from its own configured app.url, which can be a |
| 38 | + * genuinely different DOMAIN than the one this suite is actually running |
| 39 | + * against (e.g. app.url is http://localhost but APP_URL=http://nginx in the |
| 40 | + * docker-compose e2e profile) - cookies are domain-scoped, unlike ports, so |
| 41 | + * following the server's literal value would leave the session cookie behind. |
| 42 | + */ |
| 43 | +function sameOriginUrl(page: Page, absoluteUrl: string): string { |
| 44 | + const target = new URL(absoluteUrl); |
| 45 | + const current = new URL(page.url()); |
| 46 | + target.protocol = current.protocol; |
| 47 | + target.host = current.host; |
| 48 | + return target.toString(); |
| 49 | +} |
| 50 | + |
| 51 | +/** Fills email + password and waits for the real MFA challenge to appear. */ |
| 52 | +async function loginToMfaChallenge(loginPage: LoginPage, email: string): Promise<void> { |
| 53 | + await loginPage.fillEmail(email); |
| 54 | + await loginPage.fillPassword(MFA_USER_PASSWORD); |
| 55 | + await expect(loginPage.twoFactorForm).toBeVisible(); |
| 56 | +} |
| 57 | + |
| 58 | +/** |
| 59 | + * Types `otp` into the 2FA form and submits it, then follows the resulting |
| 60 | + * redirect ourselves (see sameOriginUrl doc above for why the client's own |
| 61 | + * `window.location.href = redirect_url` can't be trusted to carry the |
| 62 | + * session cookie): intercept the page's own verify2FA request, replay it via |
| 63 | + * page.request (shares the page's cookies, so there's no race with the |
| 64 | + * page's own script reading the body first), then fulfill the intercepted |
| 65 | + * request with redirect_url nulled out so the client's fallback navigation |
| 66 | + * becomes a harmless same-URL no-op. |
| 67 | + */ |
| 68 | +async function verifyOtpAndFollowRedirect(page: Page, loginPage: LoginPage, otp: string): Promise<void> { |
| 69 | + let verifyPayload: { redirect_url?: string } | undefined; |
| 70 | + await page.route(VERIFY_URL, async (route) => { |
| 71 | + const req = route.request(); |
| 72 | + const response = await page.request.fetch(req.url(), { |
| 73 | + method: req.method(), |
| 74 | + headers: req.headers(), |
| 75 | + data: req.postData() ?? undefined, |
| 76 | + }); |
| 77 | + verifyPayload = await response.json(); |
| 78 | + await route.fulfill({ |
| 79 | + status: response.status(), |
| 80 | + contentType: 'application/json', |
| 81 | + body: JSON.stringify({ ...verifyPayload, redirect_url: null }), |
| 82 | + }); |
| 83 | + }); |
| 84 | + |
| 85 | + await page.locator('[data-testid="two-factor-form"] input[type="tel"]').first().click(); |
| 86 | + await page.keyboard.type(otp); |
| 87 | + await loginPage.verifyButton.click(); |
| 88 | + // The route handler above makes its OWN request to the server before |
| 89 | + // fulfilling this one, so this can take noticeably longer than the |
| 90 | + // default poll timeout - especially the first request against a |
| 91 | + // just-started stack. |
| 92 | + await expect.poll(() => verifyPayload, { timeout: 15000 }).toBeTruthy(); |
| 93 | + await page.goto(sameOriginUrl(page, verifyPayload!.redirect_url!)); |
| 94 | +} |
| 95 | + |
| 96 | +/** |
| 97 | + * Submits the consent form's Accept action and follows the resulting |
| 98 | + * /oauth2/auth redirect (postConsent() redirects back there, same pattern as |
| 99 | + * postLogin(), so the authorize endpoint can re-evaluate the request now |
| 100 | + * that consent was just granted). Returns the authorization code from the |
| 101 | + * final redirect to the client's redirect_uri. |
| 102 | + */ |
| 103 | +async function acceptConsentAndGetCode(page: Page): Promise<string> { |
| 104 | + const csrfToken = await page.locator('#_token').inputValue(); |
| 105 | + const consentResponse = await page.request.post( |
| 106 | + new URL('/accounts/user/consent', page.url()).toString(), |
| 107 | + { form: { _token: csrfToken, trust: 'AllowOnce' }, maxRedirects: 0 } |
| 108 | + ); |
| 109 | + expect(consentResponse.status()).toBe(302); |
| 110 | + const consentLocation = consentResponse.headers()['location']; |
| 111 | + expect(consentLocation).toBeTruthy(); |
| 112 | + |
| 113 | + const authorizeResponse = await page.request.get(sameOriginUrl(page, consentLocation!), { maxRedirects: 0 }); |
| 114 | + expect(authorizeResponse.status()).toBe(302); |
| 115 | + const location = authorizeResponse.headers()['location']; |
| 116 | + expect(location).toBeTruthy(); |
| 117 | + |
| 118 | + const code = new URL(location!).searchParams.get('code'); |
| 119 | + expect(code).toBeTruthy(); |
| 120 | + return code!; |
| 121 | +} |
| 122 | + |
| 123 | +/** Exchanges an authorization code for an access token, exactly as a real OAuth2 client would. */ |
| 124 | +async function exchangeCodeForToken(request: APIRequestContext, code: string): Promise<void> { |
| 125 | + const tokenResponse = await request.post('/oauth2/token/', { |
| 126 | + headers: { |
| 127 | + Authorization: `Basic ${Buffer.from(`${CLIENT_ID}:${CLIENT_SECRET}`).toString('base64')}`, |
| 128 | + }, |
| 129 | + form: { grant_type: 'authorization_code', code, redirect_uri: REDIRECT_URI }, |
| 130 | + }); |
| 131 | + |
| 132 | + expect(tokenResponse.status()).toBe(200); |
| 133 | + const tokenPayload = await tokenResponse.json(); |
| 134 | + expect(tokenPayload.access_token).toBeTruthy(); |
| 135 | + expect(tokenPayload.token_type).toBe('Bearer'); |
| 136 | +} |
| 137 | + |
| 138 | +test.describe('OAuth2 Authorization Code Flow', () => { |
| 139 | + test('unauthenticated request redirects to login', async ({ page }) => { |
| 140 | + await page.goto(authorizeUrl()); |
| 141 | + await expect(page).toHaveURL(/\/auth\/login/); |
| 142 | + }); |
| 143 | + |
| 144 | + test('MFA-enforced login completes the full authorization code flow after the challenge (memento survives MFA)', |
| 145 | + async ({ loginPage, page, request }) => { |
| 146 | + // Start the OAuth2 authorization request with no session - the server |
| 147 | + // serializes it into the session (the "memento") and redirects to login. |
| 148 | + await page.goto(authorizeUrl()); |
| 149 | + await expect(page).toHaveURL(/\/auth\/login/); |
| 150 | + |
| 151 | + // Real native-form login (see login-mfa-flow.spec.ts) against an |
| 152 | + // MFA-enforced account triggers a real challenge, not a mocked one. |
| 153 | + await loginToMfaChallenge(loginPage, MFA_USER_EMAIL); |
| 154 | + const otp = getLatestOtp(MFA_USER_EMAIL); |
| 155 | + await verifyOtpAndFollowRedirect(page, loginPage, otp); |
| 156 | + |
| 157 | + // If the memento had been dropped anywhere across the MFA detour, this |
| 158 | + // would land on the default post-login destination instead of the |
| 159 | + // consent screen for THIS specific client. |
| 160 | + await expect(page).toHaveURL(/\/accounts\/user\/consent/); |
| 161 | + await expect(page.getByText('oauth2_test_app').first()).toBeVisible(); |
| 162 | + |
| 163 | + const code = await acceptConsentAndGetCode(page); |
| 164 | + await exchangeCodeForToken(request, code); |
| 165 | + }); |
| 166 | + |
| 167 | + test('returning user with prior consent skips the consent screen entirely', |
| 168 | + async ({ loginPage, page, request }) => { |
| 169 | + await page.goto(authorizeUrl()); |
| 170 | + await expect(page).toHaveURL(/\/auth\/login/); |
| 171 | + |
| 172 | + await loginToMfaChallenge(loginPage, MFA_USER_EMAIL_CONSENT); |
| 173 | + const otp = getLatestOtp(MFA_USER_EMAIL_CONSENT); |
| 174 | + await verifyOtpAndFollowRedirect(page, loginPage, otp); |
| 175 | + await expect(page).toHaveURL(/\/accounts\/user\/consent/); |
| 176 | + |
| 177 | + // Grant consent once - this is the baseline "first-time" experience |
| 178 | + // already covered by the test above, just needed here as setup. |
| 179 | + await acceptConsentAndGetCode(page); |
| 180 | + |
| 181 | + // A second authorization request for the SAME client/scope, in the |
| 182 | + // SAME authenticated session, must now skip the consent screen |
| 183 | + // entirely (InteractiveGrantType::handle()'s has_former_consent + |
| 184 | + // auto_approval branch) and redirect straight to the client. |
| 185 | + const secondAuthorize = await page.request.get(authorizeUrl(), { maxRedirects: 0 }); |
| 186 | + expect(secondAuthorize.status()).toBe(302); |
| 187 | + const location = secondAuthorize.headers()['location']; |
| 188 | + expect(location).toBeTruthy(); |
| 189 | + expect(location).not.toMatch(/accounts\/user\/consent/); |
| 190 | + expect(location).toContain(REDIRECT_URI); |
| 191 | + |
| 192 | + const code = new URL(location!).searchParams.get('code'); |
| 193 | + expect(code).toBeTruthy(); |
| 194 | + await exchangeCodeForToken(request, code!); |
| 195 | + }); |
| 196 | + |
| 197 | + test('trusting the device during MFA lets a later login skip the challenge', |
| 198 | + async ({ loginPage, page }) => { |
| 199 | + // NOTE: MFACookieManager::queueDeviceTrustCookie() issues the |
| 200 | + // device_trust_token cookie with Secure=true. Browsers only persist |
| 201 | + // Secure cookies over a "potentially trustworthy origin" - real HTTPS, |
| 202 | + // or specifically http://localhost - so this test requires running |
| 203 | + // against http://localhost (host dev via `npx playwright test`, or CI - |
| 204 | + // see .github/workflows/*_frontend_tests.yml's APP_URL). It will not |
| 205 | + // observe the cookie under the docker-compose e2e profile's |
| 206 | + // http://nginx, which is a plain (non-localhost) HTTP origin. |
| 207 | + await page.goto(authorizeUrl()); |
| 208 | + await expect(page).toHaveURL(/\/auth\/login/); |
| 209 | + |
| 210 | + await loginToMfaChallenge(loginPage, MFA_USER_EMAIL_TRUST); |
| 211 | + await page.locator('#trust_device').check(); |
| 212 | + |
| 213 | + const otp = getLatestOtp(MFA_USER_EMAIL_TRUST); |
| 214 | + await verifyOtpAndFollowRedirect(page, loginPage, otp); |
| 215 | + await expect(page).toHaveURL(/\/accounts\/user\/consent/); |
| 216 | + |
| 217 | + const cookies = await page.context().cookies(); |
| 218 | + expect(cookies.some((c) => c.name === 'device_trust_token')).toBe(true); |
| 219 | + |
| 220 | + // Log out and start a completely fresh authorization request - only |
| 221 | + // the trusted-device cookie (not the now-cleared session) should be |
| 222 | + // available to let this second login skip the MFA challenge. |
| 223 | + await page.request.get(new URL('/accounts/user/logout', page.url()).toString()); |
| 224 | + await page.goto(authorizeUrl()); |
| 225 | + await expect(page).toHaveURL(/\/auth\/login/); |
| 226 | + |
| 227 | + await loginPage.fillEmail(MFA_USER_EMAIL_TRUST); |
| 228 | + |
| 229 | + // The password step's native form POST redirects (on success) via |
| 230 | + // Redirect::action() - same config('app.url') cross-origin caveat as |
| 231 | + // verifyOtpAndFollowRedirect() above, so replay it the same way rather |
| 232 | + // than letting the browser follow the native 302 itself. |
| 233 | + let postLoginRedirectUrl = ''; |
| 234 | + await page.route('**/auth/login', async (route) => { |
| 235 | + if (route.request().method() !== 'POST') { |
| 236 | + await route.continue(); |
| 237 | + return; |
| 238 | + } |
| 239 | + const req = route.request(); |
| 240 | + const response = await page.request.fetch(req.url(), { |
| 241 | + method: 'POST', |
| 242 | + headers: req.headers(), |
| 243 | + data: req.postData() ?? undefined, |
| 244 | + maxRedirects: 0, |
| 245 | + }); |
| 246 | + postLoginRedirectUrl = response.headers()['location'] ?? ''; |
| 247 | + await route.fulfill({ status: 200, contentType: 'text/plain', body: '' }); |
| 248 | + }); |
| 249 | + |
| 250 | + await loginPage.fillPassword(MFA_USER_PASSWORD); |
| 251 | + await expect.poll(() => postLoginRedirectUrl, { timeout: 15000 }).toBeTruthy(); |
| 252 | + |
| 253 | + // The device is trusted, so no 2FA challenge should have been issued. |
| 254 | + await expect(page.locator('[data-testid="two-factor-form"]')).not.toBeVisible(); |
| 255 | + |
| 256 | + await page.goto(sameOriginUrl(page, postLoginRedirectUrl)); |
| 257 | + await expect(page).toHaveURL(/\/accounts\/user\/consent/); |
| 258 | + }); |
| 259 | +}); |
0 commit comments