Skip to content

Commit d1ea736

Browse files
committed
feat: Add injectable loadCryptoKeyFunction
fix: Revisit middleware and instance tests, deepmerge fetcher options
1 parent 529c2f6 commit d1ea736

9 files changed

Lines changed: 126 additions & 148 deletions

File tree

.eslintrc.js

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ module.exports = {
1616
'@typescript-eslint/no-unsafe-assignment': 'warn',
1717
'simple-import-sort/imports': 'error',
1818
'@typescript-eslint/no-unsafe-call': 'off',
19-
'@typescript-eslint/no-unsafe-member-access': 'off'
20-
}
19+
'@typescript-eslint/no-unsafe-member-access': 'off',
20+
'@typescript-eslint/no-unsafe-return': 'warn',
21+
},
2122
};

.pnp.cjs

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/backend-core/src/Base.ts

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export const API_KEY = process.env.CLERK_API_KEY || '';
1111
type ImportKeyFunction = (
1212
...args: any[]
1313
) => Promise<CryptoKey | PeculiarCryptoKey>;
14+
type LoadCryptoKeyFunction = (token: string) => Promise<CryptoKey>;
1415
type DecodeBase64Function = (base64Encoded: string) => string;
1516
type VerifySignatureFunction = (...args: any[]) => Promise<boolean>;
1617

@@ -29,6 +30,7 @@ type AuthState = {
2930
status: AuthStatus;
3031
session?: Session;
3132
interstitial?: string;
33+
sessionClaims?: JWTPayload;
3234
};
3335

3436
type AuthStateParams = {
@@ -58,20 +60,25 @@ export class Base {
5860
importKeyFunction: ImportKeyFunction;
5961
verifySignatureFunction: VerifySignatureFunction;
6062
decodeBase64Function: DecodeBase64Function;
63+
loadCryptoKeyFunction?: LoadCryptoKeyFunction;
64+
6165
/**
6266
* Creates an instance of a Clerk Base.
6367
* @param {ImportKeyFunction} importKeyFunction Function to import a PEM. Should have a similar result to crypto.subtle.importKey
68+
* @param {LoadCryptoKeyFunction} loadCryptoKeyFunction Function load a PK CryptoKey from the host environment. Used for JWK clients etc.
6469
* @param {VerifySignatureFunction} verifySignatureFunction Function to verify a CryptoKey or a similar structure later on. Should have a similar result to crypto.subtle.verify
6570
* @param {DecodeBase64Function} decodeBase64Function Function to decode a Base64 string. Similar to atob
6671
*/
6772
constructor(
6873
importKeyFunction: ImportKeyFunction,
6974
verifySignatureFunction: VerifySignatureFunction,
70-
decodeBase64Function: DecodeBase64Function
75+
decodeBase64Function: DecodeBase64Function,
76+
loadCryptoKeyFunction?: LoadCryptoKeyFunction
7177
) {
7278
this.importKeyFunction = importKeyFunction;
7379
this.verifySignatureFunction = verifySignatureFunction;
7480
this.decodeBase64Function = decodeBase64Function;
81+
this.loadCryptoKeyFunction = loadCryptoKeyFunction;
7582
}
7683

7784
/**
@@ -81,26 +88,29 @@ export class Base {
8188
* The public key will be supplied in the form of CryptoKey or will be loaded from the CLERK_JWT_KEY environment variable.
8289
*
8390
* @param {string} token
84-
* @param {CryptoKey | null} [key]
8591
* @return {Promise<JWTPayload>} claims
8692
*/
87-
verifySessionToken = async (
88-
token: string,
89-
key?: CryptoKey | null
90-
): Promise<JWTPayload> => {
91-
const availableKey = key || (await this.loadPublicKey());
93+
verifySessionToken = async (token: string): Promise<JWTPayload> => {
94+
// Try to load the PK from supplied function and
95+
// if there is no custom load function or the value is
96+
// invalid try to load from the environment.
97+
const availableKey = this.loadCryptoKeyFunction
98+
? await this.loadCryptoKeyFunction(token)
99+
: await this.loadCryptoKeyFromEnv();
100+
92101
const claims = await this.verifyJwt(availableKey, token);
93102
checkClaims(claims);
94103
return claims;
95104
};
96105

97106
/**
98107
*
99-
* Construct the RSA public key from the PEM retrieved from the CLERK_JWT_KEY environment variable.
108+
* Modify the RSA public key from the PEM retrieved from the CLERK_JWT_KEY environment variable
109+
* and return a contructed CryptoKey.
100110
* You will find that at your application dashboard (https://dashboard.clerk.dev) under Settings -> API keys
101111
*
102112
*/
103-
loadPublicKey = async (): Promise<CryptoKey> => {
113+
loadCryptoKeyFromEnv = async (): Promise<CryptoKey> => {
104114
const key = process.env.CLERK_JWT_KEY;
105115
if (!key) {
106116
throw new Error('Missing jwt key');
@@ -214,6 +224,7 @@ export class Base {
214224
id: sessionClaims.sid as string,
215225
userId: sessionClaims.sub as string,
216226
},
227+
sessionClaims,
217228
};
218229
}
219230

@@ -267,6 +278,7 @@ export class Base {
267278
id: sessionClaims.sid as string,
268279
userId: sessionClaims.sub as string,
269280
},
281+
sessionClaims,
270282
};
271283
}
272284

packages/sdk-node/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@
5757
"@peculiar/webcrypto": "^1.2.3",
5858
"camelcase-keys": "^6.2.2",
5959
"cookies": "^0.8.0",
60+
"deepmerge": "^4.2.2",
6061
"got": "^11.8.2",
6162
"jsonwebtoken": "^8.5.1",
6263
"jwks-rsa": "^2.0.4",
@@ -80,4 +81,4 @@
8081
"publishConfig": {
8182
"access": "public"
8283
}
83-
}
84+
}

packages/sdk-node/src/Clerk.ts

Lines changed: 78 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,11 @@
55
Session,
66
} from '@clerk/backend-core';
77
import Cookies from 'cookies';
8+
import deepmerge from 'deepmerge';
89
import type { NextFunction, Request, Response } from 'express';
9-
import got from 'got';
10+
import got, { OptionsOfJSONResponseBody } from 'got';
1011
import jwt, { JwtPayload } from 'jsonwebtoken';
11-
import jwks, { JwksClient } from 'jwks-rsa';
12+
import jwks from 'jwks-rsa';
1213
import querystring from 'querystring';
1314

1415
import { SupportMessages } from './constants/SupportMessages';
@@ -21,7 +22,7 @@ const defaultApiKey = process.env.CLERK_API_KEY || '';
2122
const defaultApiVersion = process.env.CLERK_API_VERSION || 'v1';
2223
const defaultServerApiUrl =
2324
process.env.CLERK_API_URL || 'https://api.clerk.dev';
24-
const defaultJWKSCacheMaxAge = 3600000; // 1 hour
25+
const JWKS_MAX_AGE = 3600000; // 1 hour
2526
const packageRepo = 'https://github.com/clerkinc/clerk-sdk-node';
2627

2728
export type MiddlewareOptions = {
@@ -54,13 +55,8 @@ const verifySignature = async (
5455
return await crypto.subtle.verify(algorithm, key, signature, data);
5556
};
5657

57-
/** Base initialization */
58-
59-
const nodeBase = new Base(importKey, verifySignature, decodeBase64);
60-
6158
export default class Clerk extends ClerkBackendAPI {
62-
// private _restClient: RestClient;
63-
private _jwksClient: JwksClient;
59+
base: Base;
6460

6561
// singleton instance
6662
static _instance: Clerk;
@@ -70,7 +66,7 @@ export default class Clerk extends ClerkBackendAPI {
7066
serverApiUrl = defaultServerApiUrl,
7167
apiVersion = defaultApiVersion,
7268
httpOptions = {},
73-
jwksCacheMaxAge = defaultJWKSCacheMaxAge,
69+
jwksCacheMaxAge = JWKS_MAX_AGE,
7470
}: {
7571
apiKey?: string;
7672
serverApiUrl?: string;
@@ -82,16 +78,22 @@ export default class Clerk extends ClerkBackendAPI {
8278
url,
8379
{ method, authorization, contentType, userAgent, body }
8480
) => {
85-
return got(url, {
86-
method,
87-
responseType: 'json',
88-
headers: {
89-
authorization,
90-
'Content-Type': contentType,
91-
'User-Agent': userAgent,
81+
const finalHTTPOptions = deepmerge(
82+
{
83+
method,
84+
responseType: 'json',
85+
headers: {
86+
authorization,
87+
'Content-Type': contentType,
88+
'User-Agent': userAgent,
89+
},
90+
// @ts-ignore
91+
...(body && { body: querystring.stringify(body) }),
9292
},
93-
...(body && { body: querystring.stringify(body) }),
94-
});
93+
httpOptions
94+
) as OptionsOfJSONResponseBody;
95+
96+
return got(url, finalHTTPOptions);
9597
};
9698

9799
super({
@@ -108,21 +110,48 @@ export default class Clerk extends ClerkBackendAPI {
108110
throw Error(SupportMessages.API_KEY_NOT_FOUND);
109111
}
110112

111-
// TBD: Add jwk client as an argument to getAuthState ?
112-
// this._jwksClient = jwks({
113-
// jwksUri: `${serverApiUrl}/${apiVersion}/jwks`,
114-
// requestHeaders: {
115-
// Authorization: `Bearer ${apiKey}`,
116-
// },
117-
// timeout: 5000,
118-
// cache: true,
119-
// cacheMaxAge: jwksCacheMaxAge,
120-
// });
121-
122-
// const key = await this._jwksClient.getSigningKey(decoded.header.kid);
123-
// const verified = jwt.verify(token, key.getPublicKey(), {
124-
// algorithms: algorithms as jwt.Algorithm[],
125-
// }) as JwtPayload;
113+
const loadCryptoKey = async (token: string) => {
114+
const decoded = jwt.decode(token, { complete: true });
115+
if (!decoded) {
116+
throw new Error(`Failed to decode token: ${token}`);
117+
}
118+
119+
const jwksClient = jwks({
120+
jwksUri: `${serverApiUrl}/${apiVersion}/jwks`,
121+
requestHeaders: {
122+
Authorization: `Bearer ${defaultApiKey}`,
123+
},
124+
timeout: 5000,
125+
cache: true,
126+
cacheMaxAge: jwksCacheMaxAge,
127+
});
128+
129+
const encoder = new TextEncoder();
130+
131+
return await crypto.subtle.importKey(
132+
'raw',
133+
encoder.encode(
134+
(
135+
await jwksClient.getSigningKey(decoded.header.kid)
136+
).getPublicKey() as string
137+
),
138+
{
139+
name: 'RSASSA-PKCS1-v1_5',
140+
hash: 'SHA-256',
141+
},
142+
true,
143+
['verify']
144+
);
145+
};
146+
147+
/** Base initialization */
148+
149+
this.base = new Base(
150+
importKey,
151+
verifySignature,
152+
decodeBase64,
153+
loadCryptoKey
154+
);
126155
}
127156

128157
// For use as singleton, always returns the same instance
@@ -172,18 +201,19 @@ export default class Clerk extends ClerkBackendAPI {
172201
const cookies = new Cookies(req, res);
173202

174203
try {
175-
const { status, session, interstitial } = await nodeBase.getAuthState({
176-
cookieToken: cookies.get('__session') as string,
177-
clientUat: cookies.get('__client_uat') as string,
178-
headerToken: req.headers.authorization?.replace('Bearer ', ''),
179-
origin: req.headers.origin,
180-
host: req.headers.host,
181-
forwardedPort: req.headers['x-forwarded-port'] as string,
182-
forwardedHost: req.headers['x-forwarded-host'] as string,
183-
referrer: req.headers.referer,
184-
userAgent: req.headers['user-agent'] as string,
185-
fetchInterstitial: () => this.fetchInterstitial(),
186-
});
204+
const { status, session, interstitial, sessionClaims } =
205+
await this.base.getAuthState({
206+
cookieToken: cookies.get('__session') as string,
207+
clientUat: cookies.get('__client_uat') as string,
208+
headerToken: req.headers.authorization?.replace('Bearer ', ''),
209+
origin: req.headers.origin,
210+
host: req.headers.host,
211+
forwardedPort: req.headers['x-forwarded-port'] as string,
212+
forwardedHost: req.headers['x-forwarded-host'] as string,
213+
referrer: req.headers.referer,
214+
userAgent: req.headers['user-agent'] as string,
215+
fetchInterstitial: () => this.fetchInterstitial(),
216+
});
187217

188218
if (status === AuthStatus.SignedOut) {
189219
return signedOut();
@@ -192,6 +222,8 @@ export default class Clerk extends ClerkBackendAPI {
192222
if (status === AuthStatus.SignedIn) {
193223
// @ts-ignore
194224
req.session = session;
225+
// @ts-ignore
226+
req.sessionClaims = sessionClaims;
195227
return next();
196228
}
197229

0 commit comments

Comments
 (0)