From 7218715e5afd2aae4f7dc27a4492a4f086cb3e93 Mon Sep 17 00:00:00 2001 From: Nikos Douvlis Date: Fri, 12 Jan 2024 15:59:31 +0200 Subject: [PATCH] feat(*): Use User-Agent header with package name and version in BAPI requests --- .changeset/fresh-boats-fry.md | 13 ++ packages/backend/package.json | 2 +- packages/backend/src/api/factory.test.ts | 14 +- packages/backend/src/api/request.ts | 2 +- packages/backend/src/constants.ts | 6 +- packages/backend/src/tokens/authObjects.ts | 8 +- packages/backend/src/tokens/authStatus.ts | 12 +- .../backend/src/tokens/interstitialRule.ts | 4 +- packages/backend/src/tokens/keys.test.ts | 12 +- packages/backend/src/tokens/request.test.ts | 2 + packages/backend/src/tokens/verify.test.ts | 4 +- packages/backend/tsup.config.test.ts | 25 +++ packages/fastify/jest.config.js | 4 + .../__snapshots__/clerkClient.test.ts.snap | 1 + .../src/__snapshots__/constants.test.ts.snap | 1 + packages/fastify/src/clerkClient.ts | 2 + .../src/ssr/clerkClient.ts | 2 +- packages/nextjs/jest.config.js | 1 + packages/nextjs/src/app-router/server/auth.ts | 3 +- packages/nextjs/src/server-helpers.server.ts | 2 +- .../src/server/__tests__/clerkClient.test.ts | 16 ++ packages/nextjs/src/server/buildClerkProps.ts | 61 ++++++++ packages/nextjs/src/server/clerkClient.ts | 3 +- .../nextjs/src/server/createGetAuth.test.ts | 50 ++++++ packages/nextjs/src/server/createGetAuth.ts | 106 +++++++++++++ packages/nextjs/src/server/getAuth.ts | 145 ------------------ packages/nextjs/src/server/index.ts | 3 +- packages/nextjs/src/server/utils.ts | 31 ++-- .../nextjs/src/server/withClerkMiddleware.ts | 1 + packages/remix/src/ssr/authenticateRequest.ts | 11 +- packages/remix/src/ssr/utils.ts | 4 - 31 files changed, 358 insertions(+), 193 deletions(-) create mode 100644 .changeset/fresh-boats-fry.md create mode 100644 packages/backend/tsup.config.test.ts create mode 100644 packages/nextjs/src/server/__tests__/clerkClient.test.ts create mode 100644 packages/nextjs/src/server/buildClerkProps.ts create mode 100644 packages/nextjs/src/server/createGetAuth.test.ts create mode 100644 packages/nextjs/src/server/createGetAuth.ts delete mode 100644 packages/nextjs/src/server/getAuth.ts diff --git a/.changeset/fresh-boats-fry.md b/.changeset/fresh-boats-fry.md new file mode 100644 index 00000000000..38f089c5a2b --- /dev/null +++ b/.changeset/fresh-boats-fry.md @@ -0,0 +1,13 @@ +--- +'gatsby-plugin-clerk': minor +'@clerk/clerk-sdk-node': minor +'@clerk/backend': minor +'@clerk/fastify': minor +'@clerk/nextjs': minor +'@clerk/remix': minor +--- + +Replace the `Clerk-Backend-SDK` header with `User-Agent` in BAPI requests and update it's value to contain both the package name and the package version of the clerk package +executing the request. Eg request from `@clerk/nextjs` to BAPI with append `User-Agent: @clerk/nextjs@5.0.0-alpha-v5.16` using the latest version. + +Miscellaneous changes: The backend test build changed to use tsup. diff --git a/packages/backend/package.json b/packages/backend/package.json index e4ce0aa0ec2..9e2b71566de 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -41,7 +41,7 @@ "build:declarations": "tsc -p tsconfig.declarations.json", "publish:local": "npx yalc push --replace --sig", "build:lib": "tsup --env.NODE_ENV production", - "build:tests": "tsc -p tsconfig.test.json", + "build:tests": "tsup --config tsup.config.test.ts", "build:runtime": "rsync -r --include '*/' --include '*.js' --include '*.mjs' --exclude='*' src/runtime dist", "clean": "rimraf ./dist", "clean:tests": "rimraf ./tests/dist", diff --git a/packages/backend/src/api/factory.test.ts b/packages/backend/src/api/factory.test.ts index 2d403c26aba..ca2e59e0142 100644 --- a/packages/backend/src/api/factory.test.ts +++ b/packages/backend/src/api/factory.test.ts @@ -47,7 +47,7 @@ export default (QUnit: QUnit) => { headers: { Authorization: 'Bearer deadbeef', 'Content-Type': 'application/json', - 'Clerk-Backend-SDK': '@clerk/backend', + 'User-Agent': '@clerk/backend@0.0.0-test', }, }), ); @@ -78,7 +78,7 @@ export default (QUnit: QUnit) => { headers: { Authorization: 'Bearer deadbeef', 'Content-Type': 'application/json', - 'Clerk-Backend-SDK': '@clerk/backend', + 'User-Agent': '@clerk/backend@0.0.0-test', }, }), ); @@ -112,7 +112,7 @@ export default (QUnit: QUnit) => { headers: { Authorization: 'Bearer deadbeef', 'Content-Type': 'application/json', - 'Clerk-Backend-SDK': '@clerk/backend', + 'User-Agent': '@clerk/backend@0.0.0-test', }, }), ); @@ -136,7 +136,7 @@ export default (QUnit: QUnit) => { headers: { Authorization: 'Bearer deadbeef', 'Content-Type': 'application/json', - 'Clerk-Backend-SDK': '@clerk/backend', + 'User-Agent': '@clerk/backend@0.0.0-test', }, body: JSON.stringify({ first_name: 'John', @@ -180,7 +180,7 @@ export default (QUnit: QUnit) => { headers: { Authorization: 'Bearer deadbeef', 'Content-Type': 'application/json', - 'Clerk-Backend-SDK': '@clerk/backend', + 'User-Agent': '@clerk/backend@0.0.0-test', }, }), ); @@ -204,7 +204,7 @@ export default (QUnit: QUnit) => { headers: { Authorization: 'Bearer deadbeef', 'Content-Type': 'application/json', - 'Clerk-Backend-SDK': '@clerk/backend', + 'User-Agent': '@clerk/backend@0.0.0-test', }, }), ); @@ -229,7 +229,7 @@ export default (QUnit: QUnit) => { headers: { Authorization: 'Bearer deadbeef', 'Content-Type': 'application/json', - 'Clerk-Backend-SDK': '@clerk/backend', + 'User-Agent': '@clerk/backend@0.0.0-test', }, }), ); diff --git a/packages/backend/src/api/request.ts b/packages/backend/src/api/request.ts index d0b44f08bae..e788d7fb14a 100644 --- a/packages/backend/src/api/request.ts +++ b/packages/backend/src/api/request.ts @@ -115,7 +115,7 @@ export function buildRequest(options: CreateBackendApiOptions) { // Build headers const headers: Record = { Authorization: `Bearer ${key}`, - 'Clerk-Backend-SDK': userAgent, + 'User-Agent': userAgent, ...headerParams, }; diff --git a/packages/backend/src/constants.ts b/packages/backend/src/constants.ts index 67679b4fbbd..ae02f434b98 100644 --- a/packages/backend/src/constants.ts +++ b/packages/backend/src/constants.ts @@ -1,11 +1,11 @@ export const API_URL = 'https://api.clerk.dev'; export const API_VERSION = 'v1'; -// TODO: Get information from package.json or define them from ESBuild -export const USER_AGENT = `@clerk/backend`; +export const USER_AGENT = `${PACKAGE_NAME}@${PACKAGE_VERSION}`; export const MAX_CACHE_LAST_UPDATED_AT_SECONDS = 5 * 60; const Attributes = { + AuthToken: '__clerkAuthToken', AuthStatus: '__clerkAuthStatus', AuthReason: '__clerkAuthReason', AuthMessage: '__clerkAuthMessage', @@ -17,6 +17,7 @@ const Cookies = { } as const; const Headers = { + AuthToken: 'x-clerk-auth-token', AuthStatus: 'x-clerk-auth-status', AuthReason: 'x-clerk-auth-reason', AuthMessage: 'x-clerk-auth-message', @@ -36,6 +37,7 @@ const Headers = { const SearchParams = { AuthStatus: Headers.AuthStatus, + AuthToken: Headers.AuthToken, } as const; const ContentTypes = { diff --git a/packages/backend/src/tokens/authObjects.ts b/packages/backend/src/tokens/authObjects.ts index 9a37fcdab56..2c8e60f6229 100644 --- a/packages/backend/src/tokens/authObjects.ts +++ b/packages/backend/src/tokens/authObjects.ts @@ -15,7 +15,7 @@ import type { RequestState } from './authStatus'; import type { AuthenticateRequestOptions } from './request'; type AuthObjectDebugData = Partial; -type CreateAuthObjectDebug = (data?: AuthObjectDebugData) => AuthObjectDebug; +type CreateAuthObjectDebug = (data?: Record) => AuthObjectDebug; type AuthObjectDebug = () => unknown; export type SignedInAuthObjectOptions = { @@ -71,9 +71,9 @@ export type AuthObject = SignedInAuthObject | SignedOutAuthObject; const createDebug: CreateAuthObjectDebug = data => { return () => { const res = { ...data } || {}; - res.apiKey = (res.apiKey || '').substring(0, 7); - res.secretKey = (res.secretKey || '').substring(0, 7); - res.jwtKey = (res.jwtKey || '').substring(0, 7); + res.apiKey = ((res.apiKey as string) || '').substring(0, 7); + res.secretKey = ((res.secretKey as string) || '').substring(0, 7); + res.jwtKey = ((res.jwtKey as string) || '').substring(0, 7); return { ...res }; }; }; diff --git a/packages/backend/src/tokens/authStatus.ts b/packages/backend/src/tokens/authStatus.ts index 953a028d419..46cb593dfcc 100644 --- a/packages/backend/src/tokens/authStatus.ts +++ b/packages/backend/src/tokens/authStatus.ts @@ -29,6 +29,7 @@ export type SignedInState = { isInterstitial: false; isUnknown: false; toAuth: () => SignedInAuthObject; + token: string; }; export type SignedOutState = { @@ -48,6 +49,7 @@ export type SignedOutState = { isInterstitial: false; isUnknown: false; toAuth: () => SignedOutAuthObject; + token: null; }; export type InterstitialState = Omit & { @@ -83,7 +85,10 @@ export type AuthReason = AuthErrorReason | TokenVerificationErrorReason; export type RequestState = SignedInState | SignedOutState | InterstitialState | UnknownState; -export async function signedIn(options: T, sessionClaims: JwtPayload): Promise { +export async function signedIn( + options: T, + sessionClaims: JwtPayload, +): Promise { const { apiKey, secretKey, @@ -103,6 +108,7 @@ export async function signedIn(options: T, sessionClaims: JwtPayload): Promis signUpUrl, afterSignInUrl, afterSignUpUrl, + token, } = options as any; const { sid: sessionId, org_id: orgId, sub: userId } = sessionClaims; @@ -159,6 +165,7 @@ export async function signedIn(options: T, sessionClaims: JwtPayload): Promis isInterstitial: false, isUnknown: false, toAuth: () => authObject, + token, }; } @@ -192,6 +199,7 @@ export function signedOut(options: T, reason: AuthReason, message = ''): Sign isInterstitial: false, isUnknown: false, toAuth: () => signedOutAuthObject({ ...options, status: AuthStatus.SignedOut, reason, message }), + token: null, }; } @@ -224,6 +232,7 @@ export function interstitial(options: T, reason: AuthReason, message = ''): I isInterstitial: true, isUnknown: false, toAuth: () => null, + token: null, }; } @@ -246,5 +255,6 @@ export function unknownState(options: T, reason: AuthReason, message = ''): U isInterstitial: false, isUnknown: true, toAuth: () => null, + token: null, }; } diff --git a/packages/backend/src/tokens/interstitialRule.ts b/packages/backend/src/tokens/interstitialRule.ts index 19dd3795d26..960de127c6f 100644 --- a/packages/backend/src/tokens/interstitialRule.ts +++ b/packages/backend/src/tokens/interstitialRule.ts @@ -123,13 +123,13 @@ export const hasPositiveClientUatButCookieIsMissing: InterstitialRule = options export const hasValidHeaderToken: InterstitialRule = async options => { const { headerToken } = options as any; const sessionClaims = await verifyRequestState(options, headerToken); - return await signedIn(options, sessionClaims); + return await signedIn({ ...options, token: headerToken }, sessionClaims); }; export const hasValidCookieToken: InterstitialRule = async options => { const { cookieToken, clientUat } = options as any; const sessionClaims = await verifyRequestState(options, cookieToken); - const state = await signedIn(options, sessionClaims); + const state = await signedIn({ ...options, token: cookieToken }, sessionClaims); const jwt = state.toAuth().sessionClaims; const cookieTokenIsOutdated = jwt.iat < Number.parseInt(clientUat); diff --git a/packages/backend/src/tokens/keys.test.ts b/packages/backend/src/tokens/keys.test.ts index d203a94c114..4e38e96e0e3 100644 --- a/packages/backend/src/tokens/keys.test.ts +++ b/packages/backend/src/tokens/keys.test.ts @@ -59,7 +59,7 @@ export default (QUnit: QUnit) => { headers: { Authorization: 'Bearer deadbeef', 'Content-Type': 'application/json', - 'Clerk-Backend-SDK': '@clerk/backend', + 'User-Agent': '@clerk/backend@0.0.0-test', }, }); assert.propEqual(jwk, mockRsaJwk); @@ -78,7 +78,7 @@ export default (QUnit: QUnit) => { headers: { Authorization: 'Bearer deadbeef', 'Content-Type': 'application/json', - 'Clerk-Backend-SDK': '@clerk/backend', + 'User-Agent': '@clerk/backend@0.0.0-test', }, }); assert.propEqual(jwk, mockRsaJwk); @@ -96,7 +96,7 @@ export default (QUnit: QUnit) => { method: 'GET', headers: { 'Content-Type': 'application/json', - 'Clerk-Backend-SDK': '@clerk/backend', + 'User-Agent': '@clerk/backend@0.0.0-test', }, }); assert.propEqual(jwk, mockRsaJwk); @@ -116,7 +116,7 @@ export default (QUnit: QUnit) => { headers: { Authorization: 'Bearer sk_test_deadbeef', 'Content-Type': 'application/json', - 'Clerk-Backend-SDK': '@clerk/backend', + 'User-Agent': '@clerk/backend@0.0.0-test', }, }); assert.propEqual(jwk, mockRsaJwk); @@ -203,7 +203,7 @@ export default (QUnit: QUnit) => { action: 'Contact support@clerk.com', }); assert.propContains(err, { - message: `Unable to find a signing key in JWKS that matches the kid='${kid}' of the provided session token. Please make sure that the __session cookie or the HTTP authorization header contain a Clerk-generated session JWT. The following kid are available: ${mockRsaJwkKid}, local`, + message: `Unable to find a signing key in JWKS that matches the kid='${kid}' of the provided session token. Please make sure that the __session cookie or the HTTP authorization header contain a Clerk-generated session JWT. The following kid are available: local, ${mockRsaJwkKid}`, }); } else { // This should never be reached. If it does, the suite should fail @@ -229,7 +229,7 @@ export default (QUnit: QUnit) => { action: 'Contact support@clerk.com', }); assert.propContains(err, { - message: `Unable to find a signing key in JWKS that matches the kid='${kid}' of the provided session token. Please make sure that the __session cookie or the HTTP authorization header contain a Clerk-generated session JWT. The following kid are available: ${mockRsaJwkKid}, local`, + message: `Unable to find a signing key in JWKS that matches the kid='${kid}' of the provided session token. Please make sure that the __session cookie or the HTTP authorization header contain a Clerk-generated session JWT. The following kid are available: local, ${mockRsaJwkKid}`, }); } else { // This should never be reached. If it does, the suite should fail diff --git a/packages/backend/src/tokens/request.test.ts b/packages/backend/src/tokens/request.test.ts index 2bde827ece2..d5da4024971 100644 --- a/packages/backend/src/tokens/request.test.ts +++ b/packages/backend/src/tokens/request.test.ts @@ -36,6 +36,7 @@ function assertSignedOut( domain: '', message: '', toAuth: {}, + token: null, ...expectedState, }); } @@ -80,6 +81,7 @@ function assertInterstitial( afterSignUpUrl: '', domain: '', toAuth: {}, + token: null, ...expectedState, }); } diff --git a/packages/backend/src/tokens/verify.test.ts b/packages/backend/src/tokens/verify.test.ts index 5c29652fb10..152848720c9 100644 --- a/packages/backend/src/tokens/verify.test.ts +++ b/packages/backend/src/tokens/verify.test.ts @@ -51,7 +51,7 @@ export default (QUnit: QUnit) => { headers: { Authorization: 'Bearer a-valid-key', 'Content-Type': 'application/json', - 'Clerk-Backend-SDK': '@clerk/backend', + 'User-Agent': '@clerk/backend@0.0.0-test', }, }); assert.propEqual(payload, mockJwtPayload); @@ -68,7 +68,7 @@ export default (QUnit: QUnit) => { method: 'GET', headers: { 'Content-Type': 'application/json', - 'Clerk-Backend-SDK': '@clerk/backend', + 'User-Agent': '@clerk/backend@0.0.0-test', }, }); assert.propEqual(payload, mockJwtPayload); diff --git a/packages/backend/tsup.config.test.ts b/packages/backend/tsup.config.test.ts new file mode 100644 index 00000000000..6be0aab4169 --- /dev/null +++ b/packages/backend/tsup.config.test.ts @@ -0,0 +1,25 @@ +import { defineConfig } from 'tsup'; + +// @ts-ignore +import { name } from './package.json'; + +export default defineConfig(overrideOptions => { + const isWatch = !!overrideOptions.watch; + + return { + entry: ['./src/**/*.{ts,js}'], + outDir: 'tests/dist/', + define: { + PACKAGE_NAME: `"${name}"`, + // use "test" instead of actual package version to avoid updating the tests + // depending on it (eg userAgent related) on every version bump + PACKAGE_VERSION: `"0.0.0-test"`, + __DEV__: `${isWatch}`, + }, + external: ['#crypto'], + clean: true, + minify: false, + tsconfig: 'tsconfig.test.json', + format: 'cjs', + }; +}); diff --git a/packages/fastify/jest.config.js b/packages/fastify/jest.config.js index 8dfef518705..113bc7709fc 100644 --- a/packages/fastify/jest.config.js +++ b/packages/fastify/jest.config.js @@ -1,5 +1,9 @@ /** @type {import('ts-jest').JestConfigWithTsJest} */ module.exports = { + globals: { + PACKAGE_NAME: '@clerk/fastify', + PACKAGE_VERSION: '0.0.0-test', + }, displayName: 'fastify', injectGlobals: true, roots: ['/src'], diff --git a/packages/fastify/src/__snapshots__/clerkClient.test.ts.snap b/packages/fastify/src/__snapshots__/clerkClient.test.ts.snap index 77048672034..3cc63a09a2f 100644 --- a/packages/fastify/src/__snapshots__/clerkClient.test.ts.snap +++ b/packages/fastify/src/__snapshots__/clerkClient.test.ts.snap @@ -8,6 +8,7 @@ exports[`clerk initializes clerk with constants 1`] = ` "apiVersion": "v1", "jwtKey": "", "secretKey": "TEST_API_KEY", + "userAgent": "@clerk/fastify@0.0.0-test", }, ], ] diff --git a/packages/fastify/src/__snapshots__/constants.test.ts.snap b/packages/fastify/src/__snapshots__/constants.test.ts.snap index 0e3330bd47e..c51d99bab1a 100644 --- a/packages/fastify/src/__snapshots__/constants.test.ts.snap +++ b/packages/fastify/src/__snapshots__/constants.test.ts.snap @@ -14,6 +14,7 @@ exports[`constants from environment variables 1`] = ` "AuthMessage": "x-clerk-auth-message", "AuthReason": "x-clerk-auth-reason", "AuthStatus": "x-clerk-auth-status", + "AuthToken": "x-clerk-auth-token", "Authorization": "authorization", "ClerkRedirectTo": "x-clerk-redirect-to", "CloudFrontForwardedProto": "cloudfront-forwarded-proto", diff --git a/packages/fastify/src/clerkClient.ts b/packages/fastify/src/clerkClient.ts index 03141d50442..c93fb2742bd 100644 --- a/packages/fastify/src/clerkClient.ts +++ b/packages/fastify/src/clerkClient.ts @@ -9,4 +9,6 @@ export const clerkClient = createClerkClient({ apiUrl: API_URL, apiVersion: API_VERSION, jwtKey: JWT_KEY, + // @ts-ignore - defined by tsup config + userAgent: `${PACKAGE_NAME}@${PACKAGE_VERSION}`, }); diff --git a/packages/gatsby-plugin-clerk/src/ssr/clerkClient.ts b/packages/gatsby-plugin-clerk/src/ssr/clerkClient.ts index a6ed05bc75b..b312d2399f7 100644 --- a/packages/gatsby-plugin-clerk/src/ssr/clerkClient.ts +++ b/packages/gatsby-plugin-clerk/src/ssr/clerkClient.ts @@ -9,7 +9,7 @@ const clerkClient = Clerk({ apiUrl: API_URL, apiVersion: API_VERSION, // TODO: Fetch version from package.json - userAgent: 'gatsby-plugin-clerk', + userAgent: `${PACKAGE_NAME}@${PACKAGE_VERSION}`, }); const createClerkClient = Clerk; diff --git a/packages/nextjs/jest.config.js b/packages/nextjs/jest.config.js index 6a91935b956..9223ae2e1ac 100644 --- a/packages/nextjs/jest.config.js +++ b/packages/nextjs/jest.config.js @@ -1,6 +1,7 @@ /** @type {import('ts-jest').JestConfigWithTsJest} */ module.exports = { globals: { + PACKAGE_NAME: '@clerk/nextjs', PACKAGE_VERSION: '0.0.0-test', }, displayName: 'nextjs', diff --git a/packages/nextjs/src/app-router/server/auth.ts b/packages/nextjs/src/app-router/server/auth.ts index 43a4e87144a..a71f3b5f9ca 100644 --- a/packages/nextjs/src/app-router/server/auth.ts +++ b/packages/nextjs/src/app-router/server/auth.ts @@ -5,8 +5,9 @@ import type { } from '@clerk/types'; import { notFound, redirect } from 'next/navigation'; +import { buildClerkProps } from '../../server/buildClerkProps'; +import { createGetAuth } from '../../server/createGetAuth'; import { authAuthHeaderMissing } from '../../server/errors'; -import { buildClerkProps, createGetAuth } from '../../server/getAuth'; import type { AuthObjectWithDeprecatedResources } from '../../server/types'; import { buildRequestLike } from './utils'; diff --git a/packages/nextjs/src/server-helpers.server.ts b/packages/nextjs/src/server-helpers.server.ts index a4a04255f9a..2d0f58d7bf4 100644 --- a/packages/nextjs/src/server-helpers.server.ts +++ b/packages/nextjs/src/server-helpers.server.ts @@ -2,7 +2,7 @@ import { auth } from './app-router/server/auth'; import { currentUser } from './app-router/server/currentUser'; import { authMiddleware } from './server/authMiddleware'; import { clerkClient } from './server/clerkClient'; -import { getAuth } from './server/getAuth'; +import { getAuth } from './server/createGetAuth'; import { redirectToSignIn, redirectToSignUp } from './server/redirect'; import { withClerkMiddleware } from './server/withClerkMiddleware'; diff --git a/packages/nextjs/src/server/__tests__/clerkClient.test.ts b/packages/nextjs/src/server/__tests__/clerkClient.test.ts new file mode 100644 index 00000000000..2289c87998f --- /dev/null +++ b/packages/nextjs/src/server/__tests__/clerkClient.test.ts @@ -0,0 +1,16 @@ +global.fetch = jest.fn(() => Promise.resolve(new Response(null))); + +import { clerkClient } from '../clerkClient'; + +describe('clerkClient', () => { + it('should pass version package to userAgent', async () => { + await clerkClient.users.getUser('user_test'); + + expect(global.fetch).toBeCalled(); + expect((global.fetch as any).mock.calls[0][1].headers).toMatchObject({ + Authorization: 'Bearer TEST_API_KEY', + 'Content-Type': 'application/json', + 'User-Agent': '@clerk/nextjs@0.0.0-test', + }); + }); +}); diff --git a/packages/nextjs/src/server/buildClerkProps.ts b/packages/nextjs/src/server/buildClerkProps.ts new file mode 100644 index 00000000000..3fa56c7a7d0 --- /dev/null +++ b/packages/nextjs/src/server/buildClerkProps.ts @@ -0,0 +1,61 @@ +import type { Organization, Session, User } from '@clerk/backend'; +import { + AuthStatus, + decodeJwt, + makeAuthObjectSerializable, + sanitizeAuthObject, + signedInAuthObject, + signedOutAuthObject, +} from '@clerk/backend'; + +import { API_KEY, API_URL, API_VERSION, SECRET_KEY } from './clerkClient'; +import type { RequestLike } from './types'; +import { getAuthKeyFromRequest, injectSSRStateIntoObject } from './utils'; + +type BuildClerkPropsInitState = { user?: User | null; session?: Session | null; organization?: Organization | null }; + +/** + * To enable Clerk SSR support, include this object to the `props` + * returned from `getServerSideProps`. This will automatically make the auth state available to + * the Clerk components and hooks during SSR, the hydration phase and CSR. + * @example + * import { getAuth } from '@clerk/nextjs/server'; + * + * export const getServerSideProps = ({ req }) => { + * const { authServerSideProps } = getAuth(req); + * const myData = getMyData(); + * + * return { + * props: { myData, authServerSideProps }, + * }; + * }; + */ +type BuildClerkProps = (req: RequestLike, authState?: BuildClerkPropsInitState) => Record; + +export const buildClerkProps: BuildClerkProps = (req, initState = {}) => { + const authToken = getAuthKeyFromRequest(req, 'AuthToken'); + const authStatus = getAuthKeyFromRequest(req, 'AuthStatus') as AuthStatus; + const authMessage = getAuthKeyFromRequest(req, 'AuthMessage'); + const authReason = getAuthKeyFromRequest(req, 'AuthReason'); + + const options = { + apiKey: API_KEY, + secretKey: SECRET_KEY, + apiUrl: API_URL, + apiVersion: API_VERSION, + authStatus, + authMessage, + authReason, + }; + + let authObject; + if (!authStatus || authStatus !== AuthStatus.SignedIn) { + authObject = signedOutAuthObject(options); + } else { + const { payload, raw } = decodeJwt(authToken as string); + authObject = signedInAuthObject(payload, { ...options, token: raw.text }); + } + + const sanitizedAuthObject = makeAuthObjectSerializable(sanitizeAuthObject({ ...authObject, ...initState })); + return injectSSRStateIntoObject({}, sanitizedAuthObject); +}; diff --git a/packages/nextjs/src/server/clerkClient.ts b/packages/nextjs/src/server/clerkClient.ts index 5ec9ca27118..6c8b4b24bbd 100644 --- a/packages/nextjs/src/server/clerkClient.ts +++ b/packages/nextjs/src/server/clerkClient.ts @@ -8,8 +8,7 @@ const clerkClient = Clerk({ secretKey: SECRET_KEY, apiUrl: API_URL, apiVersion: API_VERSION, - // TODO: Fetch version from package.json - userAgent: '@clerk/nextjs', + userAgent: `${PACKAGE_NAME}@${PACKAGE_VERSION}`, proxyUrl: PROXY_URL, domain: DOMAIN, isSatellite: IS_SATELLITE, diff --git a/packages/nextjs/src/server/createGetAuth.test.ts b/packages/nextjs/src/server/createGetAuth.test.ts new file mode 100644 index 00000000000..ddd3cb5429c --- /dev/null +++ b/packages/nextjs/src/server/createGetAuth.test.ts @@ -0,0 +1,50 @@ +import { AuthStatus, constants } from '@clerk/backend'; +import { NextRequest } from 'next/server'; + +import { createGetAuth, getAuth } from './createGetAuth'; + +// { alg: 'HS256' }.{ sub: 'user-id' }.sig +const mockToken = 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1c2VyLWlkIn0.0u5CllULtDVD9DUUmUMdJLbBCSNcnv4j3hCaPz4dNr8'; + +describe('createGetAuth(opts)', () => { + it('returns a getAuth function', () => { + expect(createGetAuth({ debugLoggerName: 'test', noAuthStatusMessage: 'test' })).toBeInstanceOf(Function); + }); +}); + +describe('getAuth(req)', () => { + it('parses and returns the token claims when signed in', () => { + const req = new NextRequest('https://www.clerk.com', { + headers: new Headers({ + [constants.Headers.AuthStatus]: AuthStatus.SignedIn, + [constants.Headers.AuthToken]: mockToken, + [constants.Headers.AuthMessage]: 'message', + [constants.Headers.AuthReason]: 'reason', + }), + }); + + expect(getAuth(req).userId).toEqual('user-id'); + }); + + it('parses and returns the token claims when signed out', () => { + const req = new NextRequest('https://www.clerk.com', { + headers: new Headers({ + [constants.Headers.AuthStatus]: AuthStatus.SignedOut, + [constants.Headers.AuthMessage]: 'message', + [constants.Headers.AuthReason]: 'reason', + }), + }); + + expect(getAuth(req).userId).toEqual(null); + }); + + it('throws if auth status is not found', () => { + const req = new NextRequest('https://www.clerk.com', { + headers: new Headers({ + [constants.Headers.AuthToken]: mockToken, + }), + }); + + expect(() => getAuth(req)).toThrowError(); + }); +}); diff --git a/packages/nextjs/src/server/createGetAuth.ts b/packages/nextjs/src/server/createGetAuth.ts new file mode 100644 index 00000000000..ab333e01ebf --- /dev/null +++ b/packages/nextjs/src/server/createGetAuth.ts @@ -0,0 +1,106 @@ +import type { SignedInAuthObject, SignedOutAuthObject } from '@clerk/backend'; +import { AuthStatus, constants, decodeJwt, signedInAuthObject, signedOutAuthObject } from '@clerk/backend'; +import { deprecatedObjectProperty } from '@clerk/shared/deprecated'; +import type { SecretKeyOrApiKey } from '@clerk/types'; + +import { withLogger } from '../utils/debugLogger'; +import { API_KEY, API_URL, API_VERSION, SECRET_KEY } from './clerkClient'; +import { getAuthAuthHeaderMissing } from './errors'; +import type { AuthObjectWithDeprecatedResources, RequestLike } from './types'; +import { getAuthKeyFromRequest, getCookie, getHeader } from './utils'; + +type GetAuthOpts = Partial; + +export const createGetAuth = ({ + debugLoggerName, + noAuthStatusMessage, +}: { + noAuthStatusMessage: string; + debugLoggerName: string; +}) => + withLogger(debugLoggerName, logger => { + return ( + req: RequestLike, + opts?: GetAuthOpts, + ): + | AuthObjectWithDeprecatedResources + | AuthObjectWithDeprecatedResources => { + if (getHeader(req, constants.Headers.EnableDebug) === 'true') { + logger.enable(); + } + + // When the auth status is set, we trust that the middleware has already run + // Then, we don't have to re-verify the JWT here, + // we can just strip out the claims manually. + const authToken = getAuthKeyFromRequest(req, 'AuthToken') as string; + const authStatus = getAuthKeyFromRequest(req, 'AuthStatus') as AuthStatus; + const authMessage = getAuthKeyFromRequest(req, 'AuthMessage'); + const authReason = getAuthKeyFromRequest(req, 'AuthReason'); + + logger.debug('Debug', { + authReason, + authMessage, + authStatus, + authToken, + }); + + if (!authStatus) { + throw new Error(noAuthStatusMessage); + } + + const options = { + apiKey: opts?.apiKey || API_KEY, + authStatus, + authMessage, + secretKey: opts?.secretKey || SECRET_KEY, + authReason, + authToken, + apiUrl: API_URL, + apiVersion: API_VERSION, + }; + logger.debug('Options debug', options); + + if (authStatus !== AuthStatus.SignedIn) { + return signedOutAuthObject(options); + } + + const jwt = decodeJwt(authToken); + logger.debug('JWT debug', jwt.raw.text); + + const signedIn = signedInAuthObject(jwt.payload, { + ...options, + token: jwt.raw.text, + }); + + if (signedIn) { + if (signedIn.user) { + deprecatedObjectProperty(signedIn, 'user', 'Use `clerkClient.users.getUser` instead.'); + } + + if (signedIn.organization) { + deprecatedObjectProperty( + signedIn, + 'organization', + 'Use `clerkClient.organizations.getOrganization` instead.', + ); + } + + if (signedIn.session) { + deprecatedObjectProperty(signedIn, 'session', 'Use `clerkClient.sessions.getSession` instead.'); + } + } + + return signedIn; + }; + }); + +export const parseJwt = (req: RequestLike) => { + const cookieToken = getCookie(req, constants.Cookies.Session); + const headerToken = getHeader(req, 'authorization')?.replace('Bearer ', ''); + return decodeJwt(cookieToken || headerToken || ''); +}; + +export const getAuth = createGetAuth({ + debugLoggerName: 'getAuth()', + noAuthStatusMessage: getAuthAuthHeaderMissing(), +}); diff --git a/packages/nextjs/src/server/getAuth.ts b/packages/nextjs/src/server/getAuth.ts deleted file mode 100644 index 0397268d6da..00000000000 --- a/packages/nextjs/src/server/getAuth.ts +++ /dev/null @@ -1,145 +0,0 @@ -import type { Organization, Session, SignedInAuthObject, SignedOutAuthObject, User } from '@clerk/backend'; -import { - AuthStatus, - constants, - decodeJwt, - makeAuthObjectSerializable, - sanitizeAuthObject, - signedInAuthObject, - signedOutAuthObject, -} from '@clerk/backend'; -import { deprecatedObjectProperty } from '@clerk/shared/deprecated'; -import type { SecretKeyOrApiKey } from '@clerk/types'; - -import { withLogger } from '../utils/debugLogger'; -import { API_KEY, API_URL, API_VERSION, SECRET_KEY } from './clerkClient'; -import { getAuthAuthHeaderMissing } from './errors'; -import type { AuthObjectWithDeprecatedResources, RequestLike } from './types'; -import { getAuthKeyFromRequest, getCookie, getHeader, injectSSRStateIntoObject } from './utils'; - -type GetAuthOpts = Partial; - -export const createGetAuth = ({ - debugLoggerName, - noAuthStatusMessage, -}: { - noAuthStatusMessage: string; - debugLoggerName: string; -}) => - withLogger(debugLoggerName, logger => { - return ( - req: RequestLike, - opts?: GetAuthOpts, - ): - | AuthObjectWithDeprecatedResources - | AuthObjectWithDeprecatedResources => { - const debug = getHeader(req, constants.Headers.EnableDebug) === 'true'; - if (debug) { - logger.enable(); - } - - // When the auth status is set, we trust that the middleware has already run - // Then, we don't have to re-verify the JWT here, - // we can just strip out the claims manually. - const authStatus = getAuthKeyFromRequest(req, 'AuthStatus') as AuthStatus; - const authMessage = getAuthKeyFromRequest(req, 'AuthMessage'); - const authReason = getAuthKeyFromRequest(req, 'AuthReason'); - logger.debug('Headers debug', { authStatus, authMessage, authReason }); - - if (!authStatus) { - throw new Error(noAuthStatusMessage); - } - - const options = { - apiKey: opts?.apiKey || API_KEY, - secretKey: opts?.secretKey || SECRET_KEY, - apiUrl: API_URL, - apiVersion: API_VERSION, - authStatus, - authMessage, - authReason, - }; - logger.debug('Options debug', options); - - if (authStatus !== AuthStatus.SignedIn) { - return signedOutAuthObject(options); - } - - const jwt = parseJwt(req); - logger.debug('JWT debug', jwt.raw.text); - - const signedIn = signedInAuthObject(jwt.payload, { ...options, token: jwt.raw.text }); - - if (signedIn.user) { - deprecatedObjectProperty(signedIn, 'user', 'Use `clerkClient.users.getUser` instead.'); - } - - if (signedIn.organization) { - deprecatedObjectProperty(signedIn, 'organization', 'Use `clerkClient.organizations.getOrganization` instead.'); - } - - if (signedIn.session) { - deprecatedObjectProperty(signedIn, 'session', 'Use `clerkClient.sessions.getSession` instead.'); - } - - return signedIn; - }; - }); - -export const getAuth = createGetAuth({ - debugLoggerName: 'getAuth()', - noAuthStatusMessage: getAuthAuthHeaderMissing(), -}); - -type BuildClerkPropsInitState = { user?: User | null; session?: Session | null; organization?: Organization | null }; - -/** - * To enable Clerk SSR support, include this object to the `props` - * returned from `getServerSideProps`. This will automatically make the auth state available to - * the Clerk components and hooks during SSR, the hydration phase and CSR. - * @example - * import { getAuth } from '@clerk/nextjs/server'; - * - * export const getServerSideProps = ({ req }) => { - * const { authServerSideProps } = getAuth(req); - * const myData = getMyData(); - * - * return { - * props: { myData, authServerSideProps }, - * }; - * }; - */ -type BuildClerkProps = (req: RequestLike, authState?: BuildClerkPropsInitState) => Record; - -export const buildClerkProps: BuildClerkProps = (req, initState = {}) => { - const authStatus = getAuthKeyFromRequest(req, 'AuthStatus') as AuthStatus; - const authMessage = getAuthKeyFromRequest(req, 'AuthMessage'); - const authReason = getAuthKeyFromRequest(req, 'AuthReason'); - - const options = { - apiKey: API_KEY, - secretKey: SECRET_KEY, - apiUrl: API_URL, - apiVersion: API_VERSION, - authStatus, - authMessage, - authReason, - }; - - let authObject; - if (!authStatus || authStatus !== AuthStatus.SignedIn) { - authObject = signedOutAuthObject(options); - } else { - const { payload, raw } = parseJwt(req); - authObject = signedInAuthObject(payload, { ...options, token: raw.text }); - } - - const sanitizedAuthObject = makeAuthObjectSerializable(sanitizeAuthObject({ ...authObject, ...initState })); - return injectSSRStateIntoObject({}, sanitizedAuthObject); -}; - -const parseJwt = (req: RequestLike) => { - const cookieToken = getCookie(req, constants.Cookies.Session); - const headerToken = getHeader(req, 'authorization')?.replace('Bearer ', ''); - return decodeJwt(cookieToken || headerToken || ''); -}; diff --git a/packages/nextjs/src/server/index.ts b/packages/nextjs/src/server/index.ts index 583593723c2..4cbd5edc69c 100644 --- a/packages/nextjs/src/server/index.ts +++ b/packages/nextjs/src/server/index.ts @@ -1,5 +1,6 @@ export * from './clerkClient'; -export { buildClerkProps, getAuth } from './getAuth'; +export { getAuth } from './createGetAuth'; +export { buildClerkProps } from './buildClerkProps'; export * from './withClerkMiddleware'; export { redirectToSignUp, redirectToSignIn } from './redirect'; diff --git a/packages/nextjs/src/server/utils.ts b/packages/nextjs/src/server/utils.ts index 37080d720e5..f8b389bd801 100644 --- a/packages/nextjs/src/server/utils.ts +++ b/packages/nextjs/src/server/utils.ts @@ -8,10 +8,7 @@ import { NextResponse } from 'next/server'; import { constants as nextConstants } from '../constants'; import { API_KEY, DOMAIN, IS_SATELLITE, PROXY_URL, SECRET_KEY, SIGN_IN_URL } from './clerkClient'; import { missingDomainAndProxy, missingSignInUrlInDev } from './errors'; -import type { NextMiddlewareResult, RequestLike } from './types'; -import type { WithAuthOptions } from './types'; - -type AuthKey = 'AuthStatus' | 'AuthMessage' | 'AuthReason'; +import type { NextMiddlewareResult, RequestLike, WithAuthOptions } from './types'; export function setCustomAttributeOnRequest(req: RequestLike, key: string, value: string): void { Object.assign(req, { [key]: value }); @@ -22,12 +19,21 @@ export function getCustomAttributeFromRequest(req: RequestLike, key: string): st return key in req ? req[key] : undefined; } -export function getAuthKeyFromRequest(req: RequestLike, key: AuthKey): string | null | undefined { - return ( - getCustomAttributeFromRequest(req, constants.Attributes[key]) || - getHeader(req, constants.Headers[key]) || - (key === 'AuthStatus' ? getQueryParam(req, constants.SearchParams.AuthStatus) : undefined) - ); +export function getAuthKeyFromRequest( + req: RequestLike, + key: keyof typeof constants.Attributes, +): string | null | undefined { + const val = getCustomAttributeFromRequest(req, constants.Attributes[key]) || getHeader(req, constants.Headers[key]); + if (val) { + return val; + } + // alternatively, check whether the value exists as a query param + // this is only required for specific nextjs versions that don't support overriding request headers + // and is no longer needed in v5 + if (key === 'AuthStatus' || key === 'AuthToken') { + return getQueryParam(req, key) || undefined; + } + return undefined; } // Tries to extract auth status from the request using several strategies @@ -159,7 +165,7 @@ export function decorateRequest( res: NextMiddlewareResult, requestState: RequestState, ): NextMiddlewareResult { - const { reason, message, status } = requestState; + const { reason, message, status, token } = requestState; // pass-through case, convert to next() if (!res) { res = NextResponse.next(); @@ -198,14 +204,17 @@ export function decorateRequest( // In this case, we won't set them at all in order to avoid having them visible in the req.url setRequestHeadersOnNextResponse(res, req, { [constants.Headers.AuthStatus]: status, + [constants.Headers.AuthToken]: token || '', [constants.Headers.AuthMessage]: message || '', [constants.Headers.AuthReason]: reason || '', }); } else { res.headers.set(constants.Headers.AuthStatus, status); + res.headers.set(constants.Headers.AuthToken, token || ''); res.headers.set(constants.Headers.AuthMessage, message || ''); res.headers.set(constants.Headers.AuthReason, reason || ''); rewriteURL.searchParams.set(constants.SearchParams.AuthStatus, status); + rewriteURL.searchParams.set(constants.SearchParams.AuthToken, token || ''); rewriteURL.searchParams.set(constants.Headers.AuthMessage, message || ''); rewriteURL.searchParams.set(constants.Headers.AuthReason, reason || ''); } diff --git a/packages/nextjs/src/server/withClerkMiddleware.ts b/packages/nextjs/src/server/withClerkMiddleware.ts index 433cdad862f..9ff67438745 100644 --- a/packages/nextjs/src/server/withClerkMiddleware.ts +++ b/packages/nextjs/src/server/withClerkMiddleware.ts @@ -89,6 +89,7 @@ export const withClerkMiddleware: WithClerkMiddleware = (...args: unknown[]) => // Set auth result on request in a private property so that middleware can read it too setCustomAttributeOnRequest(req, constants.Attributes.AuthStatus, requestState.status); + setCustomAttributeOnRequest(req, constants.Attributes.AuthToken, requestState.token || ''); setCustomAttributeOnRequest(req, constants.Attributes.AuthMessage, requestState.message || ''); setCustomAttributeOnRequest(req, constants.Attributes.AuthReason, requestState.reason || ''); diff --git a/packages/remix/src/ssr/authenticateRequest.ts b/packages/remix/src/ssr/authenticateRequest.ts index 713e7c23437..4e7ce624091 100644 --- a/packages/remix/src/ssr/authenticateRequest.ts +++ b/packages/remix/src/ssr/authenticateRequest.ts @@ -94,7 +94,16 @@ export function authenticateRequest(args: LoaderFunctionArgs, opts: RootAuthLoad throw new Error(satelliteAndMissingSignInUrl); } - return Clerk({ apiUrl, apiKey, secretKey, jwtKey, proxyUrl, isSatellite, domain }).authenticateRequest({ + return Clerk({ + apiUrl, + apiKey, + secretKey, + jwtKey, + proxyUrl, + isSatellite, + domain, + userAgent: `${PACKAGE_NAME}@${PACKAGE_VERSION}`, + }).authenticateRequest({ apiKey, audience, secretKey, diff --git a/packages/remix/src/ssr/utils.ts b/packages/remix/src/ssr/utils.ts index e944548a505..11cf214467d 100644 --- a/packages/remix/src/ssr/utils.ts +++ b/packages/remix/src/ssr/utils.ts @@ -109,19 +109,15 @@ export const injectRequestStateIntoResponse = async ( requestState: RequestState, context: AppLoadContext, ) => { - // eslint-disable-next-line @typescript-eslint/no-unused-vars const clone = response.clone(); const data = await clone.json(); - const { clerkState, headers } = getResponseClerkState(requestState, context); - // set the correct content-type header in case the user returned a `Response` directly // without setting the header, instead of using the `json()` helper clone.headers.set(constants.Headers.ContentType, constants.ContentTypes.Json); headers.forEach((value, key) => { clone.headers.set(key, value); }); - return json({ ...(data || {}), ...clerkState }, clone); };