Skip to content

Commit 9ae79d6

Browse files
committed
Merge branch 'vaggelis/user-4002-implement-the-aio-components-new-flow-and-session-task' into vaggelis/user-4007-dont-allow-untrusted-passwords-to-be-able-to-sign-in
# Conflicts: # packages/clerk-js/src/ui/components/SessionTasks/index.tsx # packages/clerk-js/src/ui/components/SessionTasks/tasks/TaskChooseOrganization/index.tsx # packages/clerk-js/src/ui/components/SessionTasks/tasks/TaskChooseOrganization/withTaskGuard.ts # packages/clerk-js/src/ui/components/SessionTasks/tasks/TaskResetPassword/index.tsx # packages/clerk-js/src/ui/components/SessionTasks/tasks/shared/withTaskGuard.ts # packages/clerk-js/src/ui/components/SessionTasks/tasks/withTaskGuard.ts # packages/clerk-js/src/ui/contexts/ClerkUIComponentsContext.tsx # packages/clerk-js/src/ui/contexts/components/SessionTasks.ts
2 parents c71cf42 + 0c6c23a commit 9ae79d6

File tree

15 files changed

+281
-64
lines changed

15 files changed

+281
-64
lines changed

.changeset/warm-phones-compete.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
---
2+
---
Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
import { randomBytes } from 'node:crypto';
2+
3+
import type { OAuthApplication } from '@clerk/backend';
4+
import { createClerkClient } from '@clerk/backend';
5+
import { TokenType } from '@clerk/backend/internal';
6+
import { expect, test } from '@playwright/test';
7+
8+
import type { Application } from '../../models/application';
9+
import { appConfigs } from '../../presets';
10+
import type { FakeUser } from '../../testUtils';
11+
import { createTestUtils } from '../../testUtils';
12+
13+
test.describe('OAuth machine authentication @machine', () => {
14+
test.describe.configure({ mode: 'parallel' });
15+
let app: Application;
16+
let fakeUser: FakeUser;
17+
let oAuthApp: OAuthApplication;
18+
19+
test.beforeAll(async () => {
20+
test.setTimeout(120_000);
21+
22+
app = await appConfigs.next.appRouter
23+
.clone()
24+
.addFile(
25+
'src/app/api/protected/route.ts',
26+
() => `
27+
import { auth } from '@clerk/nextjs/server';
28+
29+
export async function GET() {
30+
const { userId, tokenType } = await auth({ acceptsToken: 'oauth_token' });
31+
32+
if (!userId) {
33+
return Response.json({ error: 'Unauthorized' }, { status: 401 });
34+
}
35+
36+
return Response.json({ userId, tokenType });
37+
}
38+
`,
39+
)
40+
.addFile(
41+
'src/app/oauth/callback/route.ts',
42+
() => `
43+
import { NextResponse } from 'next/server';
44+
45+
export async function GET() {
46+
return NextResponse.json({ message: 'OAuth callback received' });
47+
}
48+
`,
49+
)
50+
.commit();
51+
52+
await app.setup();
53+
await app.withEnv(appConfigs.envs.withEmailCodes);
54+
await app.dev();
55+
56+
// Test user that will authorize the OAuth application
57+
const u = createTestUtils({ app });
58+
fakeUser = u.services.users.createFakeUser();
59+
await u.services.users.createBapiUser(fakeUser);
60+
61+
const clerkClient = createClerkClient({
62+
secretKey: app.env.privateVariables.get('CLERK_SECRET_KEY'),
63+
publishableKey: app.env.publicVariables.get('CLERK_PUBLISHABLE_KEY'),
64+
});
65+
66+
// Create an OAuth application via the BAPI
67+
oAuthApp = await clerkClient.oauthApplications.create({
68+
name: `Integration Test OAuth App - ${Date.now()}`,
69+
redirectUris: [`${app.serverUrl}/oauth/callback`],
70+
scopes: 'profile email',
71+
});
72+
});
73+
74+
test.afterAll(async () => {
75+
const clerkClient = createClerkClient({
76+
secretKey: app.env.privateVariables.get('CLERK_SECRET_KEY'),
77+
publishableKey: app.env.publicVariables.get('CLERK_PUBLISHABLE_KEY'),
78+
});
79+
80+
if (oAuthApp.id) {
81+
await clerkClient.oauthApplications.delete(oAuthApp.id);
82+
}
83+
84+
await fakeUser.deleteIfExists();
85+
await app.teardown();
86+
});
87+
88+
test('verifies valid OAuth access token obtained through authorization flow', async ({ page, context }) => {
89+
const u = createTestUtils({ app, page, context });
90+
91+
// Build the authorization URL
92+
const state = randomBytes(16).toString('hex');
93+
const redirectUri = `${app.serverUrl}/oauth/callback`;
94+
const authorizeUrl = new URL(oAuthApp.authorizeUrl);
95+
authorizeUrl.searchParams.set('client_id', oAuthApp.clientId);
96+
authorizeUrl.searchParams.set('redirect_uri', redirectUri);
97+
authorizeUrl.searchParams.set('response_type', 'code');
98+
authorizeUrl.searchParams.set('scope', 'profile email');
99+
authorizeUrl.searchParams.set('state', state);
100+
101+
// Navigate to Clerk's authorization endpoint
102+
await u.page.goto(authorizeUrl.toString());
103+
104+
// Sign in on Account Portal
105+
await u.po.signIn.waitForMounted();
106+
await u.po.signIn.signInWithEmailAndInstantPassword({
107+
email: fakeUser.email,
108+
password: fakeUser.password,
109+
});
110+
111+
// Accept consent screen
112+
// Per https://clerk.com/docs/guides/configure/auth-strategies/oauth/how-clerk-implements-oauth#consent-screen-management
113+
const consentButton = u.page.getByRole('button', { name: 'Allow' });
114+
await consentButton.waitFor({ timeout: 10000 });
115+
await consentButton.click();
116+
117+
// Wait for the redirect to complete
118+
await u.page.waitForURL(/oauth\/callback/, { timeout: 10000 });
119+
120+
// Extract the authorization code from the callback URL
121+
const currentUrl = u.page.url();
122+
const urlObj = new URL(currentUrl);
123+
const finalAuthCode = urlObj.searchParams.get('code');
124+
125+
expect(finalAuthCode).toBeTruthy();
126+
127+
// Exchange authorization code for access token
128+
expect(oAuthApp.clientSecret).toBeTruthy();
129+
130+
const tokenResponse = await u.page.request.post(oAuthApp.tokenFetchUrl, {
131+
data: new URLSearchParams({
132+
grant_type: 'authorization_code',
133+
code: finalAuthCode,
134+
redirect_uri: redirectUri,
135+
client_id: oAuthApp.clientId,
136+
client_secret: oAuthApp.clientSecret,
137+
}).toString(),
138+
headers: {
139+
'Content-Type': 'application/x-www-form-urlencoded',
140+
},
141+
});
142+
143+
expect(tokenResponse.status()).toBe(200);
144+
const tokenResponseBody = await tokenResponse.text();
145+
146+
const tokenData = JSON.parse(tokenResponseBody) as { access_token?: string };
147+
const accessToken = tokenData.access_token;
148+
149+
expect(accessToken).toBeTruthy();
150+
151+
// Use the access token to authenticate a request to our protected route
152+
const protectedRouteUrl = new URL('/api/protected', app.serverUrl);
153+
const protectedResponse = await u.page.request.get(protectedRouteUrl.toString(), {
154+
headers: {
155+
Authorization: `Bearer ${accessToken}`,
156+
},
157+
});
158+
159+
expect(protectedResponse.status()).toBe(200);
160+
const authData = await protectedResponse.json();
161+
expect(authData.userId).toBeDefined();
162+
expect(authData.tokenType).toBe(TokenType.OAuthToken);
163+
});
164+
165+
test('rejects request without OAuth token', async ({ request }) => {
166+
const url = new URL('/api/protected', app.serverUrl);
167+
const res = await request.get(url.toString());
168+
expect(res.status()).toBe(401);
169+
});
170+
171+
test('rejects request with invalid OAuth token', async ({ request }) => {
172+
const url = new URL('/api/protected', app.serverUrl);
173+
const res = await request.get(url.toString(), {
174+
headers: {
175+
Authorization: 'Bearer invalid_oauth_token',
176+
},
177+
});
178+
expect(res.status()).toBe(401);
179+
});
180+
});

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -152,7 +152,7 @@
152152
"yalc": "1.0.0-pre.53",
153153
"zx": "catalog:repo"
154154
},
155-
"packageManager": "[email protected]",
155+
"packageManager": "[email protected]+sha512.17c560fca4867ae9473a3899ad84a88334914f379be46d455cbf92e5cf4b39d34985d452d2583baf19967fa76cb5c17bc9e245529d0b98745721aa7200ecaf7a",
156156
"engines": {
157157
"node": ">=18.17.0",
158158
"pnpm": ">=10.17.1"

packages/clerk-js/src/ui/components/SessionTasks/index.tsx

Lines changed: 4 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import { useClerk } from '@clerk/shared/react';
22
import { eventComponentMounted } from '@clerk/shared/telemetry';
3-
import type { SessionResource } from '@clerk/shared/types';
43
import { useEffect, useRef } from 'react';
54

65
import { Flow } from '@/ui/customizables';
@@ -86,7 +85,8 @@ type SessionTasksProps = {
8685
*/
8786
export const SessionTasks = withCardStateProvider(({ redirectUrlComplete }: SessionTasksProps) => {
8887
const clerk = useClerk();
89-
const { navigate, matches } = useRouter();
88+
const { navigate } = useRouter();
89+
9090
const currentTaskContainer = useRef<HTMLDivElement>(null);
9191

9292
// If there are no pending tasks, navigate away from the tasks flow.
@@ -103,7 +103,7 @@ export const SessionTasks = withCardStateProvider(({ redirectUrlComplete }: Sess
103103
}
104104

105105
clerk.telemetry?.record(eventComponentMounted('SessionTask', { task: task.key }));
106-
}, [clerk, matches, navigate, redirectUrlComplete]);
106+
}, [clerk, navigate, redirectUrlComplete]);
107107

108108
if (!clerk.session?.currentTask) {
109109
return (
@@ -120,17 +120,8 @@ export const SessionTasks = withCardStateProvider(({ redirectUrlComplete }: Sess
120120
);
121121
}
122122

123-
const navigateOnSetActive = async ({ session }: { session: SessionResource }) => {
124-
const currentTask = session.currentTask;
125-
if (!currentTask) {
126-
return navigate(redirectUrlComplete);
127-
}
128-
129-
return navigate(`./${INTERNAL_SESSION_TASK_ROUTE_BY_KEY[currentTask.key]}`);
130-
};
131-
132123
return (
133-
<SessionTasksContext.Provider value={{ redirectUrlComplete, currentTaskContainer, navigateOnSetActive }}>
124+
<SessionTasksContext.Provider value={{ redirectUrlComplete }}>
134125
<SessionTasksRoutes />
135126
</SessionTasksContext.Provider>
136127
);

packages/clerk-js/src/ui/components/SessionTasks/tasks/TaskChooseOrganization/ChooseOrganizationScreen.tsx

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import {
1616
sharedMainIdentifierSx,
1717
} from '@/ui/common/organizations/OrganizationPreview';
1818
import { organizationListParams, populateCacheUpdateItem } from '@/ui/components/OrganizationSwitcher/utils';
19-
import { useTaskChooseOrganizationContext } from '@/ui/contexts/components/SessionTasks';
19+
import { useSessionTasksContext, useTaskChooseOrganizationContext } from '@/ui/contexts/components/SessionTasks';
2020
import { Col, descriptors, localizationKeys, Text, useLocalizations } from '@/ui/customizables';
2121
import { Action, Actions } from '@/ui/elements/Actions';
2222
import { Card } from '@/ui/elements/Card';
@@ -25,7 +25,6 @@ import { Header } from '@/ui/elements/Header';
2525
import { OrganizationPreview } from '@/ui/elements/OrganizationPreview';
2626
import { useOrganizationListInView } from '@/ui/hooks/useOrganizationListInView';
2727
import { Add } from '@/ui/icons';
28-
import { useRouter } from '@/ui/router';
2928
import { handleError } from '@/ui/utils/errorHandler';
3029

3130
type ChooseOrganizationScreenProps = {
@@ -107,7 +106,7 @@ export const ChooseOrganizationScreen = (props: ChooseOrganizationScreenProps) =
107106
const MembershipPreview = (props: { organization: OrganizationResource }) => {
108107
const { user } = useUser();
109108
const card = useCardState();
110-
const { navigate } = useRouter();
109+
const { navigateOnSetActive } = useSessionTasksContext();
111110
const { redirectUrlComplete } = useTaskChooseOrganizationContext();
112111
const { isLoaded, setActive } = useOrganizationList();
113112
const { t } = useLocalizations();
@@ -121,9 +120,8 @@ const MembershipPreview = (props: { organization: OrganizationResource }) => {
121120
try {
122121
await setActive({
123122
organization,
124-
navigate: async () => {
125-
// TODO(after-auth) ORGS-779 - Handle next tasks
126-
await navigate(redirectUrlComplete);
123+
navigate: async ({ session }) => {
124+
await navigateOnSetActive?.({ session, redirectUrlComplete });
127125
},
128126
});
129127
} catch (err) {

packages/clerk-js/src/ui/components/SessionTasks/tasks/TaskChooseOrganization/CreateOrganizationScreen.tsx

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,13 @@ import { useOrganizationList } from '@clerk/shared/react';
22
import type { CreateOrganizationParams } from '@clerk/shared/types';
33

44
import { useEnvironment } from '@/ui/contexts';
5-
import { useTaskChooseOrganizationContext } from '@/ui/contexts/components/SessionTasks';
5+
import { useSessionTasksContext, useTaskChooseOrganizationContext } from '@/ui/contexts/components/SessionTasks';
66
import { localizationKeys } from '@/ui/customizables';
77
import { useCardState } from '@/ui/elements/contexts';
88
import { Form } from '@/ui/elements/Form';
99
import { FormButtonContainer } from '@/ui/elements/FormButtons';
1010
import { FormContainer } from '@/ui/elements/FormContainer';
1111
import { Header } from '@/ui/elements/Header';
12-
import { useRouter } from '@/ui/router';
1312
import { createSlug } from '@/ui/utils/createSlug';
1413
import { handleError } from '@/ui/utils/errorHandler';
1514
import { useFormControl } from '@/ui/utils/useFormControl';
@@ -22,7 +21,7 @@ type CreateOrganizationScreenProps = {
2221

2322
export const CreateOrganizationScreen = (props: CreateOrganizationScreenProps) => {
2423
const card = useCardState();
25-
const { navigate } = useRouter();
24+
const { navigateOnSetActive } = useSessionTasksContext();
2625
const { redirectUrlComplete } = useTaskChooseOrganizationContext();
2726
const { createOrganization, isLoaded, setActive } = useOrganizationList({
2827
userMemberships: organizationListParams.userMemberships,
@@ -60,9 +59,8 @@ export const CreateOrganizationScreen = (props: CreateOrganizationScreenProps) =
6059

6160
await setActive({
6261
organization,
63-
navigate: async () => {
64-
// TODO(after-auth) ORGS-779 - Handle next tasks
65-
await navigate(redirectUrlComplete);
62+
navigate: async ({ session }) => {
63+
await navigateOnSetActive?.({ session, redirectUrlComplete });
6664
},
6765
});
6866
} catch (err) {

packages/clerk-js/src/ui/components/SessionTasks/tasks/TaskChooseOrganization/index.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,9 @@ import { withCardStateProvider } from '@/ui/elements/contexts';
88
import { useMultipleSessions } from '@/ui/hooks/useMultipleSessions';
99
import { useOrganizationListInView } from '@/ui/hooks/useOrganizationListInView';
1010

11+
import { withTaskGuard } from '../shared';
1112
import { ChooseOrganizationScreen } from './ChooseOrganizationScreen';
1213
import { CreateOrganizationScreen } from './CreateOrganizationScreen';
13-
import { withTaskGuard } from './withTaskGuard';
1414

1515
const TaskChooseOrganizationInternal = () => {
1616
const { signOut } = useClerk();
@@ -105,5 +105,5 @@ const TaskChooseOrganizationFlows = withCardStateProvider((props: TaskChooseOrga
105105
});
106106

107107
export const TaskChooseOrganization = withCoreSessionSwitchGuard(
108-
withTaskGuard(withCardStateProvider(TaskChooseOrganizationInternal)),
108+
withTaskGuard(withCardStateProvider(TaskChooseOrganizationInternal), 'choose-organization'),
109109
);

0 commit comments

Comments
 (0)