From c5da89e2ec37ad2c8a36c7fd755806be6a1c0e4b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 30 Mar 2026 16:25:02 +0000 Subject: [PATCH 1/8] Add OAuth2 PKCE registration E2E test for example resource server Tests the full cross-origin PKCE flow: admin creates invite code, new user visits example app, clicks Register, gets redirected to auth server, fills registration form, approves consent screen, and lands on the protected /account page. https://claude.ai/code/session_01SHq9hQdUiZTD9Huz2gMNZ2 --- .../ExampleResourceServer.cy.ts | 86 ++++++++++++++++++- 1 file changed, 84 insertions(+), 2 deletions(-) diff --git a/tests/e2e-auth-tests/cypress/e2e/example_resource_server/ExampleResourceServer.cy.ts b/tests/e2e-auth-tests/cypress/e2e/example_resource_server/ExampleResourceServer.cy.ts index 0425c441..56acd692 100644 --- a/tests/e2e-auth-tests/cypress/e2e/example_resource_server/ExampleResourceServer.cy.ts +++ b/tests/e2e-auth-tests/cypress/e2e/example_resource_server/ExampleResourceServer.cy.ts @@ -1,7 +1,89 @@ describe("ExampleResourceServer", () => { - it("can visit the example resource server", async () => { - cy.visit("http://example-nextjs-resource-server:3007"); + const exampleAppUrl: string = + Cypress.env("EXAMPLE_NEXTJS_RESOURCE_SERVER_URL") || + "http://example-nextjs-resource-server:3007"; + const authServerUrl: string = Cypress.config("baseUrl")!; + + it("can visit the example resource server", () => { + cy.visit(exampleAppUrl); cy.url().should("include", "example-nextjs-resource-server"); cy.contains("h1", "@schemavaults/example-nextjs-resource-server"); }); + + it("can register a new user through the full OAuth2 PKCE flow and access the protected /account route", () => { + // Step 1: Login as admin and create an invite code for the new user + cy.create_and_login_as_superuser().then((success: boolean) => { + if (!success) { + throw new Error("Failed to login as superuser"); + } + + cy.generate_random_code(24).then((inviteCode: string) => { + cy.create_invite_code(inviteCode, 1).then((created: boolean) => { + if (!created) { + throw new Error("Failed to create invite code"); + } + + // Step 2: Logout from admin session + cy.logout(); + + // Step 3: Visit example app and click "Register" + cy.visit(exampleAppUrl); + cy.contains("h1", "@schemavaults/example-nextjs-resource-server"); + cy.contains("button", "Register").click(); + + // Step 4: Example app's /auth/register generates PKCE params and + // redirects to auth server's /auth/register with code_challenge, + // redirect_uri, and app_id query parameters + cy.url({ timeout: 20000 }).should("include", "/auth/register"); + cy.url().should("include", "code_challenge"); + cy.wait_for_page_hydration(); + + // Step 5: Fill the registration form on the auth server + cy.generate_random_code(12).then((suffix: string) => { + const email = `pkce-reg-test-${suffix}@example.com`; + const password = "TestPassword123!"; + + cy.get("input[name='email']") + .should("be.visible") + .type(email, { force: true }); + cy.get("input[name='password']") + .should("be.visible") + .type(password, { force: true }); + cy.get("input[name='confirm']") + .should("be.visible") + .type(password, { force: true }); + cy.get("input[name='invite_code']") + .should("not.be.disabled") + .type(inviteCode, { force: true }); + + cy.get("button[type='submit']") + .should("not.be.disabled") + .click(); + + // Step 6: Consent screen appears because the example app is not a + // hardcoded app — AppAuthorizationConsentScreen renders in + // authorize-only mode. Click "Authorize & Continue" to approve. + cy.contains("Authorize & Continue", { timeout: 15000 }) + .should("be.visible") + .click(); + + // Step 7: Auth server redirects back to example app's + // /auth/authorize?authorization_code=...&challenge_time=... + // The example app's useTradeAuthorizationCodeForTokensEffect + // exchanges the auth code + stored code_verifier for tokens, + // then redirects to /account. + + // Step 8: Verify the protected /account page renders successfully + cy.url({ timeout: 30000 }).should("include", "/account"); + cy.contains("Example Account Page", { timeout: 15000 }).should( + "be.visible", + ); + cy.contains( + "If you're seeing this it means that you were not redirected because you are logged in!", + ).should("be.visible"); + }); + }); + }); + }); + }); }); From 2f91a223c3fa4e4939df30de0b44d74553691cb6 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 30 Mar 2026 16:25:23 +0000 Subject: [PATCH 2/8] Update bun.lock after dependency install https://claude.ai/code/session_01SHq9hQdUiZTD9Huz2gMNZ2 --- bun.lock | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/bun.lock b/bun.lock index 8f70f68c..7e063659 100644 --- a/bun.lock +++ b/bun.lock @@ -10,7 +10,7 @@ }, "auth-server": { "name": "@schemavaults/auth-server", - "version": "0.20.52", + "version": "0.21.0", "dependencies": { "@hookform/resolvers": "3.9.0", "@schemavaults/app-definitions": "workspace:*", @@ -70,7 +70,7 @@ }, "packages/auth-client-sdk": { "name": "@schemavaults/auth-client-sdk", - "version": "0.9.26", + "version": "0.9.28", "dependencies": { "@schemavaults/app-definitions": "workspace:*", "@schemavaults/auth-common": "workspace:*", @@ -109,7 +109,7 @@ }, "packages/auth-react-provider": { "name": "@schemavaults/auth-react-provider", - "version": "0.10.14", + "version": "0.10.17", "dependencies": { "@schemavaults/app-definitions": "workspace:*", "@schemavaults/auth-client-sdk": "workspace:*", @@ -141,9 +141,10 @@ }, "packages/auth-resource-server-codegen-templates": { "name": "@schemavaults/auth-resource-server-codegen-templates", - "version": "0.0.17", + "version": "0.0.19", "devDependencies": { "@eslint/js": "9.39.1", + "@schemavaults/auth-client-sdk": "workspace:*", "@schemavaults/auth-common": "workspace:*", "@schemavaults/auth-react-provider": "workspace:*", "@schemavaults/jwt": "workspace:*", @@ -165,7 +166,7 @@ }, "packages/auth-server-sdk": { "name": "@schemavaults/auth-server-sdk", - "version": "0.21.14", + "version": "0.21.19", "bin": { "auth-server-sdk": "./dist/cli.cjs", }, @@ -191,7 +192,7 @@ }, "packages/auth-ui": { "name": "@schemavaults/auth-ui", - "version": "0.6.59", + "version": "0.6.61", "dependencies": { "@hookform/resolvers": "3.9.0", "@schemavaults/app-definitions": "workspace:*", @@ -226,7 +227,7 @@ }, "packages/jwt": { "name": "@schemavaults/jwt", - "version": "0.6.36", + "version": "0.6.37", "dependencies": { "@schemavaults/app-definitions": "workspace:*", "@schemavaults/auth-common": "workspace:*", @@ -246,7 +247,7 @@ }, "tests/cypress-e2e-auth-tests-helper-commands": { "name": "@schemavaults/cypress-e2e-auth-tests-helper-commands", - "version": "0.1.2", + "version": "0.1.3", "dependencies": { "@schemavaults/app-definitions": "workspace:*", "@schemavaults/auth-common": "workspace:*", @@ -261,7 +262,7 @@ }, "tests/e2e-auth-tests": { "name": "@schemavaults/e2e-auth-tests", - "version": "0.2.8", + "version": "0.2.17", "dependencies": { "@schemavaults/app-definitions": "workspace:*", "@schemavaults/auth-common": "workspace:*", @@ -277,7 +278,7 @@ }, "tests/example-nextjs-resource-server": { "name": "@schemavaults/example-nextjs-resource-server", - "version": "0.0.5", + "version": "0.1.4", "dependencies": { "@schemavaults/auth-client-sdk": "workspace:*", "@schemavaults/auth-react-provider": "workspace:*", From bb52b0de9a176c0ba0a41b186f7380d16419c6a4 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 30 Mar 2026 16:35:39 +0000 Subject: [PATCH 3/8] Fix docker-compose port mismatch for example resource server The Dockerfile sets PORT=80, but docker-compose was exposing and mapping port 3007. Fixed to expose port 80 internally (matching the container) and map host 3007 to container 80. Updated the EXAMPLE_NEXTJS_RESOURCE_SERVER_URL env var to use port 80 for inter-container communication. https://claude.ai/code/session_01SHq9hQdUiZTD9Huz2gMNZ2 --- tests/e2e-auth-tests/docker-compose.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/e2e-auth-tests/docker-compose.yml b/tests/e2e-auth-tests/docker-compose.yml index c28ba490..25dddbae 100644 --- a/tests/e2e-auth-tests/docker-compose.yml +++ b/tests/e2e-auth-tests/docker-compose.yml @@ -15,7 +15,7 @@ services: CYPRESS_BASE_URL: http://schemavaults-auth:80 SCHEMAVAULTS_APP_ENVIRONMENT: test TEST_SUITE_NAME: ${TEST_SUITE_NAME:?error} - EXAMPLE_NEXTJS_RESOURCE_SERVER_URL: http://example-nextjs-resource-server:3007 + EXAMPLE_NEXTJS_RESOURCE_SERVER_URL: http://example-nextjs-resource-server:80 EXAMPLE_NEXTJS_RESOURCE_SERVER_JWKS_ACCESS_PUBLIC_KEY: ${EXAMPLE_NEXTJS_RESOURCE_SERVER_JWKS_ACCESS_PUBLIC_KEY:-null} profiles: - e2e @@ -47,9 +47,9 @@ services: SCHEMAVAULTS_CLIENT_APP_ID: 00000000-0000-0000-0000-000000000000 SCHEMAVAULTS_AUTH_JWKS_ACCESS_PRIVATE_KEY: ${EXAMPLE_NEXTJS_RESOURCE_SERVER_JWKS_ACCESS_PRIVATE_KEY:-null} expose: - - 3007 + - 80 ports: - - 3007:3007 + - 3007:80 profiles: - e2e_with_resource_server From 76e9a9ae37929f6acf774d68ed11970cdddd2c17 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 30 Mar 2026 16:43:30 +0000 Subject: [PATCH 4/8] Wrap cross-origin interactions with cy.origin() Cypress requires cy.origin() when interacting with elements on a different origin than the base URL. Added cy.origin() blocks for: - Visiting and interacting with the example app (click Register) - Verifying the /account page after redirect back to the example app https://claude.ai/code/session_01SHq9hQdUiZTD9Huz2gMNZ2 --- .../ExampleResourceServer.cy.ts | 33 ++++++++++++------- 1 file changed, 21 insertions(+), 12 deletions(-) diff --git a/tests/e2e-auth-tests/cypress/e2e/example_resource_server/ExampleResourceServer.cy.ts b/tests/e2e-auth-tests/cypress/e2e/example_resource_server/ExampleResourceServer.cy.ts index 56acd692..2909bf2f 100644 --- a/tests/e2e-auth-tests/cypress/e2e/example_resource_server/ExampleResourceServer.cy.ts +++ b/tests/e2e-auth-tests/cypress/e2e/example_resource_server/ExampleResourceServer.cy.ts @@ -6,8 +6,10 @@ describe("ExampleResourceServer", () => { it("can visit the example resource server", () => { cy.visit(exampleAppUrl); - cy.url().should("include", "example-nextjs-resource-server"); - cy.contains("h1", "@schemavaults/example-nextjs-resource-server"); + cy.origin(exampleAppUrl, () => { + cy.url().should("include", "example-nextjs-resource-server"); + cy.contains("h1", "@schemavaults/example-nextjs-resource-server"); + }); }); it("can register a new user through the full OAuth2 PKCE flow and access the protected /account route", () => { @@ -27,13 +29,17 @@ describe("ExampleResourceServer", () => { cy.logout(); // Step 3: Visit example app and click "Register" + // This is cross-origin (example app vs auth server base URL) cy.visit(exampleAppUrl); - cy.contains("h1", "@schemavaults/example-nextjs-resource-server"); - cy.contains("button", "Register").click(); + cy.origin(exampleAppUrl, () => { + cy.contains("h1", "@schemavaults/example-nextjs-resource-server"); + cy.contains("button", "Register").click(); + }); // Step 4: Example app's /auth/register generates PKCE params and // redirects to auth server's /auth/register with code_challenge, - // redirect_uri, and app_id query parameters + // redirect_uri, and app_id query parameters. + // After the redirect we're back on the auth server origin. cy.url({ timeout: 20000 }).should("include", "/auth/register"); cy.url().should("include", "code_challenge"); cy.wait_for_page_hydration(); @@ -74,13 +80,16 @@ describe("ExampleResourceServer", () => { // then redirects to /account. // Step 8: Verify the protected /account page renders successfully - cy.url({ timeout: 30000 }).should("include", "/account"); - cy.contains("Example Account Page", { timeout: 15000 }).should( - "be.visible", - ); - cy.contains( - "If you're seeing this it means that you were not redirected because you are logged in!", - ).should("be.visible"); + // We're back on the example app origin after the redirect chain. + cy.origin(exampleAppUrl, () => { + cy.url({ timeout: 30000 }).should("include", "/account"); + cy.contains("Example Account Page", { + timeout: 15000, + }).should("be.visible"); + cy.contains( + "If you're seeing this it means that you were not redirected because you are logged in!", + ).should("be.visible"); + }); }); }); }); From 35d35b3ac01798efe6df1088e2a65c9c99deca23 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 30 Mar 2026 16:52:31 +0000 Subject: [PATCH 5/8] Normalize origin for cy.origin() to strip default port 80 new URL(url).origin strips the default :80 port, matching how browsers and Cypress normalize origins. Previously cy.origin() received `:80` in the URL while the browser had already stripped it, causing a "same origin" error. https://claude.ai/code/session_01SHq9hQdUiZTD9Huz2gMNZ2 --- .../ExampleResourceServer.cy.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/tests/e2e-auth-tests/cypress/e2e/example_resource_server/ExampleResourceServer.cy.ts b/tests/e2e-auth-tests/cypress/e2e/example_resource_server/ExampleResourceServer.cy.ts index 2909bf2f..fb21b262 100644 --- a/tests/e2e-auth-tests/cypress/e2e/example_resource_server/ExampleResourceServer.cy.ts +++ b/tests/e2e-auth-tests/cypress/e2e/example_resource_server/ExampleResourceServer.cy.ts @@ -2,11 +2,13 @@ describe("ExampleResourceServer", () => { const exampleAppUrl: string = Cypress.env("EXAMPLE_NEXTJS_RESOURCE_SERVER_URL") || "http://example-nextjs-resource-server:3007"; - const authServerUrl: string = Cypress.config("baseUrl")!; + // Normalize origin to strip default port 80 — cy.origin() requires + // the argument to match the browser's normalised origin exactly. + const exampleAppOrigin: string = new URL(exampleAppUrl).origin; it("can visit the example resource server", () => { cy.visit(exampleAppUrl); - cy.origin(exampleAppUrl, () => { + cy.origin(exampleAppOrigin, () => { cy.url().should("include", "example-nextjs-resource-server"); cy.contains("h1", "@schemavaults/example-nextjs-resource-server"); }); @@ -31,7 +33,7 @@ describe("ExampleResourceServer", () => { // Step 3: Visit example app and click "Register" // This is cross-origin (example app vs auth server base URL) cy.visit(exampleAppUrl); - cy.origin(exampleAppUrl, () => { + cy.origin(exampleAppOrigin, () => { cy.contains("h1", "@schemavaults/example-nextjs-resource-server"); cy.contains("button", "Register").click(); }); @@ -81,7 +83,7 @@ describe("ExampleResourceServer", () => { // Step 8: Verify the protected /account page renders successfully // We're back on the example app origin after the redirect chain. - cy.origin(exampleAppUrl, () => { + cy.origin(exampleAppOrigin, () => { cy.url({ timeout: 30000 }).should("include", "/account"); cy.contains("Example Account Page", { timeout: 15000, From e304863d3428fa92750b24fcf092b68daa631244 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 30 Mar 2026 16:54:19 +0000 Subject: [PATCH 6/8] Remove unnecessary cy.origin() after cy.visit() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cy.visit() to a cross-origin URL handles the origin transition automatically — commands run directly on the visited page. Only the final redirect back from auth server to example app needs cy.origin() since that navigation happens via JS redirect, not cy.visit(). https://claude.ai/code/session_01SHq9hQdUiZTD9Huz2gMNZ2 --- .../ExampleResourceServer.cy.ts | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/tests/e2e-auth-tests/cypress/e2e/example_resource_server/ExampleResourceServer.cy.ts b/tests/e2e-auth-tests/cypress/e2e/example_resource_server/ExampleResourceServer.cy.ts index fb21b262..2c6036f2 100644 --- a/tests/e2e-auth-tests/cypress/e2e/example_resource_server/ExampleResourceServer.cy.ts +++ b/tests/e2e-auth-tests/cypress/e2e/example_resource_server/ExampleResourceServer.cy.ts @@ -8,10 +8,8 @@ describe("ExampleResourceServer", () => { it("can visit the example resource server", () => { cy.visit(exampleAppUrl); - cy.origin(exampleAppOrigin, () => { - cy.url().should("include", "example-nextjs-resource-server"); - cy.contains("h1", "@schemavaults/example-nextjs-resource-server"); - }); + cy.url().should("include", "example-nextjs-resource-server"); + cy.contains("h1", "@schemavaults/example-nextjs-resource-server"); }); it("can register a new user through the full OAuth2 PKCE flow and access the protected /account route", () => { @@ -31,12 +29,11 @@ describe("ExampleResourceServer", () => { cy.logout(); // Step 3: Visit example app and click "Register" - // This is cross-origin (example app vs auth server base URL) + // cy.visit() handles the cross-origin transition, so commands + // run directly against the example app after navigating. cy.visit(exampleAppUrl); - cy.origin(exampleAppOrigin, () => { - cy.contains("h1", "@schemavaults/example-nextjs-resource-server"); - cy.contains("button", "Register").click(); - }); + cy.contains("h1", "@schemavaults/example-nextjs-resource-server"); + cy.contains("button", "Register").click(); // Step 4: Example app's /auth/register generates PKCE params and // redirects to auth server's /auth/register with code_challenge, From 91fc2aead1042b0cf16a569b7cca2278912d3098 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 30 Mar 2026 17:07:27 +0000 Subject: [PATCH 7/8] Move cy.visit() inside cy.origin() for cross-origin example app cy.origin() must wrap the entire cross-origin interaction including the visit. Placing cy.visit() inside cy.origin() makes it relative to the example app origin, letting Cypress properly manage the cross-origin context from the start. https://claude.ai/code/session_01SHq9hQdUiZTD9Huz2gMNZ2 --- .../ExampleResourceServer.cy.ts | 29 +++++++++++-------- 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/tests/e2e-auth-tests/cypress/e2e/example_resource_server/ExampleResourceServer.cy.ts b/tests/e2e-auth-tests/cypress/e2e/example_resource_server/ExampleResourceServer.cy.ts index 2c6036f2..6196f1c1 100644 --- a/tests/e2e-auth-tests/cypress/e2e/example_resource_server/ExampleResourceServer.cy.ts +++ b/tests/e2e-auth-tests/cypress/e2e/example_resource_server/ExampleResourceServer.cy.ts @@ -7,9 +7,11 @@ describe("ExampleResourceServer", () => { const exampleAppOrigin: string = new URL(exampleAppUrl).origin; it("can visit the example resource server", () => { - cy.visit(exampleAppUrl); - cy.url().should("include", "example-nextjs-resource-server"); - cy.contains("h1", "@schemavaults/example-nextjs-resource-server"); + cy.origin(exampleAppOrigin, () => { + cy.visit("/"); + cy.url().should("include", "example-nextjs-resource-server"); + cy.contains("h1", "@schemavaults/example-nextjs-resource-server"); + }); }); it("can register a new user through the full OAuth2 PKCE flow and access the protected /account route", () => { @@ -28,17 +30,20 @@ describe("ExampleResourceServer", () => { // Step 2: Logout from admin session cy.logout(); - // Step 3: Visit example app and click "Register" - // cy.visit() handles the cross-origin transition, so commands - // run directly against the example app after navigating. - cy.visit(exampleAppUrl); - cy.contains("h1", "@schemavaults/example-nextjs-resource-server"); - cy.contains("button", "Register").click(); + // Step 3: Visit example app and click "Register". + // Wrap in cy.origin() since example app is cross-origin + // from the auth server base URL. cy.visit() inside + // cy.origin() is relative to the given origin. + cy.origin(exampleAppOrigin, () => { + cy.visit("/"); + cy.contains("h1", "@schemavaults/example-nextjs-resource-server"); + cy.contains("button", "Register").click(); + }); // Step 4: Example app's /auth/register generates PKCE params and // redirects to auth server's /auth/register with code_challenge, // redirect_uri, and app_id query parameters. - // After the redirect we're back on the auth server origin. + // After the redirect we're back on the auth server (base URL) origin. cy.url({ timeout: 20000 }).should("include", "/auth/register"); cy.url().should("include", "code_challenge"); cy.wait_for_page_hydration(); @@ -78,8 +83,8 @@ describe("ExampleResourceServer", () => { // exchanges the auth code + stored code_verifier for tokens, // then redirects to /account. - // Step 8: Verify the protected /account page renders successfully - // We're back on the example app origin after the redirect chain. + // Step 8: Verify the protected /account page renders successfully. + // We're crossing back to the example app origin from the auth server. cy.origin(exampleAppOrigin, () => { cy.url({ timeout: 30000 }).should("include", "/account"); cy.contains("Example Account Page", { From 112a576b802c715832bd786e69ea6424bef31863 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 30 Mar 2026 17:22:15 +0000 Subject: [PATCH 8/8] Strip default port 80 from example resource server URL The browser normalises http://host:80 to http://host (port 80 is the HTTP default). The auth server's CORS validation does a strict string comparison between the browser's Origin header and the registered app domain. With :80 in the registered domain, CORS rejects the token exchange request, causing the PKCE flow to fail silently and redirect the user to / instead of /account. https://claude.ai/code/session_01SHq9hQdUiZTD9Huz2gMNZ2 --- tests/e2e-auth-tests/docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/e2e-auth-tests/docker-compose.yml b/tests/e2e-auth-tests/docker-compose.yml index 25dddbae..d04f746a 100644 --- a/tests/e2e-auth-tests/docker-compose.yml +++ b/tests/e2e-auth-tests/docker-compose.yml @@ -15,7 +15,7 @@ services: CYPRESS_BASE_URL: http://schemavaults-auth:80 SCHEMAVAULTS_APP_ENVIRONMENT: test TEST_SUITE_NAME: ${TEST_SUITE_NAME:?error} - EXAMPLE_NEXTJS_RESOURCE_SERVER_URL: http://example-nextjs-resource-server:80 + EXAMPLE_NEXTJS_RESOURCE_SERVER_URL: http://example-nextjs-resource-server EXAMPLE_NEXTJS_RESOURCE_SERVER_JWKS_ACCESS_PUBLIC_KEY: ${EXAMPLE_NEXTJS_RESOURCE_SERVER_JWKS_ACCESS_PUBLIC_KEY:-null} profiles: - e2e