Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ module.exports = {
'@typescript-eslint/no-unsafe-assignment': 'warn',
'simple-import-sort/imports': 'error',
'@typescript-eslint/no-unsafe-call': 'off',
'@typescript-eslint/no-unsafe-member-access': 'off'
}
'@typescript-eslint/no-unsafe-member-access': 'off',
'@typescript-eslint/no-unsafe-return': 'warn',
},
};
30 changes: 21 additions & 9 deletions packages/backend-core/src/Base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export const API_KEY = process.env.CLERK_API_KEY || '';
type ImportKeyFunction = (
...args: any[]
) => Promise<CryptoKey | PeculiarCryptoKey>;
type LoadCryptoKeyFunction = (token: string) => Promise<CryptoKey>;
type DecodeBase64Function = (base64Encoded: string) => string;
type VerifySignatureFunction = (...args: any[]) => Promise<boolean>;

Expand All @@ -29,6 +30,7 @@ type AuthState = {
status: AuthStatus;
session?: Session;
interstitial?: string;
sessionClaims?: JWTPayload;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔧 I will suggest to create a custom type for this, which will include the session ID and user ID for now and not depend on the JWT payload

};

type AuthStateParams = {
Expand Down Expand Up @@ -58,20 +60,25 @@ export class Base {
importKeyFunction: ImportKeyFunction;
verifySignatureFunction: VerifySignatureFunction;
decodeBase64Function: DecodeBase64Function;
loadCryptoKeyFunction?: LoadCryptoKeyFunction;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❓ What's the difference between the loadCryptoKeyFunction and the importKeyFunction?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Their docs:

   @param {ImportKeyFunction} importKeyFunction Function to import a PEM. Should have a similar result to crypto.subtle.importKey
   @param {LoadCryptoKeyFunction} loadCryptoKeyFunction Function load a PK CryptoKey from the host environment. Used for JWK clients etc.

Their difference is not so easily discernable for readers not familiar with the crypto operations we need to use for our jwt verification.

import is a reserved term coined as input a key in an external, portable format and take back a CryptoKey.

load does not have any special terminology like that and would allow injecting any kind of process that the client (of @clerk/backend-core) needs to do to provide a CryptoKey from his PK.


/**
* Creates an instance of a Clerk Base.
* @param {ImportKeyFunction} importKeyFunction Function to import a PEM. Should have a similar result to crypto.subtle.importKey
* @param {LoadCryptoKeyFunction} loadCryptoKeyFunction Function load a PK CryptoKey from the host environment. Used for JWK clients etc.
* @param {VerifySignatureFunction} verifySignatureFunction Function to verify a CryptoKey or a similar structure later on. Should have a similar result to crypto.subtle.verify
* @param {DecodeBase64Function} decodeBase64Function Function to decode a Base64 string. Similar to atob
*/
constructor(
importKeyFunction: ImportKeyFunction,
verifySignatureFunction: VerifySignatureFunction,
decodeBase64Function: DecodeBase64Function
decodeBase64Function: DecodeBase64Function,
loadCryptoKeyFunction?: LoadCryptoKeyFunction
) {
this.importKeyFunction = importKeyFunction;
this.verifySignatureFunction = verifySignatureFunction;
this.decodeBase64Function = decodeBase64Function;
this.loadCryptoKeyFunction = loadCryptoKeyFunction;
}

/**
Expand All @@ -81,26 +88,29 @@ export class Base {
* The public key will be supplied in the form of CryptoKey or will be loaded from the CLERK_JWT_KEY environment variable.
*
* @param {string} token
* @param {CryptoKey | null} [key]
* @return {Promise<JWTPayload>} claims
*/
verifySessionToken = async (
token: string,
key?: CryptoKey | null
): Promise<JWTPayload> => {
const availableKey = key || (await this.loadPublicKey());
verifySessionToken = async (token: string): Promise<JWTPayload> => {
// Try to load the PK from supplied function and
// if there is no custom load function
// try to load from the environment.
const availableKey = this.loadCryptoKeyFunction
? await this.loadCryptoKeyFunction(token)
: await this.loadCryptoKeyFromEnv();

const claims = await this.verifyJwt(availableKey, token);
checkClaims(claims);
return claims;
};

/**
*
* Construct the RSA public key from the PEM retrieved from the CLERK_JWT_KEY environment variable.
* Modify the RSA public key from the PEM retrieved from the CLERK_JWT_KEY environment variable
* and return a contructed CryptoKey.
* You will find that at your application dashboard (https://dashboard.clerk.dev) under Settings -> API keys
*
*/
loadPublicKey = async (): Promise<CryptoKey> => {
loadCryptoKeyFromEnv = async (): Promise<CryptoKey> => {
const key = process.env.CLERK_JWT_KEY;
if (!key) {
throw new Error('Missing jwt key');
Expand Down Expand Up @@ -214,6 +224,7 @@ export class Base {
id: sessionClaims.sid as string,
userId: sessionClaims.sub as string,
},
sessionClaims,
};
}

Expand Down Expand Up @@ -267,6 +278,7 @@ export class Base {
id: sessionClaims.sid as string,
userId: sessionClaims.sub as string,
},
sessionClaims,
};
}

Expand Down
5 changes: 3 additions & 2 deletions packages/sdk-node/package.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"version": "2.6.0",
"version": "2.6.2",
"license": "MIT",
"main": "dist/index.js",
"module": "esm/index.js",
Expand Down Expand Up @@ -57,6 +57,7 @@
"@peculiar/webcrypto": "^1.2.3",
"camelcase-keys": "^6.2.2",
"cookies": "^0.8.0",
"deepmerge": "^4.2.2",
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was introduced as a fix for merging supplied httpOptions.

"got": "^11.8.2",
"jsonwebtoken": "^8.5.1",
"jwks-rsa": "^2.0.4",
Expand All @@ -80,4 +81,4 @@
"publishConfig": {
"access": "public"
}
}
}
114 changes: 73 additions & 41 deletions packages/sdk-node/src/Clerk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,11 @@
Session,
} from '@clerk/backend-core';
import Cookies from 'cookies';
import deepmerge from 'deepmerge';
import type { NextFunction, Request, Response } from 'express';
import got from 'got';
import got, { OptionsOfJSONResponseBody } from 'got';
import jwt, { JwtPayload } from 'jsonwebtoken';
import jwks, { JwksClient } from 'jwks-rsa';
import jwks from 'jwks-rsa';
import querystring from 'querystring';

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

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

/** Base initialization */

const nodeBase = new Base(importKey, verifySignature, decodeBase64);

export default class Clerk extends ClerkBackendAPI {
// private _restClient: RestClient;
private _jwksClient: JwksClient;
base: Base;

// singleton instance
static _instance: Clerk;
Expand All @@ -70,19 +66,19 @@ export default class Clerk extends ClerkBackendAPI {
serverApiUrl = defaultServerApiUrl,
apiVersion = defaultApiVersion,
httpOptions = {},
jwksCacheMaxAge = defaultJWKSCacheMaxAge,
jwksCacheMaxAge = JWKS_MAX_AGE,
}: {
apiKey?: string;
serverApiUrl?: string;
apiVersion?: string;
httpOptions?: object;
httpOptions?: OptionsOfJSONResponseBody;
jwksCacheMaxAge?: number;
} = {}) {
const fetcher: ClerkFetcher = (
url,
{ method, authorization, contentType, userAgent, body }
) => {
return got(url, {
const finalHTTPOptions = deepmerge(httpOptions, {
method,
responseType: 'json',
headers: {
Expand All @@ -92,7 +88,9 @@ export default class Clerk extends ClerkBackendAPI {
},
// @ts-ignore
...(body && { body: querystring.stringify(body) }),
});
}) as OptionsOfJSONResponseBody;

return got(url, finalHTTPOptions);
};

super({
Expand All @@ -109,21 +107,48 @@ export default class Clerk extends ClerkBackendAPI {
throw Error(SupportMessages.API_KEY_NOT_FOUND);
}

// TBD: Add jwk client as an argument to getAuthState ?
// this._jwksClient = jwks({
// jwksUri: `${serverApiUrl}/${apiVersion}/jwks`,
// requestHeaders: {
// Authorization: `Bearer ${apiKey}`,
// },
// timeout: 5000,
// cache: true,
// cacheMaxAge: jwksCacheMaxAge,
// });

// const key = await this._jwksClient.getSigningKey(decoded.header.kid);
// const verified = jwt.verify(token, key.getPublicKey(), {
// algorithms: algorithms as jwt.Algorithm[],
// }) as JwtPayload;
const loadCryptoKey = async (token: string) => {
const decoded = jwt.decode(token, { complete: true });
if (!decoded) {
throw new Error(`Failed to decode token: ${token}`);
}

const jwksClient = jwks({
jwksUri: `${serverApiUrl}/${apiVersion}/jwks`,
requestHeaders: {
Authorization: `Bearer ${defaultApiKey}`,
},
timeout: 5000,
cache: true,
cacheMaxAge: jwksCacheMaxAge,
});

const encoder = new TextEncoder();

return await crypto.subtle.importKey(
'raw',
encoder.encode(
(
await jwksClient.getSigningKey(decoded.header.kid)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❓ What will happen if we can't find a key with the provided kid? Will it throw a descriptive error?

).getPublicKey() as string
),
{
name: 'RSASSA-PKCS1-v1_5',
hash: 'SHA-256',
},
true,
['verify']
);
};

/** Base initialization */

this.base = new Base(
importKey,
verifySignature,
decodeBase64,
loadCryptoKey
);
}

// For use as singleton, always returns the same instance
Expand Down Expand Up @@ -173,18 +198,19 @@ export default class Clerk extends ClerkBackendAPI {
const cookies = new Cookies(req, res);

try {
const { status, session, interstitial } = await nodeBase.getAuthState({
cookieToken: cookies.get('__session') as string,
clientUat: cookies.get('__client_uat') as string,
headerToken: req.headers.authorization?.replace('Bearer ', ''),
origin: req.headers.origin,
host: req.headers.host,
forwardedPort: req.headers['x-forwarded-port'] as string,
forwardedHost: req.headers['x-forwarded-host'] as string,
referrer: req.headers.referer,
userAgent: req.headers['user-agent'] as string,
fetchInterstitial: () => this.fetchInterstitial(),
});
const { status, session, interstitial, sessionClaims } =
await this.base.getAuthState({
cookieToken: cookies.get('__session') as string,
clientUat: cookies.get('__client_uat') as string,
headerToken: req.headers.authorization?.replace('Bearer ', ''),
origin: req.headers.origin,
host: req.headers.host,
forwardedPort: req.headers['x-forwarded-port'] as string,
forwardedHost: req.headers['x-forwarded-host'] as string,
referrer: req.headers.referer,
userAgent: req.headers['user-agent'] as string,
fetchInterstitial: () => this.fetchInterstitial(),
});

if (status === AuthStatus.SignedOut) {
return signedOut();
Expand All @@ -193,6 +219,8 @@ export default class Clerk extends ClerkBackendAPI {
if (status === AuthStatus.SignedIn) {
// @ts-ignore
req.session = session;
// @ts-ignore
req.sessionClaims = sessionClaims;
return next();
}

Expand Down Expand Up @@ -251,7 +279,7 @@ export default class Clerk extends ClerkBackendAPI {
return async (
req: WithSessionProp<Request> | WithSessionClaimsProp<Request>,
res: Response,
next: NextFunction
next?: NextFunction
) => {
try {
await this._runMiddleware(
Expand Down Expand Up @@ -282,4 +310,8 @@ export default class Clerk extends ClerkBackendAPI {
) {
return this.withSession(handler, { onError });
}

set httpOptions(value: OptionsOfJSONResponseBody) {
this.httpOptions = value;
}
}
Loading