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 extends Key { /** * 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 { 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( token: SignedJwt, options?: VerifyOptions, ): Promise> { 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, } } 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, ): Promise { 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 { 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 { 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 { const keyLike = await importPKCS8(pem, alg, { extractable: true }) return this.fromKeyLike(keyLike, kid) } static async fromJWK( input: string | Record, inputKid?: string, ): Promise { 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(jwkSchema.parse({ ...jwk, kid })) } }