-
Notifications
You must be signed in to change notification settings - Fork 402
loadCryptoKeyFunction, jwksClient on clerk-sdk-node addition and fixes #2
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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>; | ||
|
|
||
|
|
@@ -29,6 +30,7 @@ type AuthState = { | |
| status: AuthStatus; | ||
| session?: Session; | ||
| interstitial?: string; | ||
| sessionClaims?: JWTPayload; | ||
| }; | ||
|
|
||
| type AuthStateParams = { | ||
|
|
@@ -58,20 +60,25 @@ export class Base { | |
| importKeyFunction: ImportKeyFunction; | ||
| verifySignatureFunction: VerifySignatureFunction; | ||
| decodeBase64Function: DecodeBase64Function; | ||
| loadCryptoKeyFunction?: LoadCryptoKeyFunction; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ❓ What's the difference between the
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Their docs: @param {ImportKeyFunction} importKeyFunction Function to import a PEM. Should have a similar result to crypto.subtle.importKey
@param {LoadCryptoKeyFunction} loadCryptoKeyFunction Function load a PK CryptoKey from the host environment. Used for JWK clients etc.Their difference is not so easily discernable for readers not familiar with the crypto operations we need to use for our jwt verification.
|
||
|
|
||
| /** | ||
| * 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; | ||
| } | ||
|
|
||
| /** | ||
|
|
@@ -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'); | ||
|
|
@@ -214,6 +224,7 @@ export class Base { | |
| id: sessionClaims.sid as string, | ||
| userId: sessionClaims.sub as string, | ||
| }, | ||
| sessionClaims, | ||
| }; | ||
| } | ||
|
|
||
|
|
@@ -267,6 +278,7 @@ export class Base { | |
| id: sessionClaims.sid as string, | ||
| userId: sessionClaims.sub as string, | ||
| }, | ||
| sessionClaims, | ||
| }; | ||
| } | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,5 +1,5 @@ | ||
| { | ||
| "version": "2.6.0", | ||
| "version": "2.6.2", | ||
| "license": "MIT", | ||
| "main": "dist/index.js", | ||
| "module": "esm/index.js", | ||
|
|
@@ -57,6 +57,7 @@ | |
| "@peculiar/webcrypto": "^1.2.3", | ||
| "camelcase-keys": "^6.2.2", | ||
| "cookies": "^0.8.0", | ||
| "deepmerge": "^4.2.2", | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This was introduced as a fix for merging supplied |
||
| "got": "^11.8.2", | ||
| "jsonwebtoken": "^8.5.1", | ||
| "jwks-rsa": "^2.0.4", | ||
|
|
@@ -80,4 +81,4 @@ | |
| "publishConfig": { | ||
| "access": "public" | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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'; | ||
|
|
@@ -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 = { | ||
|
|
@@ -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; | ||
|
|
@@ -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: { | ||
|
|
@@ -92,7 +88,9 @@ export default class Clerk extends ClerkBackendAPI { | |
| }, | ||
| // @ts-ignore | ||
| ...(body && { body: querystring.stringify(body) }), | ||
| }); | ||
| }) as OptionsOfJSONResponseBody; | ||
|
|
||
| return got(url, finalHTTPOptions); | ||
| }; | ||
|
|
||
| super({ | ||
|
|
@@ -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) | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ❓ 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 | ||
|
|
@@ -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(); | ||
|
|
@@ -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(); | ||
| } | ||
|
|
||
|
|
@@ -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( | ||
|
|
@@ -282,4 +310,8 @@ export default class Clerk extends ClerkBackendAPI { | |
| ) { | ||
| return this.withSession(handler, { onError }); | ||
| } | ||
|
|
||
| set httpOptions(value: OptionsOfJSONResponseBody) { | ||
| this.httpOptions = value; | ||
| } | ||
| } | ||
There was a problem hiding this comment.
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