Skip to content

Commit 052220a

Browse files
committed
feat: add e2e coverage for the OAuth2 authorization code flow
Adds tests/e2e/tests/oauth2/auth-code-flow.spec.ts, exercising the full authorization code grant end to end - including the memento (pending OAuth2 request) surviving a real MFA detour, consent-bypass for a returning user, and MFA-skip for a trusted device: - unauthenticated /oauth2/auth redirects to login (memento serialized). - full flow: real login -> real MFA challenge -> real OTP -> consent screen for the correct client -> Accept -> authorization code -> code exchanged at the token endpoint for a real access_token. - returning user with prior consent: a second /oauth2/auth for the same client+scope skips the consent screen entirely and redirects straight to redirect_uri (InteractiveGrantType::handle()'s has_former_consent + auto_approval branch). - trusted device: checking "Trust this device" during MFA sets the Secure device_trust_token cookie; logging out and logging back in then skips the MFA challenge entirely. Infrastructure needed to drive this for real (no mocks): - app/Console/Commands/GetLatestOtp.php (idp:get-latest-otp {email}): prints the newest not-yet-redeemed OTP for a user, since the mailer queues via Redis and there is no catchable local mailbox to read the code from. Registered in app/Console/Kernel.php. - tests/e2e/utils/otp.ts: reads that OTP from the test runner - directly via `php artisan` when reachable in-process (CI, host dev), or via `docker exec idp-app php artisan ...` when running against the dockerized stack (APP_URL points at nginx). - docker-compose/playwright/Dockerfile + docker-compose.yml: the playwright service now builds this image (adds the Docker CLI on top of the stock Playwright image) and mounts /var/run/docker.sock so the above `docker exec` path works from inside that container. Scoped to the e2e profile only. - The suite works around two config('app.url')-vs-actual-origin mismatches (e.g. app.url=http://localhost but this suite runs against http://nginx in the docker-compose e2e profile - cookies are domain-scoped, so following the server's literal absolute redirect/ form-action URLs client-side would drop the session): verify2FA's redirect_url, the consent form's action, and the password step's postLogin() redirect are all replayed via page.request (shares the page's cookies) instead of trusting the browser/client-side JS to follow them unassisted. - .github/workflows/{pull_request,push}_frontend_tests.yml: seed mfa-oauth2-consent@test.com and mfa-oauth2-trust@test.com alongside the existing mfa-oauth2@test.com fixture. Known environment limitation (not a bug): the trusted-device assertion requires a "potentially trustworthy origin" for the Secure cookie to persist - true for http://localhost (host dev, and CI, which already uses APP_URL=http://localhost:8001) but not for the docker-compose e2e profile's http://nginx, where browsers silently drop the cookie. Verified: 16/17 e2e via `docker compose --profile e2e run --rm playwright npx playwright test` (the trusted-device test is the one expected miss, per the above), 4/4 in tests/e2e/tests/oauth2/ via host (`npx playwright test`), 40/40 PHP (TwoFactorLoginFlowTest), 23/23 Jest.
1 parent 3a3c3a7 commit 052220a

8 files changed

Lines changed: 357 additions & 1 deletion

File tree

.github/workflows/pull_request_frontend_tests.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,9 @@ jobs:
105105
for i in 001 002 003 004 005 006 007 008; do
106106
php artisan idp:create-super-admin "mfa-ts-$i@test.com" '1Qaz2wsx!'
107107
done
108+
php artisan idp:create-super-admin mfa-oauth2@test.com '1Qaz2wsx!'
109+
php artisan idp:create-super-admin mfa-oauth2-consent@test.com '1Qaz2wsx!'
110+
php artisan idp:create-super-admin mfa-oauth2-trust@test.com '1Qaz2wsx!'
108111
- name: Install Playwright Chromium
109112
run: npx playwright install --with-deps chromium
110113
- name: Start web server

.github/workflows/push_frontend_tests.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,9 @@ jobs:
106106
for i in 001 002 003 004 005 006 007 008; do
107107
php artisan idp:create-super-admin "mfa-ts-$i@test.com" '1Qaz2wsx!'
108108
done
109+
php artisan idp:create-super-admin mfa-oauth2@test.com '1Qaz2wsx!'
110+
php artisan idp:create-super-admin mfa-oauth2-consent@test.com '1Qaz2wsx!'
111+
php artisan idp:create-super-admin mfa-oauth2-trust@test.com '1Qaz2wsx!'
109112
- name: Install Playwright Chromium
110113
run: npx playwright install --with-deps chromium
111114
- name: Start web server
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
<?php namespace App\Console\Commands;
2+
/**
3+
* Copyright 2026 OpenStack Foundation
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
* http://www.apache.org/licenses/LICENSE-2.0
8+
* Unless required by applicable law or agreed to in writing, software
9+
* distributed under the License is distributed on an "AS IS" BASIS,
10+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
* See the License for the specific language governing permissions and
12+
* limitations under the License.
13+
**/
14+
15+
use App\libs\OAuth2\Repositories\IOAuth2OTPRepository;
16+
use Illuminate\Console\Command;
17+
18+
/**
19+
* Class GetLatestOtp
20+
*
21+
* Prints the value of the latest not-yet-redeemed OTP issued for a given
22+
* username. Useful for E2E tests that need to complete a real MFA/passwordless
23+
* challenge (the mailer queues via Redis, so there is no catchable local
24+
* mailbox to read the code from instead).
25+
*
26+
* @package App\Console\Commands
27+
*/
28+
class GetLatestOtp extends Command
29+
{
30+
protected $signature = 'idp:get-latest-otp {email}';
31+
32+
protected $description = 'Print the latest not-yet-redeemed OTP value issued for the given username (useful for E2E tests)';
33+
34+
public function handle(IOAuth2OTPRepository $repository)
35+
{
36+
$email = trim($this->argument('email'));
37+
38+
// DoctrineOAuth2OTPRepository::getByUserNameNotRedeemed() orders by
39+
// id DESC, so the newest not-yet-redeemed OTP is the FIRST result,
40+
// not the last - an account with more than one pending OTP (e.g. a
41+
// prior attempt that was never redeemed) would otherwise return a
42+
// stale code.
43+
$otps = $repository->getByUserNameNotRedeemed($email);
44+
if (empty($otps)) {
45+
$this->error("no pending otp for {$email}");
46+
return 1;
47+
}
48+
49+
$this->line(reset($otps)->getValue());
50+
return 0;
51+
}
52+
}

app/Console/Kernel.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ class Kernel extends ConsoleKernel
3030
Commands\CleanOpenIdStaleData::class,
3131
Commands\CreateSuperAdmin::class,
3232
Commands\CreateRawUser::class,
33+
Commands\GetLatestOtp::class,
3334
Commands\SpammerProcess\RebuildUserSpammerEstimator::class,
3435
Commands\SpammerProcess\UserSpammerProcessor::class,
3536
];

docker-compose.yml

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,12 +58,18 @@ services:
5858
- idp-local-net
5959
env_file: ./.env
6060
playwright:
61-
image: mcr.microsoft.com/playwright:v1.61.1-jammy
61+
build:
62+
context: ./docker-compose/playwright
6263
container_name: idp-playwright
6364
working_dir: /var/www
6465
volumes:
6566
- ./:/var/www
6667
- playwright_cache:/root/.cache/ms-playwright
68+
# Lets the e2e suite `docker exec idp-app php artisan idp:get-latest-otp
69+
# <email>` to read a real OTP value (see tests/e2e/utils/otp.ts) -
70+
# grants this container full control of the host's Docker daemon, not
71+
# just idp-app, so keep this service dev/e2e-only (profiles: [e2e]).
72+
- /var/run/docker.sock:/var/run/docker.sock
6773
networks:
6874
- idp-local-net
6975
depends_on:
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
FROM mcr.microsoft.com/playwright:v1.61.1-jammy
2+
3+
# Docker CLI only (no daemon) - lets the e2e suite shell out to
4+
# `docker exec idp-app php artisan idp:get-latest-otp <email>` to read a
5+
# real OTP value without adding DB/mail dependencies to the test runner.
6+
# Requires /var/run/docker.sock to be mounted at runtime (docker-compose.yml).
7+
ARG DOCKER_CLI_VERSION=27.3.1
8+
RUN apt-get update \
9+
&& apt-get install -y --no-install-recommends ca-certificates curl \
10+
&& curl -fsSL "https://download.docker.com/linux/static/stable/x86_64/docker-${DOCKER_CLI_VERSION}.tgz" \
11+
| tar -xz --strip-components=1 -C /usr/local/bin docker/docker \
12+
&& rm -rf /var/lib/apt/lists/*
Lines changed: 259 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,259 @@
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+
});

tests/e2e/utils/otp.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { execFileSync } from 'node:child_process';
2+
3+
/**
4+
* Reads the real OTP value issued for `email` via the idp:get-latest-otp
5+
* artisan command - the mailer queues via Redis, so there is no catchable
6+
* local mailbox to read the code from instead.
7+
*
8+
* Runs the command directly when php/artisan are reachable in the current
9+
* process (CI, host dev against `php artisan serve`), or through
10+
* `docker exec idp-app` when running against the dockerized stack (APP_URL
11+
* points at the nginx service - see docker-compose.yml's playwright service).
12+
*/
13+
export function getLatestOtp(email: string): string {
14+
const dockerized = (process.env.APP_URL ?? '').includes('nginx');
15+
const [cmd, args] = dockerized
16+
? ['docker', ['exec', 'idp-app', 'php', 'artisan', 'idp:get-latest-otp', email]]
17+
: ['php', ['artisan', 'idp:get-latest-otp', email]];
18+
19+
return execFileSync(cmd, args, { encoding: 'utf-8' }).trim();
20+
}

0 commit comments

Comments
 (0)