-
-
Notifications
You must be signed in to change notification settings - Fork 27
feat: support for remote JWKS #112
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
base: main
Are you sure you want to change the base?
Changes from 9 commits
71628be
f1bebfe
5c54235
74f44f2
956e3d3
480dfd9
fb9fa87
6ac5421
3df6f48
82333fb
0f69510
38b8d28
53d00d6
c69cae3
7303181
13849fd
a761d46
ceee3dd
3c31c1e
1dff1ec
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 |
---|---|---|
|
@@ -9,11 +9,13 @@ import { | |
import { | ||
SignJWT, | ||
jwtVerify, | ||
decodeProtectedHeader, | ||
type CryptoKey, | ||
type JWK, | ||
type KeyObject, | ||
type JoseHeaderParameters, | ||
type JWTVerifyOptions | ||
type JWTVerifyOptions, | ||
type JWTVerifyGetKey | ||
} from 'jose' | ||
|
||
import { Type as t } from '@sinclair/typebox' | ||
|
@@ -157,54 +159,85 @@ export interface JWTHeaderParameters extends JoseHeaderParameters { | |
crit?: string[] | ||
} | ||
|
||
export interface JWTOption< | ||
type BaseJWTOption<Name extends string | undefined, Schema extends TSchema | undefined> = | ||
JWTHeaderParameters & JWTPayloadInput & { | ||
/** | ||
* Name to decorate method as | ||
* | ||
* --- | ||
* @example | ||
* For example, `jwt` will decorate Context with `Context.jwt` | ||
* | ||
* ```typescript | ||
* app | ||
* .decorate({ | ||
* name: 'myJWTNamespace', | ||
* secret: process.env.JWT_SECRET | ||
* }) | ||
* .get('/sign/:name', ({ myJWTNamespace, params }) => { | ||
* return myJWTNamespace.sign(params) | ||
* }) | ||
* ``` | ||
*/ | ||
name?: Name | ||
/** | ||
* Type strict validation for JWT payload | ||
*/ | ||
schema?: Schema | ||
} | ||
|
||
export type JWTOption< | ||
Name extends string | undefined = 'jwt', | ||
Schema extends TSchema | undefined = undefined | ||
> extends JWTHeaderParameters, | ||
JWTPayloadInput { | ||
/** | ||
* Name to decorate method as | ||
* | ||
* --- | ||
* @example | ||
* For example, `jwt` will decorate Context with `Context.jwt` | ||
* | ||
* ```typescript | ||
* app | ||
* .decorate({ | ||
* name: 'myJWTNamespace', | ||
* secret: process.env.JWT_SECRETS | ||
* }) | ||
* .get('/sign/:name', ({ myJWTNamespace, params }) => { | ||
* return myJWTNamespace.sign(params) | ||
* }) | ||
* ``` | ||
*/ | ||
name?: Name | ||
/** | ||
* JWT Secret | ||
*/ | ||
secret: string | Uint8Array | CryptoKey | JWK | KeyObject | ||
/** | ||
* Type strict validation for JWT payload | ||
*/ | ||
schema?: Schema | ||
} | ||
> = | ||
| (BaseJWTOption<Name, Schema> & { | ||
/** | ||
* JWT Secret | ||
*/ | ||
secret: string | Uint8Array | CryptoKey | JWK | KeyObject | ||
/** | ||
* Remote JWKS | ||
* Use jose's `createRemoteJWKSet(new URL(...))` to create the JWKS function | ||
*/ | ||
remoteJwks?: JWTVerifyGetKey | ||
}) | ||
| (BaseJWTOption<Name, Schema> & { | ||
/** | ||
* JWT Secret | ||
*/ | ||
secret?: string | Uint8Array | CryptoKey | JWK | KeyObject | ||
/** | ||
* Remote JWKS | ||
* Use jose's `createRemoteJWKSet(new URL(...))` to create the JWKS function | ||
*/ | ||
remoteJwks: JWTVerifyGetKey | ||
}) | ||
|
||
const ASYMMETRIC_VERIFICATION_ALGS = [ | ||
'RS256','RS384','RS512', | ||
'PS256','PS384','PS512', | ||
'ES256','ES384','ES512', | ||
'EdDSA' | ||
] as const | ||
|
||
const SYMMETRIC_VERIFICATION_ALGS = ['HS256', 'HS384', 'HS512'] as const | ||
|
||
Comment on lines
+224
to
232
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. 💡 Verification agent 🧩 Analysis chainEnforce symmetric defaults on local HS verification.*
Consider adding tests to assert:
🏁 Script executed: #!/usr/bin/env bash
# Locate the constant definitions
rg -n "const SYMMETRIC_VERIFICATION_ALGS" -C2 .
rg -n "const ASYMMETRIC_VERIFICATION_ALGS" -C2 .
# Search for any references to SYMMETRIC_VERIFICATION_ALGS in verification logic
rg -n "SYMMETRIC_VERIFICATION_ALGS" -C2 src
# Inspect the verify function signature and defaults
rg -nP "\bfunction\s+verify\b" -C3 src/index.ts Length of output: 1435 🏁 Script executed: #!/usr/bin/env bash
# Extract verify implementation from src/index.ts
sed -n '250,350p' src/index.ts
# Search for jwtVerify import and usage
rg -n "import .*jwtVerify" -C2 src/index.ts
rg -n "jwtVerify" -C5 src/index.ts
# Search for local (secret-based) verification usage (e.g., jws or jwt.verify)
rg -nP "(jws|jwt)\s*\.verify" -C5 src/index.ts Length of output: 4072 Enforce symmetric defaults for local HS verification*
|
||
export const jwt = < | ||
const Name extends string = 'jwt', | ||
const Schema extends TSchema | undefined = undefined | ||
>({ | ||
name = 'jwt' as Name, | ||
secret, | ||
remoteJwks, | ||
schema, | ||
...defaultValues | ||
}: // End JWT Payload | ||
JWTOption<Name, Schema>) => { | ||
if (!secret) throw new Error("Secret can't be empty") | ||
if (!secret && !remoteJwks) throw new Error('Either "secret" or "remoteJwks" must be provided') | ||
|
||
const key = | ||
typeof secret === 'string' ? new TextEncoder().encode(secret) : secret | ||
const key = secret | ||
? (typeof secret === 'string' ? new TextEncoder().encode(secret) : secret) | ||
: undefined | ||
|
||
jmlow marked this conversation as resolved.
Show resolved
Hide resolved
|
||
const validator = schema | ||
? getSchemaValidator( | ||
|
@@ -228,19 +261,62 @@ JWTOption<Name, Schema>) => { | |
) | ||
: undefined | ||
|
||
return new Elysia({ | ||
name: '@elysiajs/jwt', | ||
seed: { | ||
name, | ||
secret, | ||
schema, | ||
...defaultValues | ||
let jwtDecoration: { | ||
verify: (jwt?: string, options?: JWTVerifyOptions) => | ||
Promise< | ||
| (UnwrapSchema<Schema, ClaimType> & Omit<JWTPayloadSpec, keyof UnwrapSchema<Schema, {}>>) | ||
| false | ||
> | ||
sign?: ( | ||
signValue: Omit<UnwrapSchema<Schema, ClaimType>, NormalizedClaim> & JWTPayloadInput | ||
) => Promise<string> | ||
} = { | ||
verify: async ( | ||
jwt?: string, | ||
options?: JWTVerifyOptions | ||
): Promise< | ||
| (UnwrapSchema<Schema, ClaimType> & | ||
Omit<JWTPayloadSpec, keyof UnwrapSchema<Schema, {}>>) | ||
| false | ||
> => { | ||
if (!jwt) return false | ||
|
||
try { | ||
const { alg } = decodeProtectedHeader(jwt) | ||
const isSymmetric = typeof alg === 'string' && alg.startsWith('HS') | ||
const remoteOnly = remoteJwks && !key | ||
if (isSymmetric && remoteOnly) throw new Error('HS* algorithm requires a local secret') | ||
// Prefer local secret for HS*; prefer remote for asymmetric algs when available | ||
let payload | ||
if (remoteJwks && !isSymmetric) { | ||
const remoteVerifyOptions: JWTVerifyOptions = !options | ||
? { algorithms: [...ASYMMETRIC_VERIFICATION_ALGS] } | ||
: (!options.algorithms | ||
? { ...options, algorithms: [...ASYMMETRIC_VERIFICATION_ALGS] } | ||
: options) | ||
payload = (await jwtVerify(jwt, remoteJwks!, remoteVerifyOptions) | ||
).payload | ||
} else { | ||
payload = (await jwtVerify(jwt, (key as Exclude<typeof key, undefined>), options)).payload | ||
} | ||
const data = payload as UnwrapSchema<Schema, ClaimType> & | ||
Omit<JWTPayloadSpec, keyof UnwrapSchema<Schema, {}>> | ||
|
||
if (validator && !validator.Check(data)) | ||
throw new ValidationError('JWT', validator, data) | ||
|
||
return data | ||
} catch (_) { | ||
return false | ||
} | ||
} | ||
}).decorate(name as Name extends string ? Name : 'jwt', { | ||
sign( | ||
} | ||
|
||
if (secret) { | ||
jwtDecoration.sign = ( | ||
signValue: Omit<UnwrapSchema<Schema, ClaimType>, NormalizedClaim> & | ||
JWTPayloadInput | ||
) { | ||
) => { | ||
jmlow marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
const { nbf, exp, iat, ...data } = signValue | ||
jmlow marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
|
||
/** | ||
|
@@ -359,34 +435,20 @@ JWTOption<Name, Schema>) => { | |
jwt = jwt.setIssuedAt(new Date()) | ||
} | ||
|
||
return jwt.sign(key) | ||
}, | ||
async verify( | ||
jwt?: string, | ||
options?: JWTVerifyOptions | ||
): Promise< | ||
| (UnwrapSchema<Schema, ClaimType> & | ||
Omit<JWTPayloadSpec, keyof UnwrapSchema<Schema, {}>>) | ||
| false | ||
> { | ||
if (!jwt) return false | ||
|
||
try { | ||
const data: any = ( | ||
await (options | ||
? jwtVerify(jwt, key, options) | ||
: jwtVerify(jwt, key)) | ||
).payload | ||
|
||
if (validator && !validator.Check(data)) | ||
throw new ValidationError('JWT', validator, data) | ||
return jwt.sign((key as Exclude<typeof key, undefined>) ) | ||
} | ||
} | ||
|
||
return data | ||
} catch (_) { | ||
return false | ||
} | ||
return new Elysia({ | ||
name: '@elysiajs/jwt', | ||
seed: { | ||
name, | ||
secret, | ||
remoteJwks, | ||
schema, | ||
...defaultValues | ||
} | ||
}) | ||
}).decorate(name as Name extends string ? Name : 'jwt', jwtDecoration) | ||
jmlow marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
} | ||
|
||
export default jwt |
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.
This is unused. Should be removed