@atproto/jwk-jose
Version:
`jose` based implementation of @atproto/jwk Key's
256 lines (227 loc) • 6.85 kB
text/typescript
import {
type GenerateKeyPairOptions,
type GenerateKeyPairResult,
type JWK,
type JWTVerifyOptions,
type KeyLike,
SignJWT,
errors,
exportJWK,
generateKeyPair,
importJWK,
importPKCS8,
jwtVerify,
} from 'jose'
import {
Jwk,
JwkError,
JwtCreateError,
JwtHeader,
JwtPayload,
JwtVerifyError,
Key,
SignedJwt,
VerifyOptions,
VerifyResult,
isPrivateJwk,
jwkSchema,
jwtHeaderSchema,
jwtPayloadSchema,
} from '@atproto/jwk'
import { RequiredKey, either } from './util.js'
const { JOSEError } = errors
export {
type GenerateKeyPairOptions,
type GenerateKeyPairResult,
type Jwk,
type JwtHeader,
type JwtPayload,
type KeyLike,
type SignedJwt,
type VerifyOptions,
}
export class JoseKey<J extends Jwk = Jwk> extends Key<J> {
/**
* Some runtimes (e.g. Bun) require an `alg` second argument to be set when
* invoking `importJWK`. In order to be compatible with these runtimes, we
* provide the following method to ensure the `alg` is always set. We also
* take the opportunity to ensure that the `alg` is compatible with this key.
*/
protected async getKeyObj(alg: string) {
if (!this.algorithms.includes(alg)) {
throw new JwkError(`Key cannot be used with algorithm "${alg}"`)
}
try {
return await importJWK(this.jwk as JWK, alg)
} catch (cause) {
throw new JwkError('Failed to import JWK', undefined, { cause })
}
}
async createJwt(header: JwtHeader, payload: JwtPayload): Promise<SignedJwt> {
try {
const { kid } = header
if (kid && kid !== this.kid) {
throw new JwtCreateError(
`Invalid "kid" (${kid}) used to sign with key "${this.kid}"`,
)
}
const { alg } = header
if (!alg) {
throw new JwtCreateError('Missing "alg" in JWT header')
}
const keyObj = await this.getKeyObj(alg)
const jwtBuilder = new SignJWT(payload).setProtectedHeader({
...header,
alg,
kid: this.kid,
})
const signedJwt = await jwtBuilder.sign(keyObj)
return signedJwt as SignedJwt
} catch (cause) {
if (cause instanceof JOSEError) {
throw new JwtCreateError(cause.message, cause.code, { cause })
} else {
throw JwtCreateError.from(cause)
}
}
}
async verifyJwt<C extends string = never>(
token: SignedJwt,
options?: VerifyOptions<C>,
): Promise<VerifyResult<C>> {
try {
const result = await jwtVerify(
token,
async ({ alg }) => this.getKeyObj(alg),
{ ...options, algorithms: this.algorithms } as JWTVerifyOptions,
)
// @NOTE if all tokens are signed exclusively through createJwt(), then
// there should be no need to parse the payload and headers here. But
// since the JWT could have been signed with the same key from somewhere
// else, let's parse it to ensure the integrity (and type safety) of the
// data.
const headerParsed = jwtHeaderSchema.safeParse(result.protectedHeader)
if (!headerParsed.success) {
throw new JwtVerifyError('Invalid JWT header', undefined, {
cause: headerParsed.error,
})
}
const payloadParsed = jwtPayloadSchema.safeParse(result.payload)
if (!payloadParsed.success) {
throw new JwtVerifyError('Invalid JWT payload', undefined, {
cause: payloadParsed.error,
})
}
return {
protectedHeader: headerParsed.data,
// "requiredClaims" enforced by jwtVerify()
payload: payloadParsed.data as RequiredKey<JwtPayload, C>,
}
} catch (cause) {
if (cause instanceof JOSEError) {
throw new JwtVerifyError(cause.message, cause.code, { cause })
} else {
throw JwtVerifyError.from(cause)
}
}
}
static async generateKeyPair(
allowedAlgos: readonly string[] = ['ES256'],
options?: GenerateKeyPairOptions,
) {
if (!allowedAlgos.length) {
throw new JwkError('No algorithms provided for key generation')
}
const errors: unknown[] = []
for (const alg of allowedAlgos) {
try {
return await generateKeyPair(alg, options)
} catch (err) {
errors.push(err)
}
}
throw new JwkError('Failed to generate key pair', undefined, {
cause: new AggregateError(errors, 'None of the algorithms worked'),
})
}
static async generate(
allowedAlgos: string[] = ['ES256'],
kid?: string,
options?: Omit<GenerateKeyPairOptions, 'extractable'>,
): Promise<JoseKey> {
const kp = await this.generateKeyPair(allowedAlgos, {
...options,
extractable: true,
})
return this.fromKeyLike(kp.privateKey, kid)
}
static async fromImportable(
input: string | KeyLike | Jwk,
kid?: string,
): Promise<JoseKey> {
if (typeof input === 'string') {
// PKCS8
if (input.startsWith('-----')) {
// The "alg" is only needed in WebCrypto (NodeJS will be fine)
return this.fromPKCS8(input, '', kid)
}
// Jwk (string)
if (input.startsWith('{')) {
return this.fromJWK(input, kid)
}
throw new JwkError('Invalid input')
}
if (typeof input === 'object') {
// Jwk
if ('kty' in input || 'alg' in input) {
return this.fromJWK(input, kid)
}
// KeyLike
return this.fromKeyLike(input, kid)
}
throw new JwkError('Invalid input')
}
/**
* @see {@link exportJWK}
*/
static async fromKeyLike(
keyLike: KeyLike | Uint8Array,
kid?: string,
alg?: string,
): Promise<JoseKey> {
const jwk = await exportJWK(keyLike)
if (alg) {
if (!jwk.alg) jwk.alg = alg
else if (jwk.alg !== alg) throw new JwkError('Invalid "alg" in JWK')
}
return this.fromJWK(jwk, kid)
}
/**
* @see {@link importPKCS8}
*/
static async fromPKCS8(
pem: string,
alg: string,
kid?: string,
): Promise<JoseKey> {
const keyLike = await importPKCS8(pem, alg, { extractable: true })
return this.fromKeyLike(keyLike, kid)
}
static async fromJWK(
input: string | Record<string, unknown>,
inputKid?: string,
): Promise<JoseKey> {
const jwk = typeof input === 'string' ? JSON.parse(input) : input
if (!jwk || typeof jwk !== 'object') throw new JwkError('Invalid JWK')
const kid = either(jwk.kid, inputKid)
// Backwards compatibility with old behavior
if (jwk.use != null && isPrivateJwk(jwk)) {
console.warn(
'Deprecation warning: Private JWK with a "use" property will be rejected in the future. Please remove replace "use" with (valid) "key_ops".',
)
jwk.key_ops ??= jwk.use === 'sig' ? ['sign'] : ['encrypt']
delete jwk.use
}
return new JoseKey<Jwk>(jwkSchema.parse({ ...jwk, kid }))
}
}