-
Notifications
You must be signed in to change notification settings - Fork 440
chore(repo): refactor machine auth tests for Next.js and Astro #8124
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
a75d8c1
70a5063
a9f8580
d1e6035
5804ee0
78b502c
8d7a5c4
f2ac8f2
d151d2f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,186 @@ | ||
| import { randomBytes } from 'node:crypto'; | ||
|
|
||
| import type { ClerkClient, M2MToken, Machine, OAuthApplication } from '@clerk/backend'; | ||
| import { faker } from '@faker-js/faker'; | ||
| import type { Page } from '@playwright/test'; | ||
| import { expect } from '@playwright/test'; | ||
|
|
||
| // ─── M2M ──────────────────────────────────────────────────────────────────── | ||
|
|
||
| export type FakeMachineNetwork = { | ||
| primaryServer: Machine; | ||
| scopedSender: Machine; | ||
| unscopedSender: Machine; | ||
| scopedSenderToken: M2MToken; | ||
| unscopedSenderToken: M2MToken; | ||
| cleanup: () => Promise<void>; | ||
| }; | ||
|
|
||
| /** | ||
| * Creates a network of three machines for M2M testing: | ||
| * - A primary API server (the "receiver") | ||
| * - A sender machine scoped to the primary (should succeed) | ||
| * - A sender machine with no scope (should fail) | ||
| * | ||
| * Each sender gets an opaque M2M token created for it. | ||
| * Call `cleanup()` to revoke tokens and delete all machines. | ||
| */ | ||
| export async function createFakeMachineNetwork(clerkClient: ClerkClient): Promise<FakeMachineNetwork> { | ||
| const fakeCompanyName = faker.company.name(); | ||
|
|
||
| const primaryServer = await clerkClient.machines.create({ | ||
| name: `${fakeCompanyName} Primary API Server`, | ||
| }); | ||
|
|
||
| const scopedSender = await clerkClient.machines.create({ | ||
| name: `${fakeCompanyName} Scoped Sender`, | ||
| scopedMachines: [primaryServer.id], | ||
| }); | ||
| const scopedSenderToken = await clerkClient.m2m.createToken({ | ||
| machineSecretKey: scopedSender.secretKey, | ||
| secondsUntilExpiration: 60 * 30, | ||
| }); | ||
|
|
||
| const unscopedSender = await clerkClient.machines.create({ | ||
| name: `${fakeCompanyName} Unscoped Sender`, | ||
| }); | ||
| const unscopedSenderToken = await clerkClient.m2m.createToken({ | ||
| machineSecretKey: unscopedSender.secretKey, | ||
| secondsUntilExpiration: 60 * 30, | ||
| }); | ||
|
|
||
| return { | ||
| primaryServer, | ||
| scopedSender, | ||
| unscopedSender, | ||
| scopedSenderToken, | ||
| unscopedSenderToken, | ||
| cleanup: async () => { | ||
| await Promise.all([ | ||
| clerkClient.m2m.revokeToken({ m2mTokenId: scopedSenderToken.id }), | ||
| clerkClient.m2m.revokeToken({ m2mTokenId: unscopedSenderToken.id }), | ||
| ]); | ||
| await Promise.all([ | ||
| clerkClient.machines.delete(scopedSender.id), | ||
| clerkClient.machines.delete(unscopedSender.id), | ||
| clerkClient.machines.delete(primaryServer.id), | ||
| ]); | ||
wobsoriano marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| }, | ||
| }; | ||
| } | ||
|
|
||
| /** | ||
| * Creates a JWT-format M2M token for a sender machine. | ||
| * JWT tokens are self-contained and expire via the `exp` claim (no revocation needed). | ||
| */ | ||
| export async function createJwtM2MToken(clerkClient: ClerkClient, senderSecretKey: string): Promise<M2MToken> { | ||
| return clerkClient.m2m.createToken({ | ||
| machineSecretKey: senderSecretKey, | ||
| secondsUntilExpiration: 60 * 30, | ||
| tokenFormat: 'jwt', | ||
| }); | ||
| } | ||
|
|
||
| // ─── OAuth ────────────────────────────────────────────────────────────────── | ||
|
|
||
| export type FakeOAuthApp = { | ||
| oAuthApp: OAuthApplication; | ||
| cleanup: () => Promise<void>; | ||
| }; | ||
|
|
||
| /** | ||
| * Creates an OAuth application via BAPI for testing the full authorization code flow. | ||
| * Call `cleanup()` to delete the OAuth application. | ||
| */ | ||
| export async function createFakeOAuthApp(clerkClient: ClerkClient, callbackUrl: string): Promise<FakeOAuthApp> { | ||
| const oAuthApp = await clerkClient.oauthApplications.create({ | ||
| name: `Integration Test OAuth App - ${Date.now()}`, | ||
| redirectUris: [callbackUrl], | ||
| scopes: 'profile email', | ||
| }); | ||
|
|
||
| return { | ||
| oAuthApp, | ||
| cleanup: async () => { | ||
| await clerkClient.oauthApplications.delete(oAuthApp.id); | ||
| }, | ||
| }; | ||
| } | ||
|
|
||
| export type ObtainOAuthAccessTokenParams = { | ||
| page: Page; | ||
| oAuthApp: OAuthApplication; | ||
| redirectUri: string; | ||
| fakeUser: { email?: string; password: string }; | ||
| signIn: { | ||
| waitForMounted: (...args: any[]) => Promise<any>; | ||
| signInWithEmailAndInstantPassword: (params: { email: string; password: string }) => Promise<any>; | ||
| }; | ||
|
Comment on lines
+110
to
+118
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -euo pipefail
echo "== tsconfig strictness flags =="
fd 'tsconfig*.json' | while read -r f; do
echo "--- $f ---"
python - "$f" <<'PY'
import json,sys
p=sys.argv[1]
try:
d=json.load(open(p))
c=d.get("compilerOptions",{})
print("strict:", c.get("strict"), "strictNullChecks:", c.get("strictNullChecks"))
except Exception as e:
print("unparseable:", e)
PY
done
echo
echo "== nullable flow in machineAuthService =="
rg -n -C2 "fakeUser: \{ email\?: string; password: string \}|searchParams\.get\('code'\)|code: authCode|return tokenData\.access_token" integration/testUtils/machineAuthService.tsRepository: clerk/javascript Length of output: 5798 🏁 Script executed: cat -n integration/testUtils/machineAuthService.ts | sed -n '135,190p'Repository: clerk/javascript Length of output: 2229 🏁 Script executed: cat -n integration/testUtils/machineAuthService.ts | sed -n '110,120p'Repository: clerk/javascript Length of output: 475 🏁 Script executed: fd machineAuthService -type fRepository: clerk/javascript Length of output: 230 🏁 Script executed: grep -r "machineAuthService" --include="*.ts" --include="*.js" --include="*.json" | head -20Repository: clerk/javascript Length of output: 230 🏁 Script executed: cd integration/testUtils && grep -r "obtainOAuthAccessToken" . && cd - || trueRepository: clerk/javascript Length of output: 186 🏁 Script executed: cat -n integration/testUtils/machineAuthService.ts | sed -n '1,20p'Repository: clerk/javascript Length of output: 816 Type contract violations in OAuth token helper create potential runtime failures. The function
While runtime assertions exist at lines 164 and 183, they do not satisfy the type contracts and will only catch issues if tests actually hit them. Ensure 🤖 Prompt for AI Agents |
||
| }; | ||
|
|
||
| /** | ||
| * Runs the full OAuth 2.0 authorization code flow using Playwright: | ||
| * 1. Navigates to the authorize URL | ||
| * 2. Signs in with the provided user credentials | ||
| * 3. Accepts the consent screen | ||
| * 4. Extracts the authorization code from the callback | ||
| * 5. Exchanges the code for an access token | ||
| * | ||
| * Returns the access token string. | ||
| */ | ||
| export async function obtainOAuthAccessToken({ | ||
| page, | ||
| oAuthApp, | ||
| redirectUri, | ||
| fakeUser, | ||
| signIn, | ||
| }: ObtainOAuthAccessTokenParams): Promise<string> { | ||
| const state = randomBytes(16).toString('hex'); | ||
| const authorizeUrl = new URL(oAuthApp.authorizeUrl); | ||
| authorizeUrl.searchParams.set('client_id', oAuthApp.clientId); | ||
| authorizeUrl.searchParams.set('redirect_uri', redirectUri); | ||
| authorizeUrl.searchParams.set('response_type', 'code'); | ||
| authorizeUrl.searchParams.set('scope', 'profile email'); | ||
| authorizeUrl.searchParams.set('state', state); | ||
|
|
||
| await page.goto(authorizeUrl.toString()); | ||
|
|
||
| // Sign in on Account Portal | ||
| await signIn.waitForMounted(); | ||
| await signIn.signInWithEmailAndInstantPassword({ | ||
| email: fakeUser.email, | ||
| password: fakeUser.password, | ||
| }); | ||
|
|
||
| // Accept consent screen | ||
| const consentButton = page.getByRole('button', { name: 'Allow' }); | ||
| await consentButton.waitFor({ timeout: 10000 }); | ||
| await consentButton.click(); | ||
|
|
||
| // Wait for redirect and extract authorization code | ||
| await page.waitForURL(/oauth\/callback/, { timeout: 10000 }); | ||
| const callbackUrl = new URL(page.url()); | ||
| const authCode = callbackUrl.searchParams.get('code'); | ||
| expect(authCode).toBeTruthy(); | ||
|
|
||
| // Exchange code for access token | ||
| expect(oAuthApp.clientSecret).toBeTruthy(); | ||
| const tokenResponse = await page.request.post(oAuthApp.tokenFetchUrl, { | ||
| data: new URLSearchParams({ | ||
| grant_type: 'authorization_code', | ||
| code: authCode, | ||
| redirect_uri: redirectUri, | ||
| client_id: oAuthApp.clientId, | ||
| client_secret: oAuthApp.clientSecret, | ||
| }).toString(), | ||
| headers: { | ||
| 'Content-Type': 'application/x-www-form-urlencoded', | ||
| }, | ||
| }); | ||
|
|
||
| expect(tokenResponse.status()).toBe(200); | ||
| const tokenData = (await tokenResponse.json()) as { access_token?: string }; | ||
| expect(tokenData.access_token).toBeTruthy(); | ||
|
|
||
| return tokenData.access_token; | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.