Skip to content

Commit 93f8478

Browse files
authored
fix: client signIn/signOut must POST with CSRF, not GET (#51)
signIn/signOut navigated with a GET to /api/auth/signin/:provider, but Auth.js v5 only accepts a CSRF-protected POST there, so the GET redirected to /auth/error?error=Configuration. They now fetch /api/auth/csrf and submit a hidden POST form. The playground home button exercises the helper and logout is one-click. Unit tests assert the POST behaviour; e2e covers both sign-in paths and logout.
1 parent b97b275 commit 93f8478

6 files changed

Lines changed: 236 additions & 96 deletions

File tree

playground/src/components/SignOutButton.astro

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,20 @@
22
33
---
44

5-
<a
6-
href="/api/auth/signout?callbackUrl=%2Fapi%2Fauth%2Flogout%2Fcallback"
5+
<button
6+
type="button"
77
data-testid="signout-button"
88
class="cursor-pointer rounded-lg bg-red-500 px-4 py-2 text-sm font-medium text-white transition duration-200 hover:bg-red-600"
99
>
1010
Sign out
11-
</a>
11+
</button>
12+
13+
<script>
14+
import { signOut } from '@zitadel/astro-auth/client';
15+
16+
document
17+
.querySelector('[data-testid="signout-button"]')
18+
?.addEventListener('click', () => {
19+
void signOut({ callbackUrl: '/' });
20+
});
21+
</script>

playground/src/pages/index.astro

Lines changed: 30 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -120,19 +120,26 @@ const session = await getSession(Astro.request, authConfig);
120120
</div>
121121
</div>
122122
<div class="mb-6 flex flex-col gap-3">
123-
<a
124-
href="/api/auth/signin"
125-
data-testid="signin-credentials"
123+
<button
124+
type="button"
125+
data-testid="signin-oauth"
126126
class="flex w-full cursor-pointer items-center justify-center rounded-lg bg-blue-600 px-4 py-3 font-semibold text-white transition duration-200 hover:bg-blue-700"
127127
>
128-
Sign in with Credentials
129-
</a>
128+
Sign in with OAuth
129+
</button>
130+
<button
131+
type="button"
132+
data-testid="signin-default"
133+
class="flex w-full cursor-pointer items-center justify-center rounded-lg border border-blue-600 px-4 py-3 font-semibold text-blue-600 transition duration-200 hover:bg-blue-50"
134+
>
135+
Sign in
136+
</button>
130137
<a
131138
href="/api/auth/signin"
132-
data-testid="signin-oauth"
133-
class="flex w-full cursor-pointer items-center justify-center rounded-lg border border-blue-600 px-4 py-3 font-semibold text-blue-600 transition duration-200 hover:bg-blue-50"
139+
data-testid="signin-credentials"
140+
class="flex w-full cursor-pointer items-center justify-center text-sm font-medium text-blue-600 transition duration-200 hover:text-blue-700"
134141
>
135-
Sign in with OAuth
142+
Sign in with Credentials
136143
</a>
137144
</div>
138145
<div class="text-center">
@@ -197,3 +204,18 @@ const session = await getSession(Astro.request, authConfig);
197204
</main>
198205
<Footer />
199206
</Layout>
207+
208+
<script>
209+
import { signIn } from '@zitadel/astro-auth/client';
210+
211+
document
212+
.querySelector('[data-testid="signin-oauth"]')
213+
?.addEventListener('click', () => {
214+
void signIn('mock-oidc', { callbackUrl: '/profile' });
215+
});
216+
document
217+
.querySelector('[data-testid="signin-default"]')
218+
?.addEventListener('click', () => {
219+
void signIn();
220+
});
221+
</script>

spec/credentials.test.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,11 @@ async function signInWithCredentials(page: Page): Promise<void> {
1515
}
1616

1717
async function signOutUser(page: Page): Promise<void> {
18+
// The logout control is now a one-click client `signOut()` button: it posts
19+
// to /api/auth/signout directly, with no interstitial confirmation page.
20+
// Wait for hydration so the click handler is wired before we click.
21+
await page.waitForLoadState('networkidle');
1822
await page.click('[data-testid="signout-button"]');
19-
await page.locator('button[type="submit"]').click();
2023
await page.waitForURL((url) => !url.pathname.startsWith('/api/auth/'), {
2124
timeout: 10_000,
2225
});

spec/oauth.test.ts

Lines changed: 41 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,27 @@ async function signInWithOAuth(page: Page): Promise<void> {
3030
await page.waitForURL(/\/profile/, { timeout: 30_000 });
3131
}
3232

33+
/**
34+
* Drives the login flow from the home page's client `signIn('mock-oidc')`
35+
* button. Because the helper performs a CSRF-protected POST straight to
36+
* `/api/auth/signin/mock-oidc`, the browser lands directly on the Mock OIDC
37+
* username form — there is no Auth.js provider chooser to click through. This
38+
* is the path that regressed to the Configuration error before the fix.
39+
*
40+
* @param page - The Playwright Page instance
41+
* @returns Resolves once the browser has navigated to /profile
42+
*/
43+
async function signInWithClientHelper(page: Page): Promise<void> {
44+
// Wait for hydration: the button calls the client `signIn` helper, so its
45+
// click handler is only wired once the page's JavaScript has loaded.
46+
await page.goto('/', { waitUntil: 'networkidle' });
47+
await page.click('[data-testid="signin-oauth"]');
48+
await page.waitForSelector('input[name="username"]', { timeout: 30_000 });
49+
await page.fill('input[name="username"]', 'testuser');
50+
await page.locator('[type="submit"]').first().click();
51+
await page.waitForURL(/\/profile/, { timeout: 30_000 });
52+
}
53+
3354
test.beforeAll(
3455
async () => {
3556
container = await new GenericContainer(
@@ -95,39 +116,41 @@ test.afterAll(async () => {
95116

96117
test('homepage shows unauthenticated state', async ({ page }) => {
97118
await page.goto('/');
119+
await expect(page.locator('[data-testid="signin-oauth"]')).toBeVisible();
120+
await expect(page.locator('[data-testid="signin-default"]')).toBeVisible();
98121
await expect(
99122
page.locator('[data-testid="signin-credentials"]'),
100123
).toBeVisible();
101-
await expect(page.locator('[data-testid="signin-oauth"]')).toBeVisible();
102124
});
103125

104-
test('OAuth sign-in via signin-oauth button', async ({ page }) => {
105-
await page.goto('/');
106-
await page.click('[data-testid="signin-oauth"]');
107-
// Auth.js renders a provider confirmation page for GET /signin/:provider;
108-
// click through it to initiate the actual OAuth flow.
109-
await page.waitForSelector('text=Mock OIDC', { timeout: 15_000 });
110-
await page.click('text=Mock OIDC');
111-
await page.waitForSelector('input[name="username"]', { timeout: 15_000 });
112-
await page.fill('input[name="username"]', 'testuser');
113-
await page.locator('[type="submit"]').first().click();
114-
await page.waitForURL(/\/profile/, { timeout: 30_000 });
126+
// Side path: the home page's client `signIn('mock-oidc')` button. This
127+
// exercises the SDK client helper end-to-end — the path that previously threw
128+
// a Configuration error because the helper issued a GET instead of a POST.
129+
test('OAuth sign-in via home client-helper button', async ({ page }) => {
130+
await signInWithClientHelper(page);
131+
await expect(page).toHaveURL(/\/profile/);
115132
await expect(page.locator('[data-testid="signout-button"]')).toBeVisible();
116133
});
117134

135+
// Direct route: the Auth.js-rendered provider chooser at /api/auth/signin.
118136
test('full OAuth flow via Auth.js sign-in page', async ({ page }) => {
119137
await signInWithOAuth(page);
120138
await expect(page).toHaveURL(/\/profile/);
121139
});
122140

123-
test('full sign-in and sign-out cycle', async ({ page }) => {
124-
await signInWithOAuth(page);
125-
await page.goto('/');
141+
// Logout component: the one-click client `signOut()` button on the profile
142+
// page clears the session and returns to the unauthenticated home page.
143+
test('sign-out via logout component', async ({ page }) => {
144+
await signInWithClientHelper(page);
145+
// Wait for hydration so the one-click signOut handler is wired.
146+
await page.waitForLoadState('networkidle');
126147
await page.click('[data-testid="signout-button"]');
127-
await page.locator('button[type="submit"]').click();
128-
await page.waitForURL((url) => !url.pathname.startsWith('/api/auth/'), {
129-
timeout: 10_000,
148+
// signOut clears the session, so the protected profile page is no longer
149+
// reachable (the app bounces unauthenticated users away from it).
150+
await page.waitForURL((url) => !url.pathname.startsWith('/profile'), {
151+
timeout: 30_000,
130152
});
153+
await page.goto('/');
131154
await expect(
132155
page.locator('[data-testid="signin-credentials"]'),
133156
).toBeVisible();

src/client.ts

Lines changed: 47 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,44 @@
1+
const BASE_PATH = '/api/auth';
2+
3+
/**
4+
* Submits a CSRF-protected POST form to an Auth.js action endpoint.
5+
*
6+
* Auth.js v5 only accepts POST requests carrying a CSRF token at its action
7+
* endpoints; a plain GET navigation is rejected as an `UnknownAction` and
8+
* redirects to the Configuration error page. We fetch the current CSRF token
9+
* and submit a hidden form so the browser performs a real POST navigation.
10+
*
11+
* @param action - The Auth.js endpoint to post to
12+
* @param fields - Extra hidden fields to include alongside the CSRF token
13+
*/
14+
async function postToAuth(
15+
action: string,
16+
fields: Record<string, string>,
17+
): Promise<void> {
18+
const res = await fetch(`${BASE_PATH}/csrf`);
19+
const { csrfToken } = (await res.json()) as { csrfToken: string };
20+
21+
const form = document.createElement('form');
22+
form.method = 'POST';
23+
form.action = action;
24+
25+
for (const [name, value] of Object.entries({ csrfToken, ...fields })) {
26+
const input = document.createElement('input');
27+
input.type = 'hidden';
28+
input.name = name;
29+
input.value = value;
30+
form.appendChild(input);
31+
}
32+
33+
document.body.appendChild(form);
34+
form.submit();
35+
}
36+
137
/**
238
* Client-side sign-in helper for Astro applications.
339
*
4-
* @param provider - The provider ID to sign in with
40+
* @param provider - The provider ID to sign in with. When omitted, the request
41+
* targets the provider chooser page.
542
* @param options - Sign-in options
643
*
744
* @public
@@ -10,16 +47,14 @@ export async function signIn(
1047
provider?: string,
1148
options: { callbackUrl?: string } = {},
1249
): Promise<void> {
13-
const basePath = '/api/auth';
14-
const params = new URLSearchParams();
50+
const action = provider
51+
? `${BASE_PATH}/signin/${provider}`
52+
: `${BASE_PATH}/signin`;
53+
const fields: Record<string, string> = {};
1554
if (options.callbackUrl) {
16-
params.set('callbackUrl', options.callbackUrl);
55+
fields.callbackUrl = options.callbackUrl;
1756
}
18-
const paramStr = params.toString();
19-
const url = provider
20-
? `${basePath}/signin/${provider}${paramStr ? `?${paramStr}` : ''}`
21-
: `${basePath}/signin${paramStr ? `?${paramStr}` : ''}`;
22-
window.location.href = url;
57+
await postToAuth(action, fields);
2358
}
2459

2560
/**
@@ -32,11 +67,9 @@ export async function signIn(
3267
export async function signOut(
3368
options: { callbackUrl?: string } = {},
3469
): Promise<void> {
35-
const basePath = '/api/auth';
36-
const params = new URLSearchParams();
70+
const fields: Record<string, string> = {};
3771
if (options.callbackUrl) {
38-
params.set('callbackUrl', options.callbackUrl);
72+
fields.callbackUrl = options.callbackUrl;
3973
}
40-
const paramStr = params.toString();
41-
window.location.href = `${basePath}/signout${paramStr ? `?${paramStr}` : ''}`;
74+
await postToAuth(`${BASE_PATH}/signout`, fields);
4275
}

0 commit comments

Comments
 (0)