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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
Copy Markdown
Contributor

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 DownExpand Up@@ -58,20 +60,25 @@ export class Base {
importKeyFunction: ImportKeyFunction;
verifySignatureFunction: VerifySignatureFunction;
decodeBase64Function: DecodeBase64Function;
loadCryptoKeyFunction?: LoadCryptoKeyFunction;

Copy link
Copy Markdown
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
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Their docs:

 @param{ImportKeyFunction}importKeyFunctionFunctiontoimportaPEM.Shouldhaveasimilarresulttocrypto.subtle.importKey
@param{LoadCryptoKeyFunction}loadCryptoKeyFunctionFunctionloadaPKCryptoKeyfromthehostenvironment.UsedforJWKclientsetc.

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 DownExpand Up@@ -214,6 +224,7 @@ export class Base {
id: sessionClaims.sid as string,
userId: sessionClaims.sub as string,
},
sessionClaims,
};
}

Expand DownExpand 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 numberDiff line numberDiff 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 DownExpand 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
Copy Markdown
ContributorAuthor

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 numberDiff line numberDiff 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 DownExpand 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
Copy Markdown
Contributor

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 DownExpand 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 DownExpand 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 DownExpand Up@@ -282,4 +310,8 @@ export default class Clerk extends ClerkBackendAPI {
) {
return this.withSession(handler, { onError });
}

set httpOptions(value: OptionsOfJSONResponseBody) {
this.httpOptions = value;
}
}
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
Copy Markdown
Contributor

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 DownExpand Up@@ -58,20 +60,25 @@ export class Base {
importKeyFunction: ImportKeyFunction;
verifySignatureFunction: VerifySignatureFunction;
decodeBase64Function: DecodeBase64Function;
loadCryptoKeyFunction?: LoadCryptoKeyFunction;

Copy link
Copy Markdown
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
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Their docs:

 @param{ImportKeyFunction}importKeyFunctionFunctiontoimportaPEM.Shouldhaveasimilarresulttocrypto.subtle.importKey
@param{LoadCryptoKeyFunction}loadCryptoKeyFunctionFunctionloadaPKCryptoKeyfromthehostenvironment.UsedforJWKclientsetc.

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 DownExpand Up@@ -214,6 +224,7 @@ export class Base {
id: sessionClaims.sid as string,
userId: sessionClaims.sub as string,
},
sessionClaims,
};
}

Expand DownExpand 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 numberDiff line numberDiff 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 DownExpand 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
Copy Markdown
ContributorAuthor

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 numberDiff line numberDiff 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 DownExpand 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
Copy Markdown
Contributor

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 DownExpand 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 DownExpand 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 DownExpand Up@@ -282,4 +310,8 @@ export default class Clerk extends ClerkBackendAPI {
) {
return this.withSession(handler, { onError });
}

set httpOptions(value: OptionsOfJSONResponseBody) {
this.httpOptions = value;
}
}
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
Copy Markdown
Contributor

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 DownExpand Up@@ -58,20 +60,25 @@ export class Base {
importKeyFunction: ImportKeyFunction;
verifySignatureFunction: VerifySignatureFunction;
decodeBase64Function: DecodeBase64Function;
loadCryptoKeyFunction?: LoadCryptoKeyFunction;

Copy link
Copy Markdown
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
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Their docs:

 @param{ImportKeyFunction}importKeyFunctionFunctiontoimportaPEM.Shouldhaveasimilarresulttocrypto.subtle.importKey
@param{LoadCryptoKeyFunction}loadCryptoKeyFunctionFunctionloadaPKCryptoKeyfromthehostenvironment.UsedforJWKclientsetc.

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 DownExpand Up@@ -214,6 +224,7 @@ export class Base {
id: sessionClaims.sid as string,
userId: sessionClaims.sub as string,
},
sessionClaims,
};
}

Expand DownExpand 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 numberDiff line numberDiff 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 DownExpand 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
Copy Markdown
ContributorAuthor

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 numberDiff line numberDiff 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 DownExpand 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
Copy Markdown
Contributor

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 DownExpand 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 DownExpand 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 DownExpand Up@@ -282,4 +310,8 @@ export default class Clerk extends ClerkBackendAPI {
) {
return this.withSession(handler, { onError });
}

set httpOptions(value: OptionsOfJSONResponseBody) {
this.httpOptions = value;
}
}
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
Copy Markdown
Contributor

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 DownExpand Up@@ -58,20 +60,25 @@ export class Base {
importKeyFunction: ImportKeyFunction;
verifySignatureFunction: VerifySignatureFunction;
decodeBase64Function: DecodeBase64Function;
loadCryptoKeyFunction?: LoadCryptoKeyFunction;

Copy link
Copy Markdown
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
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Their docs:

 @param{ImportKeyFunction}importKeyFunctionFunctiontoimportaPEM.Shouldhaveasimilarresulttocrypto.subtle.importKey
@param{LoadCryptoKeyFunction}loadCryptoKeyFunctionFunctionloadaPKCryptoKeyfromthehostenvironment.UsedforJWKclientsetc.

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 DownExpand Up@@ -214,6 +224,7 @@ export class Base {
id: sessionClaims.sid as string,
userId: sessionClaims.sub as string,
},
sessionClaims,
};
}

Expand DownExpand 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 numberDiff line numberDiff 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 DownExpand 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
Copy Markdown
ContributorAuthor

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 numberDiff line numberDiff 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 DownExpand 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
Copy Markdown
Contributor

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 DownExpand 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 DownExpand 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 DownExpand Up@@ -282,4 +310,8 @@ export default class Clerk extends ClerkBackendAPI {
) {
return this.withSession(handler, { onError });
}

set httpOptions(value: OptionsOfJSONResponseBody) {
this.httpOptions = value;
}
}
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
Copy Markdown
Contributor

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 DownExpand Up@@ -58,20 +60,25 @@ export class Base {
importKeyFunction: ImportKeyFunction;
verifySignatureFunction: VerifySignatureFunction;
decodeBase64Function: DecodeBase64Function;
loadCryptoKeyFunction?: LoadCryptoKeyFunction;

Copy link
Copy Markdown
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
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Their docs:

 @param{ImportKeyFunction}importKeyFunctionFunctiontoimportaPEM.Shouldhaveasimilarresulttocrypto.subtle.importKey
@param{LoadCryptoKeyFunction}loadCryptoKeyFunctionFunctionloadaPKCryptoKeyfromthehostenvironment.UsedforJWKclientsetc.

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 DownExpand Up@@ -214,6 +224,7 @@ export class Base {
id: sessionClaims.sid as string,
userId: sessionClaims.sub as string,
},
sessionClaims,
};
}

Expand DownExpand 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 numberDiff line numberDiff 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 DownExpand 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
Copy Markdown
ContributorAuthor

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 numberDiff line numberDiff 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 DownExpand 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
Copy Markdown
Contributor

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 DownExpand 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 DownExpand 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 DownExpand Up@@ -282,4 +310,8 @@ export default class Clerk extends ClerkBackendAPI {
) {
return this.withSession(handler, { onError });
}

set httpOptions(value: OptionsOfJSONResponseBody) {
this.httpOptions = value;
}
}
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
Copy Markdown
Contributor

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 DownExpand Up@@ -58,20 +60,25 @@ export class Base {
importKeyFunction: ImportKeyFunction;
verifySignatureFunction: VerifySignatureFunction;
decodeBase64Function: DecodeBase64Function;
loadCryptoKeyFunction?: LoadCryptoKeyFunction;

Copy link
Copy Markdown
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
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Their docs:

 @param{ImportKeyFunction}importKeyFunctionFunctiontoimportaPEM.Shouldhaveasimilarresulttocrypto.subtle.importKey
@param{LoadCryptoKeyFunction}loadCryptoKeyFunctionFunctionloadaPKCryptoKeyfromthehostenvironment.UsedforJWKclientsetc.

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 DownExpand Up@@ -214,6 +224,7 @@ export class Base {
id: sessionClaims.sid as string,
userId: sessionClaims.sub as string,
},
sessionClaims,
};
}

Expand DownExpand 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 numberDiff line numberDiff 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 DownExpand 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
Copy Markdown
ContributorAuthor

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 numberDiff line numberDiff 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 DownExpand 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
Copy Markdown
Contributor

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 DownExpand 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 DownExpand 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 DownExpand Up@@ -282,4 +310,8 @@ export default class Clerk extends ClerkBackendAPI {
) {
return this.withSession(handler, { onError });
}

set httpOptions(value: OptionsOfJSONResponseBody) {
this.httpOptions = value;
}
}
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
Copy Markdown
Contributor

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 DownExpand Up@@ -58,20 +60,25 @@ export class Base {
importKeyFunction: ImportKeyFunction;
verifySignatureFunction: VerifySignatureFunction;
decodeBase64Function: DecodeBase64Function;
loadCryptoKeyFunction?: LoadCryptoKeyFunction;

Copy link
Copy Markdown
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
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Their docs:

 @param{ImportKeyFunction}importKeyFunctionFunctiontoimportaPEM.Shouldhaveasimilarresulttocrypto.subtle.importKey
@param{LoadCryptoKeyFunction}loadCryptoKeyFunctionFunctionloadaPKCryptoKeyfromthehostenvironment.UsedforJWKclientsetc.

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 DownExpand Up@@ -214,6 +224,7 @@ export class Base {
id: sessionClaims.sid as string,
userId: sessionClaims.sub as string,
},
sessionClaims,
};
}

Expand DownExpand 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 numberDiff line numberDiff 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 DownExpand 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
Copy Markdown
ContributorAuthor

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 numberDiff line numberDiff 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 DownExpand 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
Copy Markdown
Contributor

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 DownExpand 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 DownExpand 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 DownExpand Up@@ -282,4 +310,8 @@ export default class Clerk extends ClerkBackendAPI {
) {
return this.withSession(handler, { onError });
}

set httpOptions(value: OptionsOfJSONResponseBody) {
this.httpOptions = value;
}
}
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
Copy Markdown
Contributor

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 DownExpand Up@@ -58,20 +60,25 @@ export class Base {
importKeyFunction: ImportKeyFunction;
verifySignatureFunction: VerifySignatureFunction;
decodeBase64Function: DecodeBase64Function;
loadCryptoKeyFunction?: LoadCryptoKeyFunction;

Copy link
Copy Markdown
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
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Their docs:

 @param{ImportKeyFunction}importKeyFunctionFunctiontoimportaPEM.Shouldhaveasimilarresulttocrypto.subtle.importKey
@param{LoadCryptoKeyFunction}loadCryptoKeyFunctionFunctionloadaPKCryptoKeyfromthehostenvironment.UsedforJWKclientsetc.

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 DownExpand Up@@ -214,6 +224,7 @@ export class Base {
id: sessionClaims.sid as string,
userId: sessionClaims.sub as string,
},
sessionClaims,
};
}

Expand DownExpand 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 numberDiff line numberDiff 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 DownExpand 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
Copy Markdown
ContributorAuthor

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 numberDiff line numberDiff 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 DownExpand 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
Copy Markdown
Contributor

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 DownExpand 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 DownExpand 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 DownExpand Up@@ -282,4 +310,8 @@ export default class Clerk extends ClerkBackendAPI {
) {
return this.withSession(handler, { onError });
}

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