@atproto/oauth-client
Version:
OAuth client for ATPROTO PDS. This package serves as common base for environment-specific implementations (NodeJS, Browser, React-Native).
116 lines (99 loc) • 3.52 kB
text/typescript
import { Key } from '@atproto/jwk'
import { base64url } from 'multiformats/bases/base64'
import { requestLocalLock } from './lock.js'
import { RuntimeImplementation, RuntimeLock } from './runtime-implementation.js'
export class Runtime {
readonly hasImplementationLock: boolean
readonly usingLock: RuntimeLock
constructor(protected implementation: RuntimeImplementation) {
const { requestLock } = implementation
this.hasImplementationLock = requestLock != null
this.usingLock =
requestLock?.bind(implementation) ||
// Falling back to a local lock
requestLocalLock
}
public async generateKey(algs: string[]): Promise<Key> {
const algsSorted = Array.from(algs).sort(compareAlgos)
return this.implementation.createKey(algsSorted)
}
public async sha256(text: string): Promise<string> {
const bytes = new TextEncoder().encode(text)
const digest = await this.implementation.digest(bytes, { name: 'sha256' })
return base64url.baseEncode(digest)
}
public async generateNonce(length = 16): Promise<string> {
const bytes = await this.implementation.getRandomValues(length)
return base64url.baseEncode(bytes)
}
public async generatePKCE(byteLength?: number) {
const verifier = await this.generateVerifier(byteLength)
return {
verifier,
challenge: await this.sha256(verifier),
method: 'S256' as const,
}
}
public async calculateJwkThumbprint(jwk) {
const components = extractJktComponents(jwk)
const data = JSON.stringify(components)
return this.sha256(data)
}
/**
* @see {@link https://datatracker.ietf.org/doc/html/rfc7636#section-4.1}
* @note It is RECOMMENDED that the output of a suitable random number generator
* be used to create a 32-octet sequence. The octet sequence is then
* base64url-encoded to produce a 43-octet URL safe string to use as the code
* verifier.
*/
protected async generateVerifier(byteLength = 32) {
if (byteLength < 32 || byteLength > 96) {
throw new TypeError('Invalid code_verifier length')
}
const bytes = await this.implementation.getRandomValues(byteLength)
return base64url.baseEncode(bytes)
}
}
function extractJktComponents(jwk) {
const get = (field) => {
const value = jwk[field]
if (typeof value !== 'string' || !value) {
throw new TypeError(`"${field}" Parameter missing or invalid`)
}
return value
}
switch (jwk.kty) {
case 'EC':
return { crv: get('crv'), kty: get('kty'), x: get('x'), y: get('y') }
case 'OKP':
return { crv: get('crv'), kty: get('kty'), x: get('x') }
case 'RSA':
return { e: get('e'), kty: get('kty'), n: get('n') }
case 'oct':
return { k: get('k'), kty: get('kty') }
default:
throw new TypeError('"kty" (Key Type) Parameter missing or unsupported')
}
}
/**
* 256K > ES (256 > 384 > 512) > PS (256 > 384 > 512) > RS (256 > 384 > 512) > other (in original order)
*/
function compareAlgos(a: string, b: string): number {
if (a === 'ES256K') return -1
if (b === 'ES256K') return 1
for (const prefix of ['ES', 'PS', 'RS']) {
if (a.startsWith(prefix)) {
if (b.startsWith(prefix)) {
const aLen = parseInt(a.slice(2, 5))
const bLen = parseInt(b.slice(2, 5))
// Prefer shorter key lengths
return aLen - bLen
}
return -1
} else if (b.startsWith(prefix)) {
return 1
}
}
// Don't know how to compare, keep original order
return 0
}