Skip to content

Commit

Permalink
feat: add jwks support (#407)
Browse files Browse the repository at this point in the history
  • Loading branch information
hf authored Feb 9, 2024
1 parent b595d94 commit 50e4298
Show file tree
Hide file tree
Showing 14 changed files with 392 additions and 48 deletions.
1 change: 1 addition & 0 deletions migrations/multitenant/0006-add-jwks-column.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ALTER TABLE tenants ADD COLUMN IF NOT EXISTS jwks jsonb DEFAULT NULL;
118 changes: 100 additions & 18 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@
"fs-extra": "^10.0.1",
"fs-xattr": "^0.3.1",
"ioredis": "^5.2.4",
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.2",
"knex": "^2.4.2",
"md5-file": "^5.0.0",
"pg": "^8.10.0",
Expand All @@ -69,7 +69,7 @@
"@types/fs-extra": "^9.0.13",
"@types/jest": "^29.2.1",
"@types/js-yaml": "^4.0.5",
"@types/jsonwebtoken": "^8.5.8",
"@types/jsonwebtoken": "^9.0.5",
"@types/mustache": "^4.2.2",
"@types/node": "^18.14.6",
"@types/pg": "^8.6.4",
Expand Down
145 changes: 132 additions & 13 deletions src/auth/jwt.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,15 @@
import * as crypto from 'crypto'
import jwt from 'jsonwebtoken'

import { getJwtSecret as getJwtSecretForTenant } from '../database/tenant'

Check warning on line 4 in src/auth/jwt.ts

View workflow job for this annotation

GitHub Actions / Test / OS ubuntu-20.04 / Node 20

'getJwtSecretForTenant' is defined but never used
import { getConfig } from '../config'

const { jwtAlgorithm } = getConfig()
const { isMultitenant, jwtSecret, jwtAlgorithm, jwtJWKS } = getConfig()

Check warning on line 7 in src/auth/jwt.ts

View workflow job for this annotation

GitHub Actions / Test / OS ubuntu-20.04 / Node 20

'isMultitenant' is assigned a value but never used

Check warning on line 7 in src/auth/jwt.ts

View workflow job for this annotation

GitHub Actions / Test / OS ubuntu-20.04 / Node 20

'jwtSecret' is assigned a value but never used

Check warning on line 7 in src/auth/jwt.ts

View workflow job for this annotation

GitHub Actions / Test / OS ubuntu-20.04 / Node 20

'jwtJWKS' is assigned a value but never used

const JWT_HMAC_ALGOS: jwt.Algorithm[] = ['HS256', 'HS384', 'HS512']
const JWT_RSA_ALGOS: jwt.Algorithm[] = ['RS256', 'RS384', 'RS512']
const JWT_ECC_ALGOS: jwt.Algorithm[] = ['ES256', 'ES384', 'ES512']
const JWT_ED_ALGOS: jwt.Algorithm[] = ['EdDSA'] as unknown as jwt.Algorithm[] // types for EdDSA not yet updated

interface jwtInterface {
sub?: string
Expand All @@ -20,17 +28,128 @@ export type SignedUploadToken = {
exp: number
}

export function findJWKFromHeader(
header: jwt.JwtHeader,
secret: string,
jwks: { keys: { kid?: string; kty: string }[] } | null
) {
if (!jwks || !jwks.keys) {
return secret
}

if (JWT_HMAC_ALGOS.indexOf(header.alg as jwt.Algorithm) > -1) {
// JWT is using HS, find the proper key

if (!header.kid && header.alg === jwtAlgorithm) {
// jwt is probably signed with the static secret
return secret
}

// find the first key without a kid or with the matching kid and the "oct" type
const jwk = jwks.keys.find(
(key) => (!key.kid || key.kid === header.kid) && key.kty === 'oct' && (key as any).k

Check warning on line 50 in src/auth/jwt.ts

View workflow job for this annotation

GitHub Actions / Test / OS ubuntu-20.04 / Node 20

Unexpected any. Specify a different type
)

if (!jwk) {
// jwt is probably signed with the static secret
return secret
}

return Buffer.from((jwk as any).k, 'base64')

Check warning on line 58 in src/auth/jwt.ts

View workflow job for this annotation

GitHub Actions / Test / OS ubuntu-20.04 / Node 20

Unexpected any. Specify a different type
}

// jwt is using an asymmetric algorithm
let kty = 'RSA'

if (JWT_ECC_ALGOS.indexOf(header.alg as jwt.Algorithm) > -1) {
kty = 'EC'
} else if (JWT_ED_ALGOS.indexOf(header.alg as jwt.Algorithm) > -1) {
kty = 'OKP'
}

// find the first key with a matching kid (or no kid if none is specified in the JWT header) and the correct key type
const jwk = jwks.keys.find((key) => {
return ((!key.kid && !header.kid) || key.kid === header.kid) && key.kty === kty
})

if (!jwk) {
// couldn't find a matching JWK, try to use the secret
return secret
}

return crypto.createPublicKey({
format: 'jwk',
key: jwk,
})
}

function getJWTVerificationKey(
secret: string,
jwks: { keys: { kid?: string; kty: string }[] } | null
): jwt.GetPublicKeyOrSecret {
return (header: jwt.JwtHeader, callback: jwt.SigningKeyCallback) => {
let result: any = null

Check warning on line 91 in src/auth/jwt.ts

View workflow job for this annotation

GitHub Actions / Test / OS ubuntu-20.04 / Node 20

Unexpected any. Specify a different type

try {
result = findJWKFromHeader(header, secret, jwks)
} catch (e: any) {

Check warning on line 95 in src/auth/jwt.ts

View workflow job for this annotation

GitHub Actions / Test / OS ubuntu-20.04 / Node 20

Unexpected any. Specify a different type
callback(e)
return
}

callback(null, result)
}
}

export function getJWTAlgorithms(
secret: string,
jwks: { keys: { kid?: string; kty: string }[] } | null
) {
let algorithms: jwt.Algorithm[]

if (jwks && jwks.keys && jwks.keys.length) {
const hasRSA = jwks.keys.find((key) => key.kty === 'RSA')
const hasECC = jwks.keys.find((key) => key.kty === 'EC')
const hasED = jwks.keys.find(
(key) => key.kty === 'OKP' && ((key as any).crv === 'Ed25519' || (key as any).crv === 'Ed448')
)
const hasHS = jwks.keys.find((key) => key.kty === 'oct' && (key as any).k)

algorithms = [
jwtAlgorithm as jwt.Algorithm,
...(hasRSA ? JWT_RSA_ALGOS : []),
...(hasECC ? JWT_ECC_ALGOS : []),
...(hasED ? JWT_ED_ALGOS : []),
...(hasHS ? JWT_HMAC_ALGOS : []),
]
} else {
algorithms = [jwtAlgorithm as jwt.Algorithm]
}

return algorithms
}

/**
* Verifies if a JWT is valid
* @param token
* @param secret
* @param jwks
*/
export function verifyJWT<T>(token: string, secret: string): Promise<jwt.JwtPayload & T> {
export function verifyJWT<T>(
token: string,
secret: string,
jwks?: { keys: { kid?: string; kty: string }[] } | null
): Promise<jwt.JwtPayload & T> {
return new Promise((resolve, reject) => {
jwt.verify(token, secret, { algorithms: [jwtAlgorithm as jwt.Algorithm] }, (err, decoded) => {
if (err) return reject(err)
resolve(decoded as jwt.JwtPayload & T)
})
jwt.verify(
token,
getJWTVerificationKey(secret, jwks || null),
{ algorithms: getJWTAlgorithms(secret, jwks || null) },
(err, decoded) => {
if (err) return reject(err)
resolve(decoded as jwt.JwtPayload & T)
}
)
})
}

Expand Down Expand Up @@ -62,13 +181,13 @@ export function signJWT(
* Extract the owner (user) from the provided JWT
* @param token
* @param secret
* @param jwks
*/
export async function getOwner(token: string, secret: string): Promise<string | undefined> {
const decodedJWT = await verifyJWT(token, secret)
export async function getOwner(
token: string,
secret: string,
jwks: { keys: { kid?: string; kty: string }[] } | null
): Promise<string | undefined> {
const decodedJWT = await verifyJWT(token, secret, jwks)
return (decodedJWT as jwtInterface)?.sub
}

export async function getRole(token: string, secret: string): Promise<string | undefined> {
const decodedJWT = await verifyJWT(token, secret)
return (decodedJWT as jwtInterface)?.role
}
Loading

0 comments on commit 50e4298

Please sign in to comment.