remove all the test files
This commit is contained in:
@@ -1,34 +0,0 @@
|
|||||||
import {fromByteArray, toByteArray} from 'base64-js'
|
|
||||||
|
|
||||||
// Old Node implementations do not support "base64url"
|
|
||||||
const Buffer = (Buffer => {
|
|
||||||
if (typeof Buffer === 'function') {
|
|
||||||
try {
|
|
||||||
Buffer.from('', 'base64url')
|
|
||||||
return Buffer
|
|
||||||
} catch {
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return undefined
|
|
||||||
})(globalThis.Buffer)
|
|
||||||
|
|
||||||
export const b64uDecode: (b64u: string) => Uint8Array = Buffer
|
|
||||||
? b64u => Buffer.from(b64u, 'base64url')
|
|
||||||
: b64u => {
|
|
||||||
// toByteArray requires padding but not to replace '-' and '_'
|
|
||||||
const pad = b64u.length % 4
|
|
||||||
const b64 = b64u.padEnd(b64u.length + (pad > 0 ? 4 - pad : 0), '=')
|
|
||||||
return toByteArray(b64)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const b64uEncode = Buffer
|
|
||||||
? (bytes: Uint8Array) => {
|
|
||||||
const buffer = bytes instanceof Buffer ? bytes : Buffer.from(bytes)
|
|
||||||
return buffer.toString('base64url')
|
|
||||||
}
|
|
||||||
: (bytes: Uint8Array): string =>
|
|
||||||
fromByteArray(bytes)
|
|
||||||
.replace(/\+/g, '-')
|
|
||||||
.replace(/\//g, '_')
|
|
||||||
.replace(/[=]+$/g, '')
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export const FALLBACK_ALG = 'ES256'
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
import {CryptoKey} from '#/oauth-client-temp/client/key'
|
|
||||||
import {Key} from '#/oauth-client-temp/jwk'
|
|
||||||
|
|
||||||
// TODO this might not be necessary with this setup, we will see
|
|
||||||
|
|
||||||
export type DigestAlgorithm = {
|
|
||||||
name: 'sha256' | 'sha384' | 'sha512'
|
|
||||||
}
|
|
||||||
|
|
||||||
export class CryptoImplementation {
|
|
||||||
public async createKey(algs: string[]): Promise<Key> {
|
|
||||||
return CryptoKey.generate(undefined, algs)
|
|
||||||
}
|
|
||||||
|
|
||||||
getRandomValues(byteLength: number): Uint8Array {
|
|
||||||
const bytes = new Uint8Array(byteLength)
|
|
||||||
// TODO REVIEW POLYFILL
|
|
||||||
// @ts-ignore Polyfilled
|
|
||||||
return crypto.getRandomValues(bytes)
|
|
||||||
}
|
|
||||||
|
|
||||||
async digest(
|
|
||||||
bytes: Uint8Array,
|
|
||||||
algorithm: DigestAlgorithm,
|
|
||||||
): Promise<Uint8Array> {
|
|
||||||
// TODO REVIEW POLYFILL
|
|
||||||
// @ts-ignore Polyfilled
|
|
||||||
const buffer = await this.crypto.subtle.digest(
|
|
||||||
digestAlgorithmToSubtle(algorithm),
|
|
||||||
bytes,
|
|
||||||
)
|
|
||||||
return new Uint8Array(buffer)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO OAUTH types
|
|
||||||
// @ts-ignore
|
|
||||||
function digestAlgorithmToSubtle({name}: DigestAlgorithm): AlgorithmIdentifier {
|
|
||||||
switch (name) {
|
|
||||||
case 'sha256':
|
|
||||||
case 'sha384':
|
|
||||||
case 'sha512':
|
|
||||||
return `SHA-${name.slice(-3)}`
|
|
||||||
default:
|
|
||||||
throw new Error(`Unknown hash algorithm ${name}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,173 +0,0 @@
|
|||||||
import {b64uEncode} from '#/oauth-client-temp/b64'
|
|
||||||
import {
|
|
||||||
JwtHeader,
|
|
||||||
JwtPayload,
|
|
||||||
Key,
|
|
||||||
unsafeDecodeJwt,
|
|
||||||
} from '#/oauth-client-temp/jwk'
|
|
||||||
import {CryptoImplementation, DigestAlgorithm} from './crypto-implementation'
|
|
||||||
|
|
||||||
export class CryptoWrapper {
|
|
||||||
constructor(protected implementation: CryptoImplementation) {}
|
|
||||||
|
|
||||||
public async generateKey(algs: string[]): Promise<Key> {
|
|
||||||
return this.implementation.createKey(algs)
|
|
||||||
}
|
|
||||||
|
|
||||||
public async sha256(text: string): Promise<string> {
|
|
||||||
// TODO REVIEW POLYFILL
|
|
||||||
// @ts-ignore Polyfilled
|
|
||||||
const bytes = new TextEncoder().encode(text)
|
|
||||||
const digest = await this.implementation.digest(bytes, {name: 'sha256'})
|
|
||||||
return b64uEncode(digest)
|
|
||||||
}
|
|
||||||
|
|
||||||
public async generateNonce(length = 16): Promise<string> {
|
|
||||||
const bytes = await this.implementation.getRandomValues(length)
|
|
||||||
return b64uEncode(bytes)
|
|
||||||
}
|
|
||||||
|
|
||||||
public async validateIdTokenClaims(
|
|
||||||
token: string,
|
|
||||||
state: string,
|
|
||||||
nonce: string,
|
|
||||||
code?: string,
|
|
||||||
accessToken?: string,
|
|
||||||
): Promise<{
|
|
||||||
header: JwtHeader
|
|
||||||
payload: JwtPayload
|
|
||||||
}> {
|
|
||||||
// It's fine to use unsafeDecodeJwt here because the token was received from
|
|
||||||
// the server's token endpoint. The following checks are to ensure that the
|
|
||||||
// oauth flow was indeed initiated by the client.
|
|
||||||
const {header, payload} = unsafeDecodeJwt(token)
|
|
||||||
if (!payload.nonce || payload.nonce !== nonce) {
|
|
||||||
throw new TypeError('Nonce mismatch')
|
|
||||||
}
|
|
||||||
if (payload.c_hash) {
|
|
||||||
await this.validateHashClaim(payload.c_hash, code, header)
|
|
||||||
}
|
|
||||||
if (payload.s_hash) {
|
|
||||||
await this.validateHashClaim(payload.s_hash, state, header)
|
|
||||||
}
|
|
||||||
if (payload.at_hash) {
|
|
||||||
await this.validateHashClaim(payload.at_hash, accessToken, header)
|
|
||||||
}
|
|
||||||
return {header, payload}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async validateHashClaim(
|
|
||||||
claim: unknown,
|
|
||||||
source: unknown,
|
|
||||||
header: {alg: string; crv?: string},
|
|
||||||
): Promise<void> {
|
|
||||||
if (typeof claim !== 'string' || !claim) {
|
|
||||||
throw new TypeError(`string "_hash" claim expected`)
|
|
||||||
}
|
|
||||||
if (typeof source !== 'string' || !source) {
|
|
||||||
throw new TypeError(`string value expected`)
|
|
||||||
}
|
|
||||||
const expected = await this.generateHashClaim(source, header)
|
|
||||||
if (expected !== claim) {
|
|
||||||
throw new TypeError(`"_hash" does not match`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
protected async generateHashClaim(
|
|
||||||
source: string,
|
|
||||||
header: {alg: string; crv?: string},
|
|
||||||
) {
|
|
||||||
const algo = getHashAlgo(header)
|
|
||||||
// TODO REVIEW POLYFILL
|
|
||||||
// @ts-ignore Polyfilled
|
|
||||||
const bytes = new TextEncoder().encode(source)
|
|
||||||
const digest = await this.implementation.digest(bytes, algo)
|
|
||||||
return b64uEncode(digest.slice(0, digest.length / 2))
|
|
||||||
}
|
|
||||||
|
|
||||||
public async generatePKCE(byteLength?: number) {
|
|
||||||
const verifier = await this.generateVerifier(byteLength)
|
|
||||||
return {
|
|
||||||
verifier,
|
|
||||||
challenge: await this.sha256(verifier),
|
|
||||||
method: 'S256',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO OAUTH types
|
|
||||||
public async calculateJwkThumbprint(jwk: any) {
|
|
||||||
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 b64uEncode(bytes)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getHashAlgo(header: {alg: string; crv?: string}): DigestAlgorithm {
|
|
||||||
switch (header.alg) {
|
|
||||||
case 'HS256':
|
|
||||||
case 'RS256':
|
|
||||||
case 'PS256':
|
|
||||||
case 'ES256':
|
|
||||||
case 'ES256K':
|
|
||||||
return {name: 'sha256'}
|
|
||||||
case 'HS384':
|
|
||||||
case 'RS384':
|
|
||||||
case 'PS384':
|
|
||||||
case 'ES384':
|
|
||||||
return {name: 'sha384'}
|
|
||||||
case 'HS512':
|
|
||||||
case 'RS512':
|
|
||||||
case 'PS512':
|
|
||||||
case 'ES512':
|
|
||||||
return {name: 'sha512'}
|
|
||||||
case 'EdDSA':
|
|
||||||
switch (header.crv) {
|
|
||||||
case 'Ed25519':
|
|
||||||
return {name: 'sha512'}
|
|
||||||
default:
|
|
||||||
throw new TypeError('unrecognized or invalid EdDSA curve provided')
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
throw new TypeError('unrecognized or invalid JWS algorithm provided')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO OAUTH types
|
|
||||||
function extractJktComponents(jwk: {[x: string]: any; kty: any}) {
|
|
||||||
// TODO OAUTH types
|
|
||||||
const get = (field: string) => {
|
|
||||||
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')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
import {
|
|
||||||
fromSubtleAlgorithm,
|
|
||||||
generateKeypair,
|
|
||||||
isSignatureKeyPair,
|
|
||||||
} from '#/oauth-client-temp/client/util'
|
|
||||||
import {Jwk, jwkSchema} from '#/oauth-client-temp/jwk'
|
|
||||||
import {JoseKey} from '#/oauth-client-temp/jwk-jose'
|
|
||||||
|
|
||||||
export class CryptoKey extends JoseKey {
|
|
||||||
// static async fromIndexedDB(kid: string, allowedAlgos: string[] = ['ES384']) {
|
|
||||||
// const cryptoKeyPair = await loadCryptoKeyPair(kid, allowedAlgos)
|
|
||||||
// return this.fromKeypair(kid, cryptoKeyPair)
|
|
||||||
// }
|
|
||||||
|
|
||||||
static async generate(
|
|
||||||
// TODO REVIEW POLYFILL
|
|
||||||
// @ts-ignore Polyfilled
|
|
||||||
kid: string = crypto.randomUUID(),
|
|
||||||
allowedAlgos: string[] = ['ES384'],
|
|
||||||
exportable = false,
|
|
||||||
) {
|
|
||||||
const cryptoKeyPair = await generateKeypair(allowedAlgos, exportable)
|
|
||||||
return this.fromKeypair(kid, cryptoKeyPair)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO REVIEW POLYFILL
|
|
||||||
// @ts-ignore Polyfilled
|
|
||||||
static async fromKeypair(kid: string, cryptoKeyPair: CryptoKeyPair) {
|
|
||||||
if (!isSignatureKeyPair(cryptoKeyPair)) {
|
|
||||||
throw new TypeError('CryptoKeyPair must be compatible with sign/verify')
|
|
||||||
}
|
|
||||||
|
|
||||||
// https://datatracker.ietf.org/doc/html/rfc7517
|
|
||||||
// > The "use" and "key_ops" JWK members SHOULD NOT be used together; [...]
|
|
||||||
// > Applications should specify which of these members they use.
|
|
||||||
|
|
||||||
// TODO REVIEW POLYFILL
|
|
||||||
// @ts-ignore Polyfilled
|
|
||||||
const {key_ops: _, ...jwk} = await crypto.subtle.exportKey(
|
|
||||||
'jwk',
|
|
||||||
cryptoKeyPair.privateKey.extractable
|
|
||||||
? cryptoKeyPair.privateKey
|
|
||||||
: cryptoKeyPair.publicKey,
|
|
||||||
)
|
|
||||||
|
|
||||||
const use = jwk.use ?? 'sig'
|
|
||||||
const alg =
|
|
||||||
jwk.alg ?? fromSubtleAlgorithm(cryptoKeyPair.privateKey.algorithm)
|
|
||||||
|
|
||||||
if (use !== 'sig') {
|
|
||||||
throw new TypeError('Unsupported JWK use')
|
|
||||||
}
|
|
||||||
|
|
||||||
return new CryptoKey(
|
|
||||||
jwkSchema.parse({...jwk, use, kid, alg}),
|
|
||||||
cryptoKeyPair,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO REVIEW POLYFILL
|
|
||||||
// @ts-ignore Polyfilled
|
|
||||||
constructor(jwk: Jwk, readonly cryptoKeyPair: CryptoKeyPair) {
|
|
||||||
super(jwk)
|
|
||||||
}
|
|
||||||
|
|
||||||
get isPrivate() {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
get privateJwk(): Jwk | undefined {
|
|
||||||
if (super.isPrivate) return this.jwk
|
|
||||||
throw new Error('Private Webcrypto Key not exportable')
|
|
||||||
}
|
|
||||||
|
|
||||||
protected async getKey() {
|
|
||||||
return this.cryptoKeyPair.privateKey
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
export class OAuthCallbackError extends Error {
|
|
||||||
static from(err: unknown, params: URLSearchParams, state?: string) {
|
|
||||||
if (err instanceof OAuthCallbackError) return err
|
|
||||||
const message = err instanceof Error ? err.message : undefined
|
|
||||||
return new OAuthCallbackError(params, message, state, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
public readonly params: URLSearchParams,
|
|
||||||
message = params.get('error_description') || 'OAuth callback error',
|
|
||||||
public readonly state?: string,
|
|
||||||
cause?: unknown,
|
|
||||||
) {
|
|
||||||
super(message, { cause })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,290 +0,0 @@
|
|||||||
import {GenericStore} from '@atproto/caching'
|
|
||||||
|
|
||||||
import {Key} from '#/oauth-client-temp/jwk'
|
|
||||||
import {FALLBACK_ALG} from './constants'
|
|
||||||
import {OAuthCallbackError} from './oauth-callback-error'
|
|
||||||
import {OAuthClient} from './oauth-client'
|
|
||||||
import {OAuthServer} from './oauth-server'
|
|
||||||
import {
|
|
||||||
OAuthServerFactory,
|
|
||||||
OAuthServerFactoryOptions,
|
|
||||||
} from './oauth-server-factory'
|
|
||||||
import {
|
|
||||||
OAuthAuthorizeOptions,
|
|
||||||
OAuthResponseMode,
|
|
||||||
OAuthResponseType,
|
|
||||||
} from './oauth-types'
|
|
||||||
import {Session, SessionGetter} from './session-getter'
|
|
||||||
|
|
||||||
export type InternalStateData = {
|
|
||||||
iss: string
|
|
||||||
nonce: string
|
|
||||||
dpopKey: Key
|
|
||||||
verifier?: string
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @note This could be parametrized to be of any type. This wasn't done for
|
|
||||||
* the sake of simplicity but could be added in a later development.
|
|
||||||
*/
|
|
||||||
appState?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export type OAuthClientOptions = OAuthServerFactoryOptions & {
|
|
||||||
stateStore: GenericStore<string, InternalStateData>
|
|
||||||
sessionStore: GenericStore<string, Session>
|
|
||||||
|
|
||||||
/**
|
|
||||||
* "form_post" will typically be used for server-side applications.
|
|
||||||
*/
|
|
||||||
responseMode?: OAuthResponseMode
|
|
||||||
responseType?: OAuthResponseType
|
|
||||||
}
|
|
||||||
|
|
||||||
export class OAuthClientFactory {
|
|
||||||
readonly serverFactory: OAuthServerFactory
|
|
||||||
|
|
||||||
readonly stateStore: GenericStore<string, InternalStateData>
|
|
||||||
readonly sessionGetter: SessionGetter
|
|
||||||
|
|
||||||
readonly responseMode?: OAuthResponseMode
|
|
||||||
readonly responseType?: OAuthResponseType
|
|
||||||
|
|
||||||
constructor(options: OAuthClientOptions) {
|
|
||||||
this.responseMode = options?.responseMode
|
|
||||||
this.responseType = options?.responseType
|
|
||||||
this.serverFactory = new OAuthServerFactory(options)
|
|
||||||
this.stateStore = options.stateStore
|
|
||||||
this.sessionGetter = new SessionGetter(
|
|
||||||
options.sessionStore,
|
|
||||||
this.serverFactory,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
get clientMetadata() {
|
|
||||||
return this.serverFactory.clientMetadata
|
|
||||||
}
|
|
||||||
|
|
||||||
async authorize(
|
|
||||||
input: string,
|
|
||||||
options?: OAuthAuthorizeOptions,
|
|
||||||
): Promise<URL> {
|
|
||||||
const {did, metadata} = await this.serverFactory.resolver.resolve(input)
|
|
||||||
|
|
||||||
const nonce = await this.serverFactory.crypto.generateNonce()
|
|
||||||
const pkce = await this.serverFactory.crypto.generatePKCE()
|
|
||||||
const dpopKey = await this.serverFactory.crypto.generateKey(
|
|
||||||
metadata.dpop_signing_alg_values_supported || [FALLBACK_ALG],
|
|
||||||
)
|
|
||||||
|
|
||||||
const state = await this.serverFactory.crypto.generateNonce()
|
|
||||||
|
|
||||||
await this.stateStore.set(state, {
|
|
||||||
iss: metadata.issuer,
|
|
||||||
dpopKey,
|
|
||||||
nonce,
|
|
||||||
verifier: pkce?.verifier,
|
|
||||||
appState: options?.state,
|
|
||||||
})
|
|
||||||
|
|
||||||
const parameters = {
|
|
||||||
client_id: this.clientMetadata.client_id,
|
|
||||||
redirect_uri: this.clientMetadata.redirect_uris[0],
|
|
||||||
code_challenge: pkce?.challenge,
|
|
||||||
code_challenge_method: pkce?.method,
|
|
||||||
nonce,
|
|
||||||
state,
|
|
||||||
login_hint: did || undefined,
|
|
||||||
response_mode: this.responseMode,
|
|
||||||
response_type:
|
|
||||||
this.responseType != null &&
|
|
||||||
metadata.response_types_supported?.includes(this.responseType)
|
|
||||||
? this.responseType
|
|
||||||
: 'code',
|
|
||||||
|
|
||||||
display: options?.display,
|
|
||||||
id_token_hint: options?.id_token_hint,
|
|
||||||
max_age: options?.max_age, // this.clientMetadata.default_max_age
|
|
||||||
prompt: options?.prompt,
|
|
||||||
scope: options?.scope
|
|
||||||
?.split(' ')
|
|
||||||
.filter(s => metadata.scopes_supported?.includes(s))
|
|
||||||
.join(' '),
|
|
||||||
ui_locales: options?.ui_locales,
|
|
||||||
}
|
|
||||||
|
|
||||||
if (metadata.pushed_authorization_request_endpoint) {
|
|
||||||
const server = await this.serverFactory.fromMetadata(metadata, dpopKey)
|
|
||||||
const {json} = await server.request(
|
|
||||||
'pushed_authorization_request',
|
|
||||||
parameters,
|
|
||||||
)
|
|
||||||
|
|
||||||
const authorizationUrl = new URL(metadata.authorization_endpoint)
|
|
||||||
authorizationUrl.searchParams.set(
|
|
||||||
'client_id',
|
|
||||||
this.clientMetadata.client_id,
|
|
||||||
)
|
|
||||||
authorizationUrl.searchParams.set('request_uri', json.request_uri)
|
|
||||||
return authorizationUrl
|
|
||||||
} else if (metadata.require_pushed_authorization_requests) {
|
|
||||||
throw new Error(
|
|
||||||
'Server requires pushed authorization requests (PAR) but no PAR endpoint is available',
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
const authorizationUrl = new URL(metadata.authorization_endpoint)
|
|
||||||
for (const [key, value] of Object.entries(parameters)) {
|
|
||||||
if (value) authorizationUrl.searchParams.set(key, String(value))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Length of the URL that will be sent to the server
|
|
||||||
const urlLength =
|
|
||||||
authorizationUrl.pathname.length + authorizationUrl.search.length
|
|
||||||
if (urlLength < 2048) {
|
|
||||||
return authorizationUrl
|
|
||||||
} else if (!metadata.pushed_authorization_request_endpoint) {
|
|
||||||
throw new Error('Login URL too long')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error(
|
|
||||||
'Server does not support pushed authorization requests (PAR)',
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
async callback(params: URLSearchParams): Promise<{
|
|
||||||
client: OAuthClient
|
|
||||||
state?: string
|
|
||||||
}> {
|
|
||||||
// TODO: better errors
|
|
||||||
|
|
||||||
const responseJwt = params.get('response')
|
|
||||||
if (responseJwt != null) {
|
|
||||||
// https://openid.net/specs/oauth-v2-jarm.html
|
|
||||||
throw new OAuthCallbackError(params, 'JARM not supported')
|
|
||||||
}
|
|
||||||
|
|
||||||
const issuerParam = params.get('iss')
|
|
||||||
const stateParam = params.get('state')
|
|
||||||
const errorParam = params.get('error')
|
|
||||||
const codeParam = params.get('code')
|
|
||||||
|
|
||||||
if (!stateParam) {
|
|
||||||
throw new OAuthCallbackError(params, 'Missing "state" parameter')
|
|
||||||
}
|
|
||||||
const stateData = await this.stateStore.get(stateParam)
|
|
||||||
if (stateData) {
|
|
||||||
// Prevent any kind of replay
|
|
||||||
await this.stateStore.del(stateParam)
|
|
||||||
} else {
|
|
||||||
throw new OAuthCallbackError(params, 'Invalid state')
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (errorParam != null) {
|
|
||||||
throw new OAuthCallbackError(params, undefined, stateData.appState)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!codeParam) {
|
|
||||||
throw new OAuthCallbackError(
|
|
||||||
params,
|
|
||||||
'Missing "code" query param',
|
|
||||||
stateData.appState,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const server = await this.serverFactory.fromIssuer(
|
|
||||||
stateData.iss,
|
|
||||||
stateData.dpopKey,
|
|
||||||
)
|
|
||||||
|
|
||||||
if (issuerParam != null) {
|
|
||||||
if (!server.serverMetadata.issuer) {
|
|
||||||
throw new OAuthCallbackError(
|
|
||||||
params,
|
|
||||||
'Issuer not found in metadata',
|
|
||||||
stateData.appState,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (server.serverMetadata.issuer !== issuerParam) {
|
|
||||||
throw new OAuthCallbackError(
|
|
||||||
params,
|
|
||||||
'Issuer mismatch',
|
|
||||||
stateData.appState,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else if (
|
|
||||||
server.serverMetadata.authorization_response_iss_parameter_supported
|
|
||||||
) {
|
|
||||||
throw new OAuthCallbackError(
|
|
||||||
params,
|
|
||||||
'iss missing from the response',
|
|
||||||
stateData.appState,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const tokenSet = await server.exchangeCode(codeParam, stateData.verifier)
|
|
||||||
try {
|
|
||||||
if (tokenSet.id_token) {
|
|
||||||
await this.serverFactory.crypto.validateIdTokenClaims(
|
|
||||||
tokenSet.id_token,
|
|
||||||
stateParam,
|
|
||||||
stateData.nonce,
|
|
||||||
codeParam,
|
|
||||||
tokenSet.access_token,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const sessionId = await this.serverFactory.crypto.generateNonce(4)
|
|
||||||
|
|
||||||
await this.sessionGetter.setStored(sessionId, {
|
|
||||||
dpopKey: stateData.dpopKey,
|
|
||||||
tokenSet,
|
|
||||||
})
|
|
||||||
|
|
||||||
const client = this.createClient(server, sessionId)
|
|
||||||
|
|
||||||
return {client, state: stateData.appState}
|
|
||||||
} catch (err) {
|
|
||||||
await server.revoke(tokenSet.access_token)
|
|
||||||
|
|
||||||
throw err
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
// Make sure, whatever the underlying error, that the appState is
|
|
||||||
// available in the calling code
|
|
||||||
throw OAuthCallbackError.from(err, params, stateData.appState)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build a client from a stored session. This will refresh the token only if
|
|
||||||
* needed (about to expire) by default.
|
|
||||||
*
|
|
||||||
* @param refresh See {@link SessionGetter.getSession}
|
|
||||||
*/
|
|
||||||
async restore(sessionId: string, refresh?: boolean): Promise<OAuthClient> {
|
|
||||||
const {dpopKey, tokenSet} = await this.sessionGetter.getSession(
|
|
||||||
sessionId,
|
|
||||||
refresh,
|
|
||||||
)
|
|
||||||
|
|
||||||
const server = await this.serverFactory.fromIssuer(tokenSet.iss, dpopKey)
|
|
||||||
|
|
||||||
return this.createClient(server, sessionId)
|
|
||||||
}
|
|
||||||
|
|
||||||
async revoke(sessionId: string) {
|
|
||||||
const {dpopKey, tokenSet} = await this.sessionGetter.get(sessionId, {
|
|
||||||
allowStale: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
const server = await this.serverFactory.fromIssuer(tokenSet.iss, dpopKey)
|
|
||||||
|
|
||||||
await server.revoke(tokenSet.access_token)
|
|
||||||
await this.sessionGetter.delStored(sessionId)
|
|
||||||
}
|
|
||||||
|
|
||||||
createClient(server: OAuthServer, sessionId: string): OAuthClient {
|
|
||||||
return new OAuthClient(server, sessionId, this.sessionGetter)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
import {JwtPayload, unsafeDecodeJwt} from '#/oauth-client-temp/jwk'
|
|
||||||
import {OAuthServer, TokenSet} from './oauth-server'
|
|
||||||
import {SessionGetter} from './session-getter'
|
|
||||||
|
|
||||||
export class OAuthClient {
|
|
||||||
constructor(
|
|
||||||
private readonly server: OAuthServer,
|
|
||||||
public readonly sessionId: string,
|
|
||||||
private readonly sessionGetter: SessionGetter,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param refresh See {@link SessionGetter.getSession}
|
|
||||||
*/
|
|
||||||
async getTokenSet(refresh?: boolean): Promise<TokenSet> {
|
|
||||||
const {tokenSet} = await this.sessionGetter.getSession(
|
|
||||||
this.sessionId,
|
|
||||||
refresh,
|
|
||||||
)
|
|
||||||
return tokenSet
|
|
||||||
}
|
|
||||||
|
|
||||||
async getUserinfo(): Promise<{
|
|
||||||
userinfo?: JwtPayload
|
|
||||||
expired?: boolean
|
|
||||||
scope?: string
|
|
||||||
iss: string
|
|
||||||
aud: string
|
|
||||||
sub: string
|
|
||||||
}> {
|
|
||||||
const tokenSet = await this.getTokenSet()
|
|
||||||
|
|
||||||
return {
|
|
||||||
userinfo: tokenSet.id_token
|
|
||||||
? unsafeDecodeJwt(tokenSet.id_token).payload
|
|
||||||
: undefined,
|
|
||||||
expired:
|
|
||||||
tokenSet.expires_at == null
|
|
||||||
? undefined
|
|
||||||
: tokenSet.expires_at < Date.now() - 5e3,
|
|
||||||
scope: tokenSet.scope,
|
|
||||||
iss: tokenSet.iss,
|
|
||||||
aud: tokenSet.aud,
|
|
||||||
sub: tokenSet.sub,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async signOut() {
|
|
||||||
try {
|
|
||||||
const tokenSet = await this.getTokenSet(false)
|
|
||||||
await this.server.revoke(tokenSet.access_token)
|
|
||||||
} finally {
|
|
||||||
await this.sessionGetter.delStored(this.sessionId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async request(
|
|
||||||
pathname: string,
|
|
||||||
init?: RequestInit,
|
|
||||||
refreshCredentials?: boolean,
|
|
||||||
): Promise<Response> {
|
|
||||||
const tokenSet = await this.getTokenSet(refreshCredentials)
|
|
||||||
const headers = new Headers(init?.headers)
|
|
||||||
headers.set(
|
|
||||||
'Authorization',
|
|
||||||
`${tokenSet.token_type} ${tokenSet.access_token}`,
|
|
||||||
)
|
|
||||||
const request = new Request(new URL(pathname, tokenSet.aud), {
|
|
||||||
...init,
|
|
||||||
headers,
|
|
||||||
})
|
|
||||||
|
|
||||||
return this.server.dpopFetch(request).catch(err => {
|
|
||||||
if (!refreshCredentials && isTokenExpiredError(err)) {
|
|
||||||
return this.request(pathname, init, true)
|
|
||||||
}
|
|
||||||
|
|
||||||
throw err
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @todo Actually implement this
|
|
||||||
*/
|
|
||||||
function isTokenExpiredError(_err: unknown) {
|
|
||||||
// TODO: Detect access_token expired 401
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
import {DidDocument} from '@atproto/did'
|
|
||||||
import {ResolvedHandle} from '@atproto/handle-resolver'
|
|
||||||
import {OAuthServerMetadata} from '@atproto/oauth-server-metadata'
|
|
||||||
import IsomorphicOAuthServerMetadataResolver from '@atproto/oauth-server-metadata-resolver'
|
|
||||||
|
|
||||||
import {CryptoKey} from '#/oauth-client-temp/client/key'
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
import {IdentityResolver, ResolvedIdentity} from '@atproto/identity-resolver'
|
|
||||||
import {
|
|
||||||
OAuthServerMetadata,
|
|
||||||
OAuthServerMetadataResolver,
|
|
||||||
} from '@atproto/oauth-server-metadata-resolver'
|
|
||||||
|
|
||||||
export class OAuthResolver {
|
|
||||||
constructor(
|
|
||||||
readonly metadataResolver: OAuthServerMetadataResolver,
|
|
||||||
readonly identityResolver: IdentityResolver,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public async resolve(input: string): Promise<
|
|
||||||
Partial<ResolvedIdentity> & {
|
|
||||||
url: URL
|
|
||||||
metadata: OAuthServerMetadata
|
|
||||||
}
|
|
||||||
> {
|
|
||||||
const identity = /^https?:\/\//.test(input)
|
|
||||||
? // Allow using a PDS url directly as login input (e.g. when the handle does not resolve to a DID)
|
|
||||||
{url: new URL(input)}
|
|
||||||
: await this.identityResolver.resolve(input, 'AtprotoPersonalDataServer')
|
|
||||||
|
|
||||||
const metadata = await this.metadataResolver.resolve(identity.url.origin)
|
|
||||||
|
|
||||||
return {...identity, metadata}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
import {GenericStore, MemoryStore} from '@atproto/caching'
|
|
||||||
import {Fetch} from '@atproto/fetch'
|
|
||||||
import {IdentityResolver} from '@atproto/identity-resolver'
|
|
||||||
import {OAuthClientMetadata} from '@atproto/oauth-client-metadata'
|
|
||||||
import {OAuthServerMetadata} from '@atproto/oauth-server-metadata'
|
|
||||||
import {OAuthServerMetadataResolver} from '@atproto/oauth-server-metadata-resolver'
|
|
||||||
|
|
||||||
import {Key, Keyset} from '#/oauth-client-temp/jwk'
|
|
||||||
import {CryptoImplementation} from './crypto-implementation'
|
|
||||||
import {CryptoWrapper} from './crypto-wrapper'
|
|
||||||
import {OAuthResolver} from './oauth-resolver'
|
|
||||||
import {OAuthServer} from './oauth-server'
|
|
||||||
import {OAuthClientMetadataId} from './oauth-types'
|
|
||||||
import {validateClientMetadata} from './validate-client-metadata'
|
|
||||||
|
|
||||||
export type OAuthServerFactoryOptions = {
|
|
||||||
clientMetadata: OAuthClientMetadata
|
|
||||||
metadataResolver: OAuthServerMetadataResolver
|
|
||||||
cryptoImplementation: CryptoImplementation
|
|
||||||
identityResolver: IdentityResolver
|
|
||||||
fetch?: Fetch
|
|
||||||
keyset?: Keyset
|
|
||||||
dpopNonceCache?: GenericStore<string, string>
|
|
||||||
}
|
|
||||||
|
|
||||||
export class OAuthServerFactory {
|
|
||||||
readonly clientMetadata: OAuthClientMetadataId
|
|
||||||
readonly metadataResolver: OAuthServerMetadataResolver
|
|
||||||
readonly crypto: CryptoWrapper
|
|
||||||
readonly resolver: OAuthResolver
|
|
||||||
readonly fetch: Fetch
|
|
||||||
readonly keyset?: Keyset
|
|
||||||
readonly dpopNonceCache: GenericStore<string, string>
|
|
||||||
|
|
||||||
constructor({
|
|
||||||
metadataResolver,
|
|
||||||
identityResolver,
|
|
||||||
clientMetadata,
|
|
||||||
cryptoImplementation,
|
|
||||||
keyset,
|
|
||||||
fetch = globalThis.fetch,
|
|
||||||
dpopNonceCache = new MemoryStore<string, string>({
|
|
||||||
ttl: 60e3,
|
|
||||||
max: 100,
|
|
||||||
}),
|
|
||||||
}: OAuthServerFactoryOptions) {
|
|
||||||
validateClientMetadata(clientMetadata, keyset)
|
|
||||||
|
|
||||||
if (!clientMetadata.client_id) {
|
|
||||||
throw new TypeError('A client_id property must be specified')
|
|
||||||
}
|
|
||||||
|
|
||||||
this.clientMetadata = clientMetadata
|
|
||||||
this.metadataResolver = metadataResolver
|
|
||||||
this.keyset = keyset
|
|
||||||
this.fetch = fetch
|
|
||||||
this.dpopNonceCache = dpopNonceCache
|
|
||||||
|
|
||||||
this.crypto = new CryptoWrapper(cryptoImplementation)
|
|
||||||
this.resolver = new OAuthResolver(metadataResolver, identityResolver)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fromIssuer(issuer: string, dpopKey: Key) {
|
|
||||||
const {origin} = new URL(issuer)
|
|
||||||
const serverMetadata = await this.metadataResolver.resolve(origin)
|
|
||||||
return this.fromMetadata(serverMetadata, dpopKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fromMetadata(serverMetadata: OAuthServerMetadata, dpopKey: Key) {
|
|
||||||
return new OAuthServer(
|
|
||||||
dpopKey,
|
|
||||||
serverMetadata,
|
|
||||||
this.clientMetadata,
|
|
||||||
this.dpopNonceCache,
|
|
||||||
this.resolver,
|
|
||||||
this.crypto,
|
|
||||||
this.keyset,
|
|
||||||
this.fetch,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,287 +0,0 @@
|
|||||||
import {GenericStore} from '@atproto/caching'
|
|
||||||
import {
|
|
||||||
Fetch,
|
|
||||||
fetchFailureHandler,
|
|
||||||
fetchJsonProcessor,
|
|
||||||
fetchOkProcessor,
|
|
||||||
} from '@atproto/fetch'
|
|
||||||
import {OAuthClientMetadata} from '@atproto/oauth-client-metadata'
|
|
||||||
import {OAuthServerMetadata} from '@atproto/oauth-server-metadata'
|
|
||||||
|
|
||||||
import {dpopFetchWrapper} from '#/oauth-client-temp/fetch-dpop'
|
|
||||||
import {Jwt, Key, Keyset} from '#/oauth-client-temp/jwk'
|
|
||||||
import {FALLBACK_ALG} from './constants'
|
|
||||||
import {CryptoWrapper} from './crypto-wrapper'
|
|
||||||
import {OAuthResolver} from './oauth-resolver'
|
|
||||||
import {
|
|
||||||
OAuthEndpointName,
|
|
||||||
OAuthTokenResponse,
|
|
||||||
OAuthTokenType,
|
|
||||||
} from './oauth-types'
|
|
||||||
|
|
||||||
export type TokenSet = {
|
|
||||||
iss: string
|
|
||||||
sub: string
|
|
||||||
aud: string
|
|
||||||
scope?: string
|
|
||||||
|
|
||||||
id_token?: Jwt
|
|
||||||
refresh_token?: string
|
|
||||||
access_token: string
|
|
||||||
token_type: OAuthTokenType
|
|
||||||
expires_at?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export class OAuthServer {
|
|
||||||
readonly dpopFetch: (request: Request) => Promise<Response>
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
readonly dpopKey: Key,
|
|
||||||
readonly serverMetadata: OAuthServerMetadata,
|
|
||||||
readonly clientMetadata: OAuthClientMetadata & {client_id: string},
|
|
||||||
readonly dpopNonceCache: GenericStore<string, string>,
|
|
||||||
readonly resolver: OAuthResolver,
|
|
||||||
readonly crypto: CryptoWrapper,
|
|
||||||
readonly keyset?: Keyset,
|
|
||||||
fetch?: Fetch,
|
|
||||||
) {
|
|
||||||
const dpopFetch = dpopFetchWrapper({
|
|
||||||
fetch,
|
|
||||||
iss: this.clientMetadata.client_id,
|
|
||||||
key: dpopKey,
|
|
||||||
alg: negotiateAlg(
|
|
||||||
dpopKey,
|
|
||||||
serverMetadata.dpop_signing_alg_values_supported,
|
|
||||||
),
|
|
||||||
sha256: async v => crypto.sha256(v),
|
|
||||||
nonceCache: dpopNonceCache,
|
|
||||||
})
|
|
||||||
|
|
||||||
this.dpopFetch = request => dpopFetch(request).catch(fetchFailureHandler)
|
|
||||||
}
|
|
||||||
|
|
||||||
async revoke(token: string) {
|
|
||||||
try {
|
|
||||||
await this.request('revocation', {token})
|
|
||||||
} catch {
|
|
||||||
// Don't care
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async exchangeCode(code: string, verifier?: string): Promise<TokenSet> {
|
|
||||||
const {json: tokenResponse} = await this.request('token', {
|
|
||||||
grant_type: 'authorization_code',
|
|
||||||
redirect_uri: this.clientMetadata.redirect_uris[0]!,
|
|
||||||
code,
|
|
||||||
code_verifier: verifier,
|
|
||||||
})
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (!tokenResponse.sub) {
|
|
||||||
throw new TypeError(`Missing "sub" in token response`)
|
|
||||||
}
|
|
||||||
|
|
||||||
// VERY IMPORTANT !
|
|
||||||
const resolved = await this.checkSubIssuer(tokenResponse.sub)
|
|
||||||
|
|
||||||
return {
|
|
||||||
sub: tokenResponse.sub,
|
|
||||||
aud: resolved.url.href,
|
|
||||||
iss: resolved.metadata.issuer,
|
|
||||||
|
|
||||||
scope: tokenResponse.scope,
|
|
||||||
id_token: tokenResponse.id_token,
|
|
||||||
refresh_token: tokenResponse.refresh_token,
|
|
||||||
access_token: tokenResponse.access_token,
|
|
||||||
token_type: tokenResponse.token_type ?? 'Bearer',
|
|
||||||
expires_at:
|
|
||||||
typeof tokenResponse.expires_in === 'number'
|
|
||||||
? Date.now() + tokenResponse.expires_in * 1000
|
|
||||||
: undefined,
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
await this.revoke(tokenResponse.access_token)
|
|
||||||
|
|
||||||
throw err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async refresh(tokenSet: TokenSet): Promise<TokenSet> {
|
|
||||||
if (!tokenSet.refresh_token) {
|
|
||||||
throw new Error('No refresh token available')
|
|
||||||
}
|
|
||||||
|
|
||||||
const {json: tokenResponse} = await this.request('token', {
|
|
||||||
grant_type: 'refresh_token',
|
|
||||||
refresh_token: tokenSet.refresh_token,
|
|
||||||
})
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (tokenSet.sub !== tokenResponse.sub) {
|
|
||||||
throw new TypeError(`Unexpected "sub" in token response`)
|
|
||||||
}
|
|
||||||
if (tokenSet.iss !== this.serverMetadata.issuer) {
|
|
||||||
throw new TypeError('Issuer mismatch')
|
|
||||||
}
|
|
||||||
|
|
||||||
// VERY IMPORTANT !
|
|
||||||
const resolved = await this.checkSubIssuer(tokenResponse.sub)
|
|
||||||
|
|
||||||
return {
|
|
||||||
sub: tokenResponse.sub,
|
|
||||||
aud: resolved.url.href,
|
|
||||||
iss: resolved.metadata.issuer,
|
|
||||||
|
|
||||||
id_token: tokenResponse.id_token,
|
|
||||||
refresh_token: tokenResponse.refresh_token,
|
|
||||||
access_token: tokenResponse.access_token,
|
|
||||||
token_type: tokenResponse.token_type ?? 'Bearer',
|
|
||||||
expires_at: Date.now() + (tokenResponse.expires_in ?? 60) * 1000,
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
await this.revoke(tokenResponse.access_token)
|
|
||||||
|
|
||||||
throw err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whenever an OAuth token response is received, we **MUST** verify that the
|
|
||||||
* "sub" is a DID, whose issuer authority is indeed the server we just
|
|
||||||
* obtained credentials from. This check is a critical step to actually be
|
|
||||||
* able to use the "sub" (DID) as being the actual user's identifier.
|
|
||||||
*/
|
|
||||||
protected async checkSubIssuer(sub: string) {
|
|
||||||
const resolved = await this.resolver.resolve(sub)
|
|
||||||
if (resolved.metadata.issuer !== this.serverMetadata.issuer) {
|
|
||||||
// Maybe the user switched PDS.
|
|
||||||
throw new TypeError('Issuer mismatch')
|
|
||||||
}
|
|
||||||
return resolved
|
|
||||||
}
|
|
||||||
|
|
||||||
async request<E extends OAuthEndpointName>(
|
|
||||||
endpoint: E,
|
|
||||||
payload: Record<string, unknown>,
|
|
||||||
) {
|
|
||||||
const url = this.serverMetadata[`${endpoint}_endpoint`]
|
|
||||||
if (!url) throw new Error(`No ${endpoint} endpoint available`)
|
|
||||||
const auth = await this.buildClientAuth(endpoint)
|
|
||||||
|
|
||||||
const request = new Request(url, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {...auth.headers, 'Content-Type': 'application/json'},
|
|
||||||
body: JSON.stringify({...payload, ...auth.payload}),
|
|
||||||
})
|
|
||||||
|
|
||||||
const response = await this.dpopFetch(request)
|
|
||||||
.then(fetchOkProcessor())
|
|
||||||
.then(
|
|
||||||
fetchJsonProcessor<
|
|
||||||
E extends 'pushed_authorization_request'
|
|
||||||
? {request_uri: string}
|
|
||||||
: E extends 'token'
|
|
||||||
? OAuthTokenResponse
|
|
||||||
: unknown
|
|
||||||
>(),
|
|
||||||
)
|
|
||||||
|
|
||||||
// TODO: validate using zod ?
|
|
||||||
if (endpoint === 'token') {
|
|
||||||
if (!response.json.access_token) {
|
|
||||||
throw new TypeError('No access token in token response')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return response
|
|
||||||
}
|
|
||||||
|
|
||||||
async buildClientAuth(endpoint: OAuthEndpointName): Promise<{
|
|
||||||
headers?: Record<string, string>
|
|
||||||
payload:
|
|
||||||
| {
|
|
||||||
client_id: string
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
client_id: string
|
|
||||||
client_assertion_type: string
|
|
||||||
client_assertion: string
|
|
||||||
}
|
|
||||||
}> {
|
|
||||||
const methodSupported =
|
|
||||||
this.serverMetadata[`${endpoint}_endpoint_auth_methods_supported`] ||
|
|
||||||
this.serverMetadata.token_endpoint_auth_methods_supported
|
|
||||||
|
|
||||||
const method =
|
|
||||||
this.clientMetadata[`${endpoint}_endpoint_auth_method`] ||
|
|
||||||
this.clientMetadata.token_endpoint_auth_method
|
|
||||||
|
|
||||||
if (
|
|
||||||
method === 'private_key_jwt' ||
|
|
||||||
(this.keyset &&
|
|
||||||
!method &&
|
|
||||||
(methodSupported?.includes('private_key_jwt') ?? false))
|
|
||||||
) {
|
|
||||||
if (!this.keyset) throw new Error('No keyset available')
|
|
||||||
|
|
||||||
try {
|
|
||||||
const alg =
|
|
||||||
this.serverMetadata[
|
|
||||||
`${endpoint}_endpoint_auth_signing_alg_values_supported`
|
|
||||||
] ??
|
|
||||||
this.serverMetadata
|
|
||||||
.token_endpoint_auth_signing_alg_values_supported ??
|
|
||||||
FALLBACK_ALG
|
|
||||||
|
|
||||||
// If jwks is defined, make sure to only sign using a key that exists in
|
|
||||||
// the jwks. If jwks_uri is defined, we can't be sure that the key we're
|
|
||||||
// looking for is in there so we will just assume it is.
|
|
||||||
const kid = this.clientMetadata.jwks?.keys
|
|
||||||
.map(({kid}) => kid)
|
|
||||||
.filter((v): v is string => !!v)
|
|
||||||
|
|
||||||
return {
|
|
||||||
payload: {
|
|
||||||
client_id: this.clientMetadata.client_id,
|
|
||||||
client_assertion_type:
|
|
||||||
'urn:ietf:params:oauth:client-assertion-type:jwt-bearer',
|
|
||||||
client_assertion: await this.keyset.sign(
|
|
||||||
{alg, kid},
|
|
||||||
{
|
|
||||||
iss: this.clientMetadata.client_id,
|
|
||||||
sub: this.clientMetadata.client_id,
|
|
||||||
aud: this.serverMetadata.issuer,
|
|
||||||
jti: await this.crypto.generateNonce(),
|
|
||||||
iat: Math.floor(Date.now() / 1000),
|
|
||||||
},
|
|
||||||
),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
if (method === 'private_key_jwt') throw err
|
|
||||||
|
|
||||||
// Else try next method
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
method === 'none' ||
|
|
||||||
(!method && (methodSupported?.includes('none') ?? true))
|
|
||||||
) {
|
|
||||||
return {
|
|
||||||
payload: {
|
|
||||||
client_id: this.clientMetadata.client_id,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error(`Unsupported ${endpoint} authentication method`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function negotiateAlg(key: Key, supportedAlgs: string[] | undefined): string {
|
|
||||||
const alg = key.algorithms.find(a => supportedAlgs?.includes(a) ?? true)
|
|
||||||
if (alg) return alg
|
|
||||||
|
|
||||||
throw new Error('Key does not match any alg supported by the server')
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
import {OAuthClientMetadata} from '@atproto/oauth-client-metadata'
|
|
||||||
|
|
||||||
import {Jwt} from '#/oauth-client-temp/jwk'
|
|
||||||
|
|
||||||
export type OAuthResponseMode = 'query' | 'fragment' | 'form_post'
|
|
||||||
export type OAuthResponseType = 'code' | 'code id_token'
|
|
||||||
|
|
||||||
export type OAuthEndpointName =
|
|
||||||
| 'token'
|
|
||||||
| 'revocation'
|
|
||||||
| 'introspection'
|
|
||||||
| 'pushed_authorization_request'
|
|
||||||
|
|
||||||
export type OAuthTokenType = 'Bearer' | 'DPoP'
|
|
||||||
|
|
||||||
export type OAuthAuthorizeOptions = {
|
|
||||||
display?: 'page' | 'popup' | 'touch' | 'wap'
|
|
||||||
id_token_hint?: string
|
|
||||||
max_age?: number
|
|
||||||
prompt?: 'login' | 'none' | 'consent' | 'select_account'
|
|
||||||
scope?: string
|
|
||||||
state?: string
|
|
||||||
ui_locales?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export type OAuthTokenResponse = {
|
|
||||||
issuer?: string
|
|
||||||
sub?: string
|
|
||||||
scope?: string
|
|
||||||
id_token?: Jwt
|
|
||||||
refresh_token?: string
|
|
||||||
access_token: string
|
|
||||||
token_type?: OAuthTokenType
|
|
||||||
expires_in?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export type OAuthClientMetadataId = OAuthClientMetadata & {client_id: string}
|
|
||||||
@@ -1,139 +0,0 @@
|
|||||||
import {CachedGetter, GenericStore} from '@atproto/caching'
|
|
||||||
import {FetchResponseError} from '@atproto/fetch'
|
|
||||||
|
|
||||||
import {Key} from '#/oauth-client-temp/jwk'
|
|
||||||
import {TokenSet} from './oauth-server'
|
|
||||||
import {OAuthServerFactory} from './oauth-server-factory'
|
|
||||||
|
|
||||||
export type Session = {
|
|
||||||
dpopKey: Key
|
|
||||||
tokenSet: TokenSet
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* There are several advantages to wrapping the sessionStore in a (single)
|
|
||||||
* CachedGetter, the main of which is that the cached getter will ensure that at
|
|
||||||
* most one fresh call is ever being made. Another advantage, is that it
|
|
||||||
* contains the logic for reading from the cache which, if the cache is based on
|
|
||||||
* localStorage/indexedDB, will sync across multiple tabs (for a given
|
|
||||||
* sessionId).
|
|
||||||
*/
|
|
||||||
export class SessionGetter extends CachedGetter<string, Session> {
|
|
||||||
constructor(
|
|
||||||
sessionStore: GenericStore<string, Session>,
|
|
||||||
serverFactory: OAuthServerFactory,
|
|
||||||
) {
|
|
||||||
super(
|
|
||||||
async (sessionId, options, storedSession) => {
|
|
||||||
// There needs to be a previous session to be able to refresh
|
|
||||||
if (storedSession === undefined) {
|
|
||||||
throw new Error('The session was revoked')
|
|
||||||
}
|
|
||||||
|
|
||||||
// Since refresh tokens can only be used once, we might run into
|
|
||||||
// concurrency issues if multiple tabs/instances are trying to refresh
|
|
||||||
// the same token. The chances of this happening when multiple instances
|
|
||||||
// are started simultaneously is reduced by randomizing the expiry time
|
|
||||||
// (see isStale() bellow). Even so, There still exist chances that
|
|
||||||
// multiple tabs will try to refresh the token at the same time. The
|
|
||||||
// best solution would be to use a mutex/lock to ensure that only one
|
|
||||||
// instance is refreshing the token at a time. A simpler workaround is
|
|
||||||
// to check if the value stored in the session store is the same as the
|
|
||||||
// one in memory. If it isn't, then another instance has already
|
|
||||||
// refreshed the token.
|
|
||||||
|
|
||||||
const {tokenSet, dpopKey} = storedSession
|
|
||||||
const server = await serverFactory.fromIssuer(tokenSet.iss, dpopKey)
|
|
||||||
const newTokenSet = await server.refresh(tokenSet).catch(async err => {
|
|
||||||
if (await isRefreshDeniedError(err)) {
|
|
||||||
// Allow some time for the concurrent request to be stored before
|
|
||||||
// we try to get it.
|
|
||||||
await new Promise(r => setTimeout(r, 500))
|
|
||||||
|
|
||||||
const stored = await this.getStored(sessionId)
|
|
||||||
if (stored !== undefined) {
|
|
||||||
if (
|
|
||||||
stored.tokenSet.access_token !== tokenSet.access_token ||
|
|
||||||
stored.tokenSet.refresh_token !== tokenSet.refresh_token
|
|
||||||
) {
|
|
||||||
// A concurrent refresh occurred. Pretend this one succeeded.
|
|
||||||
return stored.tokenSet
|
|
||||||
} else {
|
|
||||||
// The session data will be deleted from the sessionStore by
|
|
||||||
// the "deleteOnError" callback.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
throw err
|
|
||||||
})
|
|
||||||
return {...storedSession, tokenSet: newTokenSet}
|
|
||||||
},
|
|
||||||
sessionStore,
|
|
||||||
{
|
|
||||||
isStale: (sessionId, {tokenSet}) => {
|
|
||||||
return (
|
|
||||||
tokenSet.expires_at != null &&
|
|
||||||
tokenSet.expires_at <
|
|
||||||
Date.now() +
|
|
||||||
// Add some lee way to ensure the token is not expired when it
|
|
||||||
// reaches the server.
|
|
||||||
30e3 +
|
|
||||||
// Add some randomness to prevent all instances from trying to
|
|
||||||
// refreshing at the exact same time, when they are started at
|
|
||||||
// the same time.
|
|
||||||
60e3 * Math.random()
|
|
||||||
)
|
|
||||||
},
|
|
||||||
onStoreError: async (err, sessionId, {tokenSet, dpopKey}) => {
|
|
||||||
// If the token data cannot be stored, let's revoke it
|
|
||||||
const server = await serverFactory.fromIssuer(tokenSet.iss, dpopKey)
|
|
||||||
await server.revoke(tokenSet.access_token)
|
|
||||||
throw err
|
|
||||||
},
|
|
||||||
deleteOnError: async (err, sessionId, {tokenSet}) => {
|
|
||||||
// Not possible to refresh without a refresh token
|
|
||||||
if (!tokenSet.refresh_token) return true
|
|
||||||
|
|
||||||
// If fetching a refresh token fails because they are no longer valid,
|
|
||||||
// delete the session from the sessionStore.
|
|
||||||
if (await isRefreshDeniedError(err)) return true
|
|
||||||
|
|
||||||
// Unknown cause, keep the session in the store
|
|
||||||
return false
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param refresh When `true`, the credentials will be refreshed even if they
|
|
||||||
* are not expired. When `false`, the credentials will not be refreshed even
|
|
||||||
* if they are expired. When `undefined`, the credentials will be refreshed
|
|
||||||
* if, and only if, they are (about to be) expired. Defaults to `undefined`.
|
|
||||||
*/
|
|
||||||
async getSession(sessionId: string, refresh?: boolean) {
|
|
||||||
return this.get(sessionId, {
|
|
||||||
noCache: refresh === true,
|
|
||||||
allowStale: refresh === false,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function isRefreshDeniedError(err: unknown) {
|
|
||||||
if (err instanceof FetchResponseError && err.statusCode === 400) {
|
|
||||||
if (err.response?.bodyUsed === false) {
|
|
||||||
try {
|
|
||||||
const json = await err.response.clone().json()
|
|
||||||
return (
|
|
||||||
json.error === 'invalid_request' &&
|
|
||||||
json.error_description === 'Invalid refresh token'
|
|
||||||
)
|
|
||||||
} catch {
|
|
||||||
// falls through
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
@@ -1,160 +0,0 @@
|
|||||||
export type JWSAlgorithm =
|
|
||||||
// HMAC
|
|
||||||
| 'HS256'
|
|
||||||
| 'HS384'
|
|
||||||
| 'HS512'
|
|
||||||
// RSA
|
|
||||||
| 'PS256'
|
|
||||||
| 'PS384'
|
|
||||||
| 'PS512'
|
|
||||||
| 'RS256'
|
|
||||||
| 'RS384'
|
|
||||||
| 'RS512'
|
|
||||||
// EC
|
|
||||||
| 'ES256'
|
|
||||||
| 'ES256K'
|
|
||||||
| 'ES384'
|
|
||||||
| 'ES512'
|
|
||||||
// OKP
|
|
||||||
| 'EdDSA'
|
|
||||||
|
|
||||||
// TODO REVIEW POLYFILL
|
|
||||||
// @ts-ignore Polyfilled
|
|
||||||
export type SubtleAlgorithm = RsaHashedKeyGenParams | EcKeyGenParams
|
|
||||||
|
|
||||||
export function toSubtleAlgorithm(
|
|
||||||
alg: string,
|
|
||||||
crv?: string,
|
|
||||||
options?: {modulusLength?: number},
|
|
||||||
): SubtleAlgorithm {
|
|
||||||
switch (alg) {
|
|
||||||
case 'PS256':
|
|
||||||
case 'PS384':
|
|
||||||
case 'PS512':
|
|
||||||
return {
|
|
||||||
name: 'RSA-PSS',
|
|
||||||
hash: `SHA-${alg.slice(-3) as '256' | '384' | '512'}`,
|
|
||||||
modulusLength: options?.modulusLength ?? 2048,
|
|
||||||
publicExponent: new Uint8Array([0x01, 0x00, 0x01]),
|
|
||||||
}
|
|
||||||
case 'RS256':
|
|
||||||
case 'RS384':
|
|
||||||
case 'RS512':
|
|
||||||
return {
|
|
||||||
name: 'RSASSA-PKCS1-v1_5',
|
|
||||||
hash: `SHA-${alg.slice(-3) as '256' | '384' | '512'}`,
|
|
||||||
modulusLength: options?.modulusLength ?? 2048,
|
|
||||||
publicExponent: new Uint8Array([0x01, 0x00, 0x01]),
|
|
||||||
}
|
|
||||||
case 'ES256':
|
|
||||||
case 'ES384':
|
|
||||||
return {
|
|
||||||
name: 'ECDSA',
|
|
||||||
namedCurve: `P-${alg.slice(-3) as '256' | '384'}`,
|
|
||||||
}
|
|
||||||
case 'ES512':
|
|
||||||
return {
|
|
||||||
name: 'ECDSA',
|
|
||||||
namedCurve: 'P-521',
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
// https://github.com/w3c/webcrypto/issues/82#issuecomment-849856773
|
|
||||||
|
|
||||||
throw new TypeError(`Unsupported alg "${alg}"`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO REVIEW POLYFILL
|
|
||||||
// @ts-ignore Polyfilled
|
|
||||||
export function fromSubtleAlgorithm(algorithm: KeyAlgorithm): JWSAlgorithm {
|
|
||||||
switch (algorithm.name) {
|
|
||||||
case 'RSA-PSS':
|
|
||||||
case 'RSASSA-PKCS1-v1_5': {
|
|
||||||
// TODO REVIEW POLYFILL
|
|
||||||
// @ts-ignore Polyfilled
|
|
||||||
const hash = (<RsaHashedKeyAlgorithm>algorithm).hash.name
|
|
||||||
switch (hash) {
|
|
||||||
case 'SHA-256':
|
|
||||||
case 'SHA-384':
|
|
||||||
case 'SHA-512': {
|
|
||||||
const prefix = algorithm.name === 'RSA-PSS' ? 'PS' : 'RS'
|
|
||||||
return `${prefix}${hash.slice(-3) as '256' | '384' | '512'}`
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
throw new TypeError('unsupported RsaHashedKeyAlgorithm hash')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case 'ECDSA': {
|
|
||||||
// TODO REVIEW POLYFILL
|
|
||||||
// @ts-ignore Polyfilled
|
|
||||||
const namedCurve = (<EcKeyAlgorithm>algorithm).namedCurve
|
|
||||||
switch (namedCurve) {
|
|
||||||
case 'P-256':
|
|
||||||
case 'P-384':
|
|
||||||
case 'P-512':
|
|
||||||
return `ES${namedCurve.slice(-3) as '256' | '384' | '512'}`
|
|
||||||
case 'P-521':
|
|
||||||
return 'ES512'
|
|
||||||
default:
|
|
||||||
throw new TypeError('unsupported EcKeyAlgorithm namedCurve')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case 'Ed448':
|
|
||||||
case 'Ed25519':
|
|
||||||
return 'EdDSA'
|
|
||||||
default:
|
|
||||||
// https://github.com/w3c/webcrypto/issues/82#issuecomment-849856773
|
|
||||||
|
|
||||||
throw new TypeError(`Unexpected algorithm "${algorithm.name}"`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isSignatureKeyPair(
|
|
||||||
v: unknown,
|
|
||||||
extractable?: boolean,
|
|
||||||
// TODO REVIEW POLYFILL
|
|
||||||
// @ts-ignore Polyfilled
|
|
||||||
): v is CryptoKeyPair {
|
|
||||||
return (
|
|
||||||
typeof v === 'object' &&
|
|
||||||
v !== null &&
|
|
||||||
'privateKey' in v &&
|
|
||||||
// TODO REVIEW POLYFILL
|
|
||||||
// @ts-ignore Polyfilled
|
|
||||||
v.privateKey instanceof CryptoKey &&
|
|
||||||
v.privateKey.type === 'private' &&
|
|
||||||
(extractable == null || v.privateKey.extractable === extractable) &&
|
|
||||||
v.privateKey.usages.includes('sign') &&
|
|
||||||
'publicKey' in v &&
|
|
||||||
// TODO REVIEW POLYFILL
|
|
||||||
// @ts-ignore Polyfilled
|
|
||||||
v.publicKey instanceof CryptoKey &&
|
|
||||||
v.publicKey.type === 'public' &&
|
|
||||||
v.publicKey.extractable === true &&
|
|
||||||
v.publicKey.usages.includes('verify')
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function generateKeypair(
|
|
||||||
algs: string[],
|
|
||||||
extractable = false,
|
|
||||||
// TODO REVIEW POLYFILL
|
|
||||||
// @ts-ignore Polyfilled
|
|
||||||
): Promise<CryptoKeyPair> {
|
|
||||||
const errors: unknown[] = []
|
|
||||||
for (const alg of algs) {
|
|
||||||
try {
|
|
||||||
// TODO REVIEW POLYFILL
|
|
||||||
// @ts-ignore Polyfilled
|
|
||||||
return await crypto.subtle.generateKey(
|
|
||||||
toSubtleAlgorithm(alg),
|
|
||||||
extractable,
|
|
||||||
['sign', 'verify'],
|
|
||||||
)
|
|
||||||
} catch (err) {
|
|
||||||
errors.push(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new AggregateError(errors, 'Failed to generate keypair')
|
|
||||||
}
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
import {OAuthClientMetadata} from '@atproto/oauth-client-metadata'
|
|
||||||
|
|
||||||
import {Keyset} from '#/oauth-client-temp/jwk'
|
|
||||||
|
|
||||||
export function validateClientMetadata(
|
|
||||||
metadata: OAuthClientMetadata,
|
|
||||||
keyset?: Keyset,
|
|
||||||
): asserts metadata is OAuthClientMetadata & {client_id: string} {
|
|
||||||
if (!metadata.client_id) {
|
|
||||||
throw new TypeError('client_id must be provided')
|
|
||||||
}
|
|
||||||
|
|
||||||
const url = new URL(metadata.client_id)
|
|
||||||
if (url.pathname !== '/') {
|
|
||||||
throw new TypeError('origin must be a URL root')
|
|
||||||
}
|
|
||||||
if (url.username || url.password) {
|
|
||||||
throw new TypeError('client_id URI must not contain a username or password')
|
|
||||||
}
|
|
||||||
if (url.search || url.hash) {
|
|
||||||
throw new TypeError('client_id URI must not contain a query or fragment')
|
|
||||||
}
|
|
||||||
if (url.href !== metadata.client_id) {
|
|
||||||
throw new TypeError('client_id URI must be a normalized URL')
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
url.hostname === 'localhost' ||
|
|
||||||
url.hostname === '[::1]' ||
|
|
||||||
url.hostname === '127.0.0.1'
|
|
||||||
) {
|
|
||||||
if (url.protocol !== 'http:' || url.port) {
|
|
||||||
throw new TypeError('loopback clients must use "http:" and port "80"')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (metadata.client_uri && metadata.client_uri !== metadata.client_id) {
|
|
||||||
throw new TypeError('client_uri must match client_id')
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!metadata.redirect_uris.length) {
|
|
||||||
throw new TypeError('At least one redirect_uri must be provided')
|
|
||||||
}
|
|
||||||
for (const u of metadata.redirect_uris) {
|
|
||||||
const redirectUrl = new URL(u)
|
|
||||||
// Loopback redirect_uris require special handling
|
|
||||||
if (
|
|
||||||
redirectUrl.hostname === 'localhost' ||
|
|
||||||
redirectUrl.hostname === '[::1]' ||
|
|
||||||
redirectUrl.hostname === '127.0.0.1'
|
|
||||||
) {
|
|
||||||
if (redirectUrl.protocol !== 'http:') {
|
|
||||||
throw new TypeError('loopback redirect_uris must use "http:"')
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Not a loopback client
|
|
||||||
if (redirectUrl.origin !== url.origin) {
|
|
||||||
throw new TypeError('redirect_uris must have the same origin')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const endpoint of [
|
|
||||||
'token',
|
|
||||||
'revocation',
|
|
||||||
'introspection',
|
|
||||||
'pushed_authorization_request',
|
|
||||||
] as const) {
|
|
||||||
const method = metadata[`${endpoint}_endpoint_auth_method`]
|
|
||||||
if (method && method !== 'none') {
|
|
||||||
if (!keyset) {
|
|
||||||
throw new TypeError(`Keyset is required for ${method} method`)
|
|
||||||
}
|
|
||||||
if (!metadata[`${endpoint}_endpoint_auth_signing_alg`]) {
|
|
||||||
throw new TypeError(
|
|
||||||
`${endpoint}_endpoint_auth_signing_alg must be provided`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
// Code compiled with tsc supports "using" and "await using" syntax. This
|
|
||||||
// features is supported by downleveling the code to ES2017. The downleveling
|
|
||||||
// relies on `Symbol.dispose` and `Symbol.asyncDispose` symbols. These symbols
|
|
||||||
// might not be available in all environments. This package provides a polyfill
|
|
||||||
// for these symbols.
|
|
||||||
|
|
||||||
// @ts-expect-error
|
|
||||||
Symbol.dispose ??= Symbol('@@dispose')
|
|
||||||
// @ts-expect-error
|
|
||||||
Symbol.asyncDispose ??= Symbol('@@asyncDispose')
|
|
||||||
@@ -1,174 +0,0 @@
|
|||||||
import {GenericStore} from '@atproto/caching'
|
|
||||||
import {Fetch} from '@atproto/fetch'
|
|
||||||
|
|
||||||
import {b64uEncode} from '#/oauth-client-temp/b64'
|
|
||||||
import {Key} from '#/oauth-client-temp/jwk'
|
|
||||||
|
|
||||||
export function dpopFetchWrapper({
|
|
||||||
key,
|
|
||||||
iss,
|
|
||||||
alg,
|
|
||||||
// TODO REVIEW POLYFILL
|
|
||||||
// @ts-ignore Polyfilled
|
|
||||||
sha256 = typeof crypto !== 'undefined' && crypto.subtle != null
|
|
||||||
? subtleSha256
|
|
||||||
: undefined,
|
|
||||||
nonceCache,
|
|
||||||
}: {
|
|
||||||
key: Key
|
|
||||||
iss: string
|
|
||||||
alg?: string
|
|
||||||
sha256?: (input: string) => Promise<string>
|
|
||||||
nonceCache?: GenericStore<string, string>
|
|
||||||
fetch?: Fetch
|
|
||||||
}): Fetch {
|
|
||||||
if (!sha256) {
|
|
||||||
throw new Error(
|
|
||||||
`crypto.subtle is not available in this environment. Please provide a sha256 function.`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return async function (request) {
|
|
||||||
return dpopFetch.call(
|
|
||||||
this,
|
|
||||||
request,
|
|
||||||
key,
|
|
||||||
iss,
|
|
||||||
alg,
|
|
||||||
sha256,
|
|
||||||
nonceCache,
|
|
||||||
fetch,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function dpopFetch(
|
|
||||||
this: ThisParameterType<Fetch>,
|
|
||||||
request: Request,
|
|
||||||
key: Key,
|
|
||||||
iss: string,
|
|
||||||
alg: string = key.alg || 'ES256',
|
|
||||||
sha256: (input: string) => string | PromiseLike<string> = subtleSha256,
|
|
||||||
nonceCache?: GenericStore<string, string>,
|
|
||||||
fetch = globalThis.fetch as Fetch,
|
|
||||||
): Promise<Response> {
|
|
||||||
const authorizationHeader = request.headers.get('Authorization')
|
|
||||||
const ath = authorizationHeader?.startsWith('DPoP ')
|
|
||||||
? await sha256(authorizationHeader.slice(5))
|
|
||||||
: undefined
|
|
||||||
|
|
||||||
const {origin} = new URL(request.url)
|
|
||||||
|
|
||||||
// Clone request for potential retry
|
|
||||||
const clonedRequest = request.clone()
|
|
||||||
|
|
||||||
// Try with the previously known nonce
|
|
||||||
const oldNonce = await Promise.resolve()
|
|
||||||
.then(() => nonceCache?.get(origin))
|
|
||||||
.catch(() => undefined) // Ignore cache.get errors
|
|
||||||
|
|
||||||
request.headers.set(
|
|
||||||
'DPoP',
|
|
||||||
await buildProof(key, alg, iss, request.method, request.url, oldNonce, ath),
|
|
||||||
)
|
|
||||||
|
|
||||||
const response = await fetch(request)
|
|
||||||
|
|
||||||
const nonce = response.headers.get('DPoP-Nonce')
|
|
||||||
if (!nonce) return response
|
|
||||||
|
|
||||||
// Store the fresh nonce for future requests
|
|
||||||
try {
|
|
||||||
await nonceCache?.set(origin, nonce)
|
|
||||||
} catch {
|
|
||||||
// Ignore cache.set errors
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!(await isUseDpopNonceError(response))) {
|
|
||||||
return response
|
|
||||||
}
|
|
||||||
|
|
||||||
clonedRequest.headers.set(
|
|
||||||
'DPoP',
|
|
||||||
await buildProof(key, alg, iss, request.method, request.url, nonce, ath),
|
|
||||||
)
|
|
||||||
|
|
||||||
return fetch(clonedRequest)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function buildProof(
|
|
||||||
key: Key,
|
|
||||||
alg: string,
|
|
||||||
iss: string,
|
|
||||||
htm: string,
|
|
||||||
htu: string,
|
|
||||||
nonce?: string,
|
|
||||||
ath?: string,
|
|
||||||
) {
|
|
||||||
if (!key.bareJwk) {
|
|
||||||
throw new Error('Only asymetric keys can be used as DPoP proofs')
|
|
||||||
}
|
|
||||||
|
|
||||||
const now = Math.floor(Date.now() / 1e3)
|
|
||||||
|
|
||||||
return key.createJwt(
|
|
||||||
{
|
|
||||||
alg,
|
|
||||||
typ: 'dpop+jwt',
|
|
||||||
jwk: key.bareJwk,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
iss,
|
|
||||||
iat: now,
|
|
||||||
exp: now + 10,
|
|
||||||
// Any collision will cause the request to be rejected by the server. no biggie.
|
|
||||||
jti: Math.random().toString(36).slice(2),
|
|
||||||
htm,
|
|
||||||
htu,
|
|
||||||
nonce,
|
|
||||||
ath,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function isUseDpopNonceError(response: Response): Promise<boolean> {
|
|
||||||
if (response.status !== 400) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
const ct = response.headers.get('Content-Type')
|
|
||||||
const mime = ct?.split(';')[0]?.trim()
|
|
||||||
if (mime !== 'application/json') {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const body = await response.clone().json()
|
|
||||||
return body?.error === 'use_dpop_nonce'
|
|
||||||
} catch {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function subtleSha256(input: string): Promise<string> {
|
|
||||||
// TODO REVIEW POLYFILL
|
|
||||||
// @ts-ignore Polyfilled
|
|
||||||
if (typeof crypto === 'undefined' || crypto.subtle == null) {
|
|
||||||
throw new Error(
|
|
||||||
`crypto.subtle is not available in this environment. Please provide a sha256 function.`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO REVIEW POLYFILL
|
|
||||||
// @ts-ignore Polyfilled
|
|
||||||
return (
|
|
||||||
// TODO REVIEW POLYFILL
|
|
||||||
// @ts-ignore Polyfilled
|
|
||||||
crypto.subtle
|
|
||||||
// TODO REVIEW POLYFILL
|
|
||||||
// @ts-ignore Polyfilled
|
|
||||||
.digest('SHA-256', new TextEncoder().encode(input))
|
|
||||||
// TODO OAUTH types
|
|
||||||
.then((digest: Iterable<number>) => b64uEncode(new Uint8Array(digest)))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
import { DidResolver } from '@atproto/did'
|
|
||||||
import {
|
|
||||||
HandleResolver,
|
|
||||||
ResolvedHandle,
|
|
||||||
isResolvedHandle,
|
|
||||||
} from '@atproto/handle-resolver'
|
|
||||||
import { normalizeAndEnsureValidHandle } from '@atproto/syntax'
|
|
||||||
|
|
||||||
export type ResolvedIdentity = {
|
|
||||||
did: NonNullable<ResolvedHandle>
|
|
||||||
url: URL
|
|
||||||
}
|
|
||||||
|
|
||||||
export class IdentityResolver {
|
|
||||||
constructor(
|
|
||||||
readonly handleResolver: HandleResolver,
|
|
||||||
readonly didResolver: DidResolver<'plc' | 'web'>,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public async resolve(
|
|
||||||
input: string,
|
|
||||||
serviceType = 'AtprotoPersonalDataServer',
|
|
||||||
): Promise<ResolvedIdentity> {
|
|
||||||
const did = isResolvedHandle(input)
|
|
||||||
? input // Already a did
|
|
||||||
: await this.handleResolver.resolve(normalizeAndEnsureValidHandle(input))
|
|
||||||
if (!did) throw new Error(`Handle ${input} does not resolve to a DID`)
|
|
||||||
|
|
||||||
const url = await this.didResolver.resolveServiceEndpoint(did, {
|
|
||||||
type: serviceType,
|
|
||||||
})
|
|
||||||
|
|
||||||
return { did, url }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
export * from './identity-resolver'
|
|
||||||
export * from './universal-identity-resolver'
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
import {
|
|
||||||
DidCache,
|
|
||||||
IsomorphicDidResolver,
|
|
||||||
IsomorphicDidResolverOptions,
|
|
||||||
} from '@atproto/did'
|
|
||||||
import {Fetch} from '@atproto/fetch'
|
|
||||||
import UniversalHandleResolver, {
|
|
||||||
HandleResolverCache,
|
|
||||||
UniversalHandleResolverOptions,
|
|
||||||
} from '@atproto/handle-resolver'
|
|
||||||
|
|
||||||
import {IdentityResolver} from './identity-resolver'
|
|
||||||
|
|
||||||
export type UniversalIdentityResolverOptions = {
|
|
||||||
fetch?: Fetch
|
|
||||||
|
|
||||||
didCache?: DidCache
|
|
||||||
handleCache?: HandleResolverCache
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @see {@link IsomorphicDidResolverOptions.plcDirectoryUrl}
|
|
||||||
*/
|
|
||||||
plcDirectoryUrl?: IsomorphicDidResolverOptions['plcDirectoryUrl']
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @see {@link UniversalHandleResolverOptions.atprotoLexiconUrl}
|
|
||||||
*/
|
|
||||||
atprotoLexiconUrl?: UniversalHandleResolverOptions['atprotoLexiconUrl']
|
|
||||||
}
|
|
||||||
|
|
||||||
export class UniversalIdentityResolver extends IdentityResolver {
|
|
||||||
static from({
|
|
||||||
fetch = globalThis.fetch,
|
|
||||||
didCache,
|
|
||||||
handleCache,
|
|
||||||
plcDirectoryUrl,
|
|
||||||
atprotoLexiconUrl,
|
|
||||||
}: UniversalIdentityResolverOptions) {
|
|
||||||
return new this(
|
|
||||||
new UniversalHandleResolver({
|
|
||||||
fetch,
|
|
||||||
cache: handleCache,
|
|
||||||
atprotoLexiconUrl,
|
|
||||||
}),
|
|
||||||
new IsomorphicDidResolver({
|
|
||||||
fetch, //
|
|
||||||
cache: didCache,
|
|
||||||
plcDirectoryUrl,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
import {ObjectStoreSchema} from './schema'
|
|
||||||
import {promisify} from './util'
|
|
||||||
|
|
||||||
export class DbIndexWeb<Schema extends ObjectStoreSchema> {
|
|
||||||
constructor(private idbIndex: IDBIndex) {}
|
|
||||||
|
|
||||||
count(query?: IDBValidKey | IDBKeyRange) {
|
|
||||||
return promisify(this.idbIndex.count(query))
|
|
||||||
}
|
|
||||||
|
|
||||||
get(query: IDBValidKey | IDBKeyRange) {
|
|
||||||
return promisify<Schema>(this.idbIndex.get(query))
|
|
||||||
}
|
|
||||||
|
|
||||||
getKey(query: IDBValidKey | IDBKeyRange) {
|
|
||||||
return promisify(this.idbIndex.getKey(query))
|
|
||||||
}
|
|
||||||
|
|
||||||
getAll(query?: IDBValidKey | IDBKeyRange | null, count?: number) {
|
|
||||||
return promisify<Schema[]>(this.idbIndex.getAll(query, count))
|
|
||||||
}
|
|
||||||
|
|
||||||
getAllKeys(query?: IDBValidKey | IDBKeyRange | null, count?: number) {
|
|
||||||
return promisify(this.idbIndex.getAllKeys(query, count))
|
|
||||||
}
|
|
||||||
|
|
||||||
deleteAll(query?: IDBValidKey | IDBKeyRange | null): Promise<void> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const result = this.idbIndex.openCursor(query)
|
|
||||||
result.onsuccess = function (event) {
|
|
||||||
const cursor = (event as any).target.result as IDBCursorWithValue
|
|
||||||
if (cursor) {
|
|
||||||
cursor.delete()
|
|
||||||
cursor.continue()
|
|
||||||
} else {
|
|
||||||
resolve()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
result.onerror = function (event) {
|
|
||||||
reject((event.target as any)?.error || new Error('Unexpected error'))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
import {DbIndexWeb} from './db-index.web'
|
|
||||||
import {ObjectStoreSchema} from './schema'
|
|
||||||
import {promisify} from './util'
|
|
||||||
|
|
||||||
export class DbObjectStoreWeb<Schema extends ObjectStoreSchema> {
|
|
||||||
constructor(private idbObjStore: IDBObjectStore) {}
|
|
||||||
|
|
||||||
get name() {
|
|
||||||
return this.idbObjStore.name
|
|
||||||
}
|
|
||||||
|
|
||||||
index(name: string) {
|
|
||||||
return new DbIndexWeb<Schema>(this.idbObjStore.index(name))
|
|
||||||
}
|
|
||||||
|
|
||||||
get(key: IDBValidKey | IDBKeyRange) {
|
|
||||||
return promisify<Schema>(this.idbObjStore.get(key))
|
|
||||||
}
|
|
||||||
|
|
||||||
getKey(query: IDBValidKey | IDBKeyRange) {
|
|
||||||
return promisify<IDBValidKey | undefined>(this.idbObjStore.getKey(query))
|
|
||||||
}
|
|
||||||
|
|
||||||
getAll(query?: IDBValidKey | IDBKeyRange | null, count?: number) {
|
|
||||||
return promisify<Schema[]>(this.idbObjStore.getAll(query, count))
|
|
||||||
}
|
|
||||||
|
|
||||||
getAllKeys(query?: IDBValidKey | IDBKeyRange | null, count?: number) {
|
|
||||||
return promisify<IDBValidKey[]>(this.idbObjStore.getAllKeys(query, count))
|
|
||||||
}
|
|
||||||
|
|
||||||
add(value: Schema, key?: IDBValidKey) {
|
|
||||||
return promisify(this.idbObjStore.add(value, key))
|
|
||||||
}
|
|
||||||
|
|
||||||
put(value: Schema, key?: IDBValidKey) {
|
|
||||||
return promisify(this.idbObjStore.put(value, key))
|
|
||||||
}
|
|
||||||
|
|
||||||
delete(key: IDBValidKey | IDBKeyRange) {
|
|
||||||
return promisify(this.idbObjStore.delete(key))
|
|
||||||
}
|
|
||||||
|
|
||||||
clear() {
|
|
||||||
return promisify(this.idbObjStore.clear())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
import {DbObjectStoreWeb} from './db-object-store.web'
|
|
||||||
import {DatabaseSchema} from './schema'
|
|
||||||
|
|
||||||
export class DbTransactionWeb<Schema extends DatabaseSchema>
|
|
||||||
implements Disposable
|
|
||||||
{
|
|
||||||
#tx: IDBTransaction | null
|
|
||||||
|
|
||||||
constructor(tx: IDBTransaction) {
|
|
||||||
this.#tx = tx
|
|
||||||
|
|
||||||
const onAbort = () => {
|
|
||||||
cleanup()
|
|
||||||
}
|
|
||||||
const onComplete = () => {
|
|
||||||
cleanup()
|
|
||||||
}
|
|
||||||
const cleanup = () => {
|
|
||||||
this.#tx = null
|
|
||||||
tx.removeEventListener('abort', onAbort)
|
|
||||||
tx.removeEventListener('complete', onComplete)
|
|
||||||
}
|
|
||||||
tx.addEventListener('abort', onAbort)
|
|
||||||
tx.addEventListener('complete', onComplete)
|
|
||||||
}
|
|
||||||
|
|
||||||
protected get tx(): IDBTransaction {
|
|
||||||
if (!this.#tx) throw new Error('Transaction already ended')
|
|
||||||
return this.#tx
|
|
||||||
}
|
|
||||||
|
|
||||||
async abort() {
|
|
||||||
const {tx} = this
|
|
||||||
this.#tx = null
|
|
||||||
tx.abort()
|
|
||||||
}
|
|
||||||
|
|
||||||
async commit() {
|
|
||||||
const {tx} = this
|
|
||||||
this.#tx = null
|
|
||||||
tx.commit?.()
|
|
||||||
}
|
|
||||||
|
|
||||||
objectStore<T extends keyof Schema & string>(name: T) {
|
|
||||||
const store = this.tx.objectStore(name)
|
|
||||||
return new DbObjectStoreWeb<Schema[T]>(store)
|
|
||||||
}
|
|
||||||
|
|
||||||
[Symbol.dispose](): void {
|
|
||||||
if (this.#tx) this.commit()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
import {DatabaseSchema} from '#/oauth-client-temp/indexed-db/schema'
|
|
||||||
|
|
||||||
export class Db<Schema extends DatabaseSchema> implements Disposable {
|
|
||||||
static async open<Schema extends DatabaseSchema = DatabaseSchema>(
|
|
||||||
dbname: string,
|
|
||||||
migrations: ReadonlyArray<(db: IDBDatabase) => void>,
|
|
||||||
txOptions?: IDBTransactionOptions,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
import {DbTransactionWeb} from './db-transaction.web'
|
|
||||||
import {DatabaseSchema} from './schema'
|
|
||||||
|
|
||||||
export class Db<Schema extends DatabaseSchema> implements Disposable {
|
|
||||||
static async open<Schema extends DatabaseSchema = DatabaseSchema>(
|
|
||||||
dbName: string,
|
|
||||||
migrations: ReadonlyArray<(db: IDBDatabase) => void>,
|
|
||||||
txOptions?: IDBTransactionOptions,
|
|
||||||
) {
|
|
||||||
const db = await new Promise<IDBDatabase>((resolve, reject) => {
|
|
||||||
const request = indexedDB.open(dbName, migrations.length)
|
|
||||||
|
|
||||||
request.onerror = () => reject(request.error)
|
|
||||||
request.onsuccess = () => resolve(request.result)
|
|
||||||
request.onupgradeneeded = ({oldVersion, newVersion}) => {
|
|
||||||
const db = request.result
|
|
||||||
try {
|
|
||||||
for (
|
|
||||||
let version = oldVersion;
|
|
||||||
version < (newVersion ?? migrations.length);
|
|
||||||
++version
|
|
||||||
) {
|
|
||||||
const migration = migrations[version]
|
|
||||||
if (migration) migration(db)
|
|
||||||
else throw new Error(`Missing migration for version ${version}`)
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
db.close()
|
|
||||||
reject(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return new DbWeb<Schema>(db, txOptions)
|
|
||||||
}
|
|
||||||
|
|
||||||
#db: null | IDBDatabase
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
db: IDBDatabase,
|
|
||||||
protected readonly txOptions?: IDBTransactionOptions,
|
|
||||||
) {
|
|
||||||
this.#db = db
|
|
||||||
|
|
||||||
const cleanup = () => {
|
|
||||||
this.#db = null
|
|
||||||
db.removeEventListener('versionchange', cleanup)
|
|
||||||
db.removeEventListener('close', cleanup)
|
|
||||||
db.close() // Can we call close on a "closed" database?
|
|
||||||
}
|
|
||||||
|
|
||||||
db.addEventListener('versionchange', cleanup)
|
|
||||||
db.addEventListener('close', cleanup)
|
|
||||||
}
|
|
||||||
|
|
||||||
protected get db(): IDBDatabase {
|
|
||||||
if (!this.#db) throw new Error('Database closed')
|
|
||||||
return this.#db
|
|
||||||
}
|
|
||||||
|
|
||||||
get name() {
|
|
||||||
return this.db.name
|
|
||||||
}
|
|
||||||
|
|
||||||
get objectStoreNames() {
|
|
||||||
return this.db.objectStoreNames
|
|
||||||
}
|
|
||||||
|
|
||||||
get version() {
|
|
||||||
return this.db.version
|
|
||||||
}
|
|
||||||
|
|
||||||
async transaction<T extends readonly (keyof Schema & string)[], R>(
|
|
||||||
storeNames: T,
|
|
||||||
mode: IDBTransactionMode,
|
|
||||||
run: (tx: DbTransactionWeb<Pick<Schema, T[number]>>) => R | PromiseLike<R>,
|
|
||||||
): Promise<R> {
|
|
||||||
return new Promise<R>(async (resolve, reject) => {
|
|
||||||
try {
|
|
||||||
const tx = this.db.transaction(storeNames, mode, this.txOptions)
|
|
||||||
let result: {done: false} | {done: true; value: R} = {done: false}
|
|
||||||
|
|
||||||
tx.oncomplete = () => {
|
|
||||||
if (result.done) resolve(result.value)
|
|
||||||
else reject(new Error('Transaction completed without result'))
|
|
||||||
}
|
|
||||||
tx.onerror = () => reject(tx.error)
|
|
||||||
tx.onabort = () => reject(tx.error || new Error('Transaction aborted'))
|
|
||||||
|
|
||||||
try {
|
|
||||||
const value = await run(new DbTransactionWeb(tx))
|
|
||||||
result = {done: true, value}
|
|
||||||
tx.commit()
|
|
||||||
} catch (err) {
|
|
||||||
tx.abort()
|
|
||||||
throw err
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
reject(err)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
close() {
|
|
||||||
const {db} = this
|
|
||||||
this.#db = null
|
|
||||||
db.close()
|
|
||||||
}
|
|
||||||
|
|
||||||
[Symbol.dispose]() {
|
|
||||||
if (this.#db) return this.close()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
import '#/oauth-client-temp/disposable-polyfill'
|
|
||||||
|
|
||||||
export * from './db.web'
|
|
||||||
export * from './db.web'
|
|
||||||
export * from './db-index.web'
|
|
||||||
export * from './db-object-store.web'
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
export type ObjectStoreSchema = NonNullable<unknown>
|
|
||||||
export type DatabaseSchema = Record<string, ObjectStoreSchema>
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
export function promisify<T>(request: IDBRequest<T>) {
|
|
||||||
const promise = new Promise<T>((resolve, reject) => {
|
|
||||||
const cleanup = () => {
|
|
||||||
request.removeEventListener('success', success)
|
|
||||||
request.removeEventListener('error', error)
|
|
||||||
}
|
|
||||||
const success = () => {
|
|
||||||
resolve(request.result)
|
|
||||||
cleanup()
|
|
||||||
}
|
|
||||||
const error = () => {
|
|
||||||
reject(request.error)
|
|
||||||
cleanup()
|
|
||||||
}
|
|
||||||
request.addEventListener('success', success)
|
|
||||||
request.addEventListener('error', error)
|
|
||||||
})
|
|
||||||
|
|
||||||
return promise
|
|
||||||
}
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
export * from './jose-key'
|
|
||||||
export * from './jose-keyset'
|
|
||||||
@@ -1,115 +0,0 @@
|
|||||||
import {
|
|
||||||
exportJWK,
|
|
||||||
importJWK,
|
|
||||||
importPKCS8,
|
|
||||||
JWK,
|
|
||||||
jwtVerify,
|
|
||||||
JWTVerifyOptions,
|
|
||||||
KeyLike,
|
|
||||||
SignJWT,
|
|
||||||
} from 'jose'
|
|
||||||
|
|
||||||
import {
|
|
||||||
Jwk,
|
|
||||||
jwkSchema,
|
|
||||||
Jwt,
|
|
||||||
JwtHeader,
|
|
||||||
JwtPayload,
|
|
||||||
Key,
|
|
||||||
VerifyOptions,
|
|
||||||
VerifyPayload,
|
|
||||||
VerifyResult,
|
|
||||||
} from '#/oauth-client-temp/jwk'
|
|
||||||
import {either} from './util'
|
|
||||||
|
|
||||||
export type Importable = string | KeyLike | Jwk
|
|
||||||
|
|
||||||
export class JoseKey extends Key {
|
|
||||||
#keyObj?: KeyLike | Uint8Array
|
|
||||||
|
|
||||||
protected async getKey() {
|
|
||||||
return (this.#keyObj ||= await importJWK(this.jwk as JWK))
|
|
||||||
}
|
|
||||||
|
|
||||||
async createJwt(header: JwtHeader, payload: JwtPayload) {
|
|
||||||
if (header.kid && header.kid !== this.kid) {
|
|
||||||
throw new TypeError(
|
|
||||||
`Invalid "kid" (${header.kid}) used to sign with key "${this.kid}"`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!header.alg || !this.algorithms.includes(header.alg)) {
|
|
||||||
throw new TypeError(
|
|
||||||
`Invalid "alg" (${header.alg}) used to sign with key "${this.kid}"`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const keyObj = await this.getKey()
|
|
||||||
return new SignJWT(payload)
|
|
||||||
.setProtectedHeader({...header, kid: this.kid})
|
|
||||||
.sign(keyObj) as Promise<Jwt>
|
|
||||||
}
|
|
||||||
|
|
||||||
async verifyJwt<
|
|
||||||
P extends VerifyPayload = JwtPayload,
|
|
||||||
C extends string = string,
|
|
||||||
>(token: Jwt, options?: VerifyOptions<C>): Promise<VerifyResult<P, C>> {
|
|
||||||
const keyObj = await this.getKey()
|
|
||||||
const result = await jwtVerify(token, keyObj, {
|
|
||||||
...options,
|
|
||||||
algorithms: this.algorithms,
|
|
||||||
} as JWTVerifyOptions)
|
|
||||||
return result as VerifyResult<P, C>
|
|
||||||
}
|
|
||||||
|
|
||||||
static async fromImportable(
|
|
||||||
input: Importable,
|
|
||||||
kid?: string,
|
|
||||||
): Promise<JoseKey> {
|
|
||||||
if (typeof input === 'string') {
|
|
||||||
// PKCS8
|
|
||||||
if (input.startsWith('-----')) {
|
|
||||||
return this.fromPKCS8(input, kid)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Jwk (string)
|
|
||||||
if (input.startsWith('{')) {
|
|
||||||
return this.fromJWK(input, kid)
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new TypeError('Invalid input')
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof input === 'object') {
|
|
||||||
// Jwk
|
|
||||||
if ('kty' in input || 'alg' in input) {
|
|
||||||
return this.fromJWK(input, kid)
|
|
||||||
}
|
|
||||||
|
|
||||||
// KeyLike
|
|
||||||
return this.fromJWK(await exportJWK(input), kid)
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new TypeError('Invalid input')
|
|
||||||
}
|
|
||||||
|
|
||||||
static async fromPKCS8(pem: string, kid?: string): Promise<JoseKey> {
|
|
||||||
const keyLike = await importPKCS8(pem, '', {extractable: true})
|
|
||||||
return this.fromJWK(await exportJWK(keyLike), kid)
|
|
||||||
}
|
|
||||||
|
|
||||||
static async fromJWK(
|
|
||||||
input: string | Record<string, unknown>,
|
|
||||||
inputKid?: string,
|
|
||||||
): Promise<JoseKey> {
|
|
||||||
const jwk = jwkSchema.parse(
|
|
||||||
typeof input === 'string' ? JSON.parse(input) : input,
|
|
||||||
)
|
|
||||||
|
|
||||||
const kid = either(jwk.kid, inputKid)
|
|
||||||
const alg = jwk.alg
|
|
||||||
const use = jwk.use || 'sig'
|
|
||||||
|
|
||||||
return new JoseKey({...jwk, kid, alg, use})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
import {Key, Keyset} from '#/oauth-client-temp/jwk'
|
|
||||||
import {Importable, JoseKey} from './jose-key'
|
|
||||||
|
|
||||||
export class JoseKeyset<K extends Key = Key> extends Keyset<K> {
|
|
||||||
static async fromImportables<K extends Key = JoseKey>(
|
|
||||||
input: Record<string, K | Importable>,
|
|
||||||
) {
|
|
||||||
return new JoseKeyset(
|
|
||||||
await Promise.all(
|
|
||||||
Object.entries(input).map(([kid, secret]) =>
|
|
||||||
secret instanceof Key ? secret : JoseKey.fromImportable(secret, kid),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
export function either<T extends string | number | boolean>(
|
|
||||||
a?: T,
|
|
||||||
b?: T,
|
|
||||||
): T | undefined {
|
|
||||||
if (a != null && b != null && a !== b) {
|
|
||||||
throw new TypeError(`Expected "${b}", got "${a}"`)
|
|
||||||
}
|
|
||||||
return a ?? b ?? undefined
|
|
||||||
}
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
import {Jwk} from './jwk'
|
|
||||||
|
|
||||||
declare const process: undefined | {versions?: {node?: string}}
|
|
||||||
const IS_NODE_RUNTIME =
|
|
||||||
typeof process !== 'undefined' && typeof process?.versions?.node === 'string'
|
|
||||||
|
|
||||||
export function* jwkAlgorithms(jwk: Jwk): Generator<string> {
|
|
||||||
// Ed25519, Ed448, and secp256k1 always have "alg"
|
|
||||||
// OKP always has "use"
|
|
||||||
if (jwk.alg) {
|
|
||||||
yield jwk.alg
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (jwk.kty) {
|
|
||||||
case 'EC': {
|
|
||||||
if (jwk.use === 'enc' || jwk.use === undefined) {
|
|
||||||
yield 'ECDH-ES'
|
|
||||||
yield 'ECDH-ES+A128KW'
|
|
||||||
yield 'ECDH-ES+A192KW'
|
|
||||||
yield 'ECDH-ES+A256KW'
|
|
||||||
}
|
|
||||||
|
|
||||||
if (jwk.use === 'sig' || jwk.use === undefined) {
|
|
||||||
const crv = 'crv' in jwk ? jwk.crv : undefined
|
|
||||||
switch (crv) {
|
|
||||||
case 'P-256':
|
|
||||||
case 'P-384':
|
|
||||||
yield `ES${crv.slice(-3)}`.replace('21', '12')
|
|
||||||
break
|
|
||||||
case 'P-521':
|
|
||||||
yield 'ES512'
|
|
||||||
break
|
|
||||||
case 'secp256k1':
|
|
||||||
if (IS_NODE_RUNTIME) yield 'ES256K'
|
|
||||||
break
|
|
||||||
default:
|
|
||||||
throw new TypeError(`Unsupported crv "${crv}"`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'OKP': {
|
|
||||||
if (!jwk.use) throw new TypeError('Missing "use" Parameter value')
|
|
||||||
yield 'ECDH-ES'
|
|
||||||
yield 'ECDH-ES+A128KW'
|
|
||||||
yield 'ECDH-ES+A192KW'
|
|
||||||
yield 'ECDH-ES+A256KW'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'RSA': {
|
|
||||||
if (jwk.use === 'enc' || jwk.use === undefined) {
|
|
||||||
yield 'RSA-OAEP'
|
|
||||||
yield 'RSA-OAEP-256'
|
|
||||||
yield 'RSA-OAEP-384'
|
|
||||||
yield 'RSA-OAEP-512'
|
|
||||||
if (IS_NODE_RUNTIME) yield 'RSA1_5'
|
|
||||||
}
|
|
||||||
|
|
||||||
if (jwk.use === 'sig' || jwk.use === undefined) {
|
|
||||||
yield 'PS256'
|
|
||||||
yield 'PS384'
|
|
||||||
yield 'PS512'
|
|
||||||
yield 'RS256'
|
|
||||||
yield 'RS384'
|
|
||||||
yield 'RS512'
|
|
||||||
}
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'oct': {
|
|
||||||
if (jwk.use === 'enc' || jwk.use === undefined) {
|
|
||||||
yield 'A128GCMKW'
|
|
||||||
yield 'A192GCMKW'
|
|
||||||
yield 'A256GCMKW'
|
|
||||||
yield 'A128KW'
|
|
||||||
yield 'A192KW'
|
|
||||||
yield 'A256KW'
|
|
||||||
}
|
|
||||||
|
|
||||||
if (jwk.use === 'sig' || jwk.use === undefined) {
|
|
||||||
yield 'HS256'
|
|
||||||
yield 'HS384'
|
|
||||||
yield 'HS512'
|
|
||||||
}
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
default:
|
|
||||||
throw new Error(`Unsupported kty "${jwk.kty}"`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
export * from './alg'
|
|
||||||
export * from './jwk'
|
|
||||||
export * from './jwks'
|
|
||||||
export * from './jwt'
|
|
||||||
export * from './jwt-decode'
|
|
||||||
export * from './jwt-verify'
|
|
||||||
export * from './key'
|
|
||||||
export * from './keyset'
|
|
||||||
export * from './util'
|
|
||||||
@@ -1,153 +0,0 @@
|
|||||||
import { z } from 'zod'
|
|
||||||
|
|
||||||
export const keyUsageSchema = z.enum([
|
|
||||||
'sign',
|
|
||||||
'verify',
|
|
||||||
'encrypt',
|
|
||||||
'decrypt',
|
|
||||||
'wrapKey',
|
|
||||||
'unwrapKey',
|
|
||||||
'deriveKey',
|
|
||||||
'deriveBits',
|
|
||||||
])
|
|
||||||
|
|
||||||
export type KeyUsage = z.infer<typeof keyUsageSchema>
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The "use" and "key_ops" JWK members SHOULD NOT be used together;
|
|
||||||
* however, if both are used, the information they convey MUST be
|
|
||||||
* consistent. Applications should specify which of these members they
|
|
||||||
* use, if either is to be used by the application.
|
|
||||||
*
|
|
||||||
* @todo Actually check that "use" and "key_ops" are consistent when both are present.
|
|
||||||
* @see {@link https://datatracker.ietf.org/doc/html/rfc7517#section-4.3}
|
|
||||||
*/
|
|
||||||
export const jwkBaseSchema = z.object({
|
|
||||||
kty: z.string().min(1),
|
|
||||||
alg: z.string().min(1).optional(),
|
|
||||||
kid: z.string().min(1).optional(),
|
|
||||||
ext: z.boolean().optional(),
|
|
||||||
use: z.enum(['sig', 'enc']).optional(),
|
|
||||||
key_ops: z.array(keyUsageSchema).readonly().optional(),
|
|
||||||
|
|
||||||
x5c: z.array(z.string()).readonly().optional(), // X.509 Certificate Chain
|
|
||||||
x5t: z.string().min(1).optional(), // X.509 Certificate SHA-1 Thumbprint
|
|
||||||
'x5t#S256': z.string().min(1).optional(), // X.509 Certificate SHA-256 Thumbprint
|
|
||||||
x5u: z.string().url().optional(), // X.509 URL
|
|
||||||
})
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @todo: properly implement this
|
|
||||||
*/
|
|
||||||
export const jwkRsaKeySchema = jwkBaseSchema
|
|
||||||
.extend({
|
|
||||||
kty: z.literal('RSA'),
|
|
||||||
alg: z
|
|
||||||
.enum(['RS256', 'RS384', 'RS512', 'PS256', 'PS384', 'PS512'])
|
|
||||||
.optional(),
|
|
||||||
|
|
||||||
n: z.string().min(1), // Modulus
|
|
||||||
e: z.string().min(1), // Exponent
|
|
||||||
|
|
||||||
d: z.string().min(1).optional(), // Private Exponent
|
|
||||||
p: z.string().min(1).optional(), // First Prime Factor
|
|
||||||
q: z.string().min(1).optional(), // Second Prime Factor
|
|
||||||
dp: z.string().min(1).optional(), // First Factor CRT Exponent
|
|
||||||
dq: z.string().min(1).optional(), // Second Factor CRT Exponent
|
|
||||||
qi: z.string().min(1).optional(), // First CRT Coefficient
|
|
||||||
oth: z
|
|
||||||
.array(
|
|
||||||
z
|
|
||||||
.object({
|
|
||||||
r: z.string().optional(),
|
|
||||||
d: z.string().optional(),
|
|
||||||
t: z.string().optional(),
|
|
||||||
})
|
|
||||||
.readonly(),
|
|
||||||
)
|
|
||||||
.nonempty()
|
|
||||||
.readonly()
|
|
||||||
.optional(), // Other Primes Info
|
|
||||||
})
|
|
||||||
.readonly()
|
|
||||||
|
|
||||||
export const jwkEcKeySchema = jwkBaseSchema
|
|
||||||
.extend({
|
|
||||||
kty: z.literal('EC'),
|
|
||||||
alg: z.enum(['ES256', 'ES384', 'ES512']).optional(),
|
|
||||||
crv: z.enum(['P-256', 'P-384', 'P-521']),
|
|
||||||
|
|
||||||
x: z.string().min(1),
|
|
||||||
y: z.string().min(1),
|
|
||||||
|
|
||||||
d: z.string().min(1).optional(), // ECC Private Key
|
|
||||||
})
|
|
||||||
.readonly()
|
|
||||||
|
|
||||||
export const jwkEcSecp256k1KeySchema = jwkBaseSchema
|
|
||||||
.extend({
|
|
||||||
kty: z.literal('EC'),
|
|
||||||
alg: z.enum(['ES256K']).optional(),
|
|
||||||
crv: z.enum(['secp256k1']),
|
|
||||||
|
|
||||||
x: z.string().min(1),
|
|
||||||
y: z.string().min(1),
|
|
||||||
|
|
||||||
d: z.string().min(1).optional(), // ECC Private Key
|
|
||||||
})
|
|
||||||
.readonly()
|
|
||||||
|
|
||||||
export const jwkOkpKeySchema = jwkBaseSchema
|
|
||||||
.extend({
|
|
||||||
kty: z.literal('OKP'),
|
|
||||||
alg: z.enum(['EdDSA']).optional(),
|
|
||||||
crv: z.enum(['Ed25519', 'Ed448']),
|
|
||||||
|
|
||||||
x: z.string().min(1),
|
|
||||||
d: z.string().min(1).optional(), // ECC Private Key
|
|
||||||
})
|
|
||||||
.readonly()
|
|
||||||
|
|
||||||
export const jwkSymKeySchema = jwkBaseSchema
|
|
||||||
.extend({
|
|
||||||
kty: z.literal('oct'), // Octet Sequence (used to represent symmetric keys)
|
|
||||||
alg: z.enum(['HS256', 'HS384', 'HS512']).optional(),
|
|
||||||
|
|
||||||
k: z.string(), // Key Value (base64url encoded)
|
|
||||||
})
|
|
||||||
.readonly()
|
|
||||||
|
|
||||||
export const jwkUnknownKeySchema = jwkBaseSchema
|
|
||||||
.extend({
|
|
||||||
kty: z
|
|
||||||
.string()
|
|
||||||
.refine((v) => v !== 'RSA' && v !== 'EC' && v !== 'OKP' && v !== 'oct'),
|
|
||||||
})
|
|
||||||
.readonly()
|
|
||||||
|
|
||||||
export const jwkSchema = z.union([
|
|
||||||
jwkUnknownKeySchema,
|
|
||||||
jwkRsaKeySchema,
|
|
||||||
jwkEcKeySchema,
|
|
||||||
jwkEcSecp256k1KeySchema,
|
|
||||||
jwkOkpKeySchema,
|
|
||||||
jwkSymKeySchema,
|
|
||||||
])
|
|
||||||
|
|
||||||
export type Jwk = z.infer<typeof jwkSchema>
|
|
||||||
|
|
||||||
export const jwkPubSchema = jwkSchema
|
|
||||||
.refine((k) => k.kid != null, 'kid is required')
|
|
||||||
.refine((k) => k.use != null || k.key_ops != null, 'use or key_ops required')
|
|
||||||
.refine(
|
|
||||||
(k) =>
|
|
||||||
!k.use ||
|
|
||||||
!k.key_ops ||
|
|
||||||
k.key_ops.every((o) =>
|
|
||||||
k.use === 'sig'
|
|
||||||
? o === 'sign' || o === 'verify'
|
|
||||||
: o === 'encrypt' || o === 'decrypt',
|
|
||||||
),
|
|
||||||
'use and key_ops must be consistent',
|
|
||||||
)
|
|
||||||
.refine((k) => !('k' in k) && !('d' in k), 'private key not allowed')
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
import {z} from 'zod'
|
|
||||||
|
|
||||||
import {jwkPubSchema, jwkSchema} from './jwk'
|
|
||||||
|
|
||||||
export const jwksSchema = z
|
|
||||||
.object({
|
|
||||||
keys: z.array(jwkSchema).readonly(),
|
|
||||||
})
|
|
||||||
.readonly()
|
|
||||||
|
|
||||||
export type Jwks = z.infer<typeof jwksSchema>
|
|
||||||
|
|
||||||
export const jwksPubSchema = z
|
|
||||||
.object({
|
|
||||||
keys: z.array(jwkPubSchema).readonly(),
|
|
||||||
})
|
|
||||||
.readonly()
|
|
||||||
|
|
||||||
export type JwksPub = z.infer<typeof jwksPubSchema>
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
import {b64uDecode} from '#/oauth-client-temp/b64'
|
|
||||||
import {JwtHeader, jwtHeaderSchema, JwtPayload, jwtPayloadSchema} from './jwt'
|
|
||||||
import {ui8ToString} from './util'
|
|
||||||
|
|
||||||
export function unsafeDecodeJwt(jwt: string): {
|
|
||||||
header: JwtHeader
|
|
||||||
payload: JwtPayload
|
|
||||||
} {
|
|
||||||
const {0: headerEnc, 1: payloadEnc, length} = jwt.split('.')
|
|
||||||
if (length > 3 || length < 2) {
|
|
||||||
throw new TypeError('invalid JWT input')
|
|
||||||
}
|
|
||||||
|
|
||||||
const header = jwtHeaderSchema.parse(
|
|
||||||
JSON.parse(ui8ToString(b64uDecode(headerEnc!))),
|
|
||||||
)
|
|
||||||
if (length === 2 && header?.alg !== 'none') {
|
|
||||||
throw new TypeError('invalid JWT input')
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload = jwtPayloadSchema.parse(
|
|
||||||
JSON.parse(ui8ToString(b64uDecode(payloadEnc!))),
|
|
||||||
)
|
|
||||||
|
|
||||||
return {header, payload}
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
import {JwtHeader, JwtPayload} from './jwt'
|
|
||||||
import {RequiredKey} from './util'
|
|
||||||
|
|
||||||
export type VerifyOptions<C extends string = string> = {
|
|
||||||
audience?: string | readonly string[]
|
|
||||||
clockTolerance?: string | number
|
|
||||||
issuer?: string | readonly string[]
|
|
||||||
maxTokenAge?: string | number
|
|
||||||
subject?: string
|
|
||||||
typ?: string
|
|
||||||
currentDate?: Date
|
|
||||||
requiredClaims?: readonly C[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export type VerifyPayload = Record<string, unknown>
|
|
||||||
|
|
||||||
export type VerifyResult<P extends VerifyPayload, C extends string> = {
|
|
||||||
payload: RequiredKey<P & JwtPayload, C>
|
|
||||||
protectedHeader: JwtHeader
|
|
||||||
}
|
|
||||||
@@ -1,172 +0,0 @@
|
|||||||
import {z} from 'zod'
|
|
||||||
|
|
||||||
import {jwkPubSchema} from './jwk'
|
|
||||||
|
|
||||||
export const JWT_REGEXP = /^[A-Za-z0-9_-]{2,}(?:\.[A-Za-z0-9_-]{2,}){1,2}$/
|
|
||||||
export const jwtSchema = z
|
|
||||||
.string()
|
|
||||||
.min(5)
|
|
||||||
.refinement(
|
|
||||||
(data: string): data is `${string}.${string}.${string}` =>
|
|
||||||
JWT_REGEXP.test(data),
|
|
||||||
{
|
|
||||||
code: z.ZodIssueCode.custom,
|
|
||||||
message: 'Must be a JWT',
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
export const isJwt = (data: unknown): data is Jwt =>
|
|
||||||
jwtSchema.safeParse(data).success
|
|
||||||
|
|
||||||
export type Jwt = z.infer<typeof jwtSchema>
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @see {@link https://www.rfc-editor.org/rfc/rfc7515.html#section-4}
|
|
||||||
*/
|
|
||||||
export const jwtHeaderSchema = z.object({
|
|
||||||
/** "alg" (Algorithm) Header Parameter */
|
|
||||||
alg: z.string(),
|
|
||||||
/** "jku" (JWK Set URL) Header Parameter */
|
|
||||||
jku: z.string().url().optional(),
|
|
||||||
/** "jwk" (JSON Web Key) Header Parameter */
|
|
||||||
jwk: z
|
|
||||||
.object({
|
|
||||||
kty: z.string(),
|
|
||||||
crv: z.string().optional(),
|
|
||||||
x: z.string().optional(),
|
|
||||||
y: z.string().optional(),
|
|
||||||
e: z.string().optional(),
|
|
||||||
n: z.string().optional(),
|
|
||||||
})
|
|
||||||
.optional(),
|
|
||||||
/** "kid" (Key ID) Header Parameter */
|
|
||||||
kid: z.string().optional(),
|
|
||||||
/** "x5u" (X.509 URL) Header Parameter */
|
|
||||||
x5u: z.string().optional(),
|
|
||||||
/** "x5c" (X.509 Certificate Chain) Header Parameter */
|
|
||||||
x5c: z.array(z.string()).optional(),
|
|
||||||
/** "x5t" (X.509 Certificate SHA-1 Thumbprint) Header Parameter */
|
|
||||||
x5t: z.string().optional(),
|
|
||||||
/** "x5t#S256" (X.509 Certificate SHA-256 Thumbprint) Header Parameter */
|
|
||||||
'x5t#S256': z.string().optional(),
|
|
||||||
/** "typ" (Type) Header Parameter */
|
|
||||||
typ: z.string().optional(),
|
|
||||||
/** "cty" (Content Type) Header Parameter */
|
|
||||||
cty: z.string().optional(),
|
|
||||||
/** "crit" (Critical) Header Parameter */
|
|
||||||
crit: z.array(z.string()).optional(),
|
|
||||||
})
|
|
||||||
|
|
||||||
export type JwtHeader = z.infer<typeof jwtHeaderSchema>
|
|
||||||
|
|
||||||
// https://www.iana.org/assignments/jwt/jwt.xhtml
|
|
||||||
export const jwtPayloadSchema = z.object({
|
|
||||||
iss: z.string().optional(),
|
|
||||||
aud: z.union([z.string(), z.array(z.string()).nonempty()]).optional(),
|
|
||||||
sub: z.string().optional(),
|
|
||||||
exp: z.number().int().optional(),
|
|
||||||
nbf: z.number().int().optional(),
|
|
||||||
iat: z.number().int().optional(),
|
|
||||||
jti: z.string().optional(),
|
|
||||||
htm: z.string().optional(),
|
|
||||||
htu: z.string().optional(),
|
|
||||||
ath: z.string().optional(),
|
|
||||||
acr: z.string().optional(),
|
|
||||||
azp: z.string().optional(),
|
|
||||||
amr: z.array(z.string()).optional(),
|
|
||||||
// https://datatracker.ietf.org/doc/html/rfc7800
|
|
||||||
cnf: z
|
|
||||||
.object({
|
|
||||||
kid: z.string().optional(), // Key ID
|
|
||||||
jwk: jwkPubSchema.optional(), // JWK
|
|
||||||
jwe: z.string().optional(), // Encrypted key
|
|
||||||
jku: z.string().url().optional(), // JWK Set URI ("kid" should also be provided)
|
|
||||||
|
|
||||||
// https://datatracker.ietf.org/doc/html/rfc9449#section-6.1
|
|
||||||
jkt: z.string().optional(),
|
|
||||||
|
|
||||||
// https://datatracker.ietf.org/doc/html/rfc8705
|
|
||||||
'x5t#S256': z.string().optional(), // X.509 Certificate SHA-256 Thumbprint
|
|
||||||
|
|
||||||
// https://datatracker.ietf.org/doc/html/rfc9203
|
|
||||||
osc: z.string().optional(), // OSCORE_Input_Material carrying the parameters for using OSCORE per-message security with implicit key confirmation
|
|
||||||
})
|
|
||||||
.optional(),
|
|
||||||
|
|
||||||
client_id: z.string().optional(),
|
|
||||||
|
|
||||||
scope: z.string().optional(),
|
|
||||||
nonce: z.string().optional(),
|
|
||||||
|
|
||||||
at_hash: z.string().optional(),
|
|
||||||
c_hash: z.string().optional(),
|
|
||||||
s_hash: z.string().optional(),
|
|
||||||
auth_time: z.number().int().optional(),
|
|
||||||
|
|
||||||
// https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims
|
|
||||||
|
|
||||||
// OpenID: "profile" scope
|
|
||||||
name: z.string().optional(),
|
|
||||||
family_name: z.string().optional(),
|
|
||||||
given_name: z.string().optional(),
|
|
||||||
middle_name: z.string().optional(),
|
|
||||||
nickname: z.string().optional(),
|
|
||||||
preferred_username: z.string().optional(),
|
|
||||||
gender: z.string().optional(), // OpenID only defines "male" and "female" without forbidding other values
|
|
||||||
picture: z.string().url().optional(),
|
|
||||||
profile: z.string().url().optional(),
|
|
||||||
website: z.string().url().optional(),
|
|
||||||
birthdate: z
|
|
||||||
.string()
|
|
||||||
.regex(/\d{4}-\d{2}-\d{2}/) // YYYY-MM-DD
|
|
||||||
.optional(),
|
|
||||||
zoneinfo: z
|
|
||||||
.string()
|
|
||||||
.regex(/^[A-Za-z0-9_/]+$/)
|
|
||||||
.optional(),
|
|
||||||
locale: z
|
|
||||||
.string()
|
|
||||||
.regex(/^[a-z]{2}(-[A-Z]{2})?$/)
|
|
||||||
.optional(),
|
|
||||||
updated_at: z.number().int().optional(),
|
|
||||||
|
|
||||||
// OpenID: "email" scope
|
|
||||||
email: z.string().optional(),
|
|
||||||
email_verified: z.boolean().optional(),
|
|
||||||
|
|
||||||
// OpenID: "phone" scope
|
|
||||||
phone_number: z.string().optional(),
|
|
||||||
phone_number_verified: z.boolean().optional(),
|
|
||||||
|
|
||||||
// OpenID: "address" scope
|
|
||||||
// https://openid.net/specs/openid-connect-core-1_0.html#AddressClaim
|
|
||||||
address: z
|
|
||||||
.object({
|
|
||||||
formatted: z.string().optional(),
|
|
||||||
street_address: z.string().optional(),
|
|
||||||
locality: z.string().optional(),
|
|
||||||
region: z.string().optional(),
|
|
||||||
postal_code: z.string().optional(),
|
|
||||||
country: z.string().optional(),
|
|
||||||
})
|
|
||||||
.optional(),
|
|
||||||
|
|
||||||
// https://datatracker.ietf.org/doc/html/rfc9396#section-14.2
|
|
||||||
authorization_details: z
|
|
||||||
.array(
|
|
||||||
z
|
|
||||||
.object({
|
|
||||||
type: z.string(),
|
|
||||||
// https://datatracker.ietf.org/doc/html/rfc9396#section-2.2
|
|
||||||
locations: z.array(z.string()).optional(),
|
|
||||||
actions: z.array(z.string()).optional(),
|
|
||||||
datatypes: z.array(z.string()).optional(),
|
|
||||||
identifier: z.string().optional(),
|
|
||||||
privileges: z.array(z.string()).optional(),
|
|
||||||
})
|
|
||||||
.passthrough(),
|
|
||||||
)
|
|
||||||
.optional(),
|
|
||||||
})
|
|
||||||
|
|
||||||
export type JwtPayload = z.infer<typeof jwtPayloadSchema>
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
import {jwkAlgorithms} from './alg'
|
|
||||||
import {Jwk, jwkSchema} from './jwk'
|
|
||||||
import {Jwt, JwtHeader, JwtPayload} from './jwt'
|
|
||||||
import {VerifyOptions, VerifyPayload, VerifyResult} from './jwt-verify'
|
|
||||||
import {cachedGetter} from './util'
|
|
||||||
|
|
||||||
export abstract class Key {
|
|
||||||
constructor(protected jwk: Jwk) {
|
|
||||||
// A key should always be used either for signing or encryption.
|
|
||||||
if (!jwk.use) throw new TypeError('Missing "use" Parameter value')
|
|
||||||
}
|
|
||||||
|
|
||||||
get isPrivate(): boolean {
|
|
||||||
const {jwk} = this
|
|
||||||
if ('d' in jwk && jwk.d !== undefined) return true
|
|
||||||
return this.isSymetric
|
|
||||||
}
|
|
||||||
|
|
||||||
get isSymetric(): boolean {
|
|
||||||
const {jwk} = this
|
|
||||||
if ('k' in jwk && jwk.k !== undefined) return true
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
get privateJwk(): Jwk | undefined {
|
|
||||||
return this.isPrivate ? this.jwk : undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
@cachedGetter
|
|
||||||
get publicJwk(): Jwk | undefined {
|
|
||||||
if (this.isSymetric) return undefined
|
|
||||||
if (this.isPrivate) {
|
|
||||||
const {d: _, ...jwk} = this.jwk as any
|
|
||||||
return jwk
|
|
||||||
}
|
|
||||||
return this.jwk
|
|
||||||
}
|
|
||||||
|
|
||||||
@cachedGetter
|
|
||||||
get bareJwk(): Jwk | undefined {
|
|
||||||
if (this.isSymetric) return undefined
|
|
||||||
const {kty, crv, e, n, x, y} = this.jwk as any
|
|
||||||
return jwkSchema.parse({crv, e, kty, n, x, y})
|
|
||||||
}
|
|
||||||
|
|
||||||
get use() {
|
|
||||||
return this.jwk.use!
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The (forced) algorithm to use. If not provided, the key will be usable with
|
|
||||||
* any of the algorithms in {@link algorithms}.
|
|
||||||
*/
|
|
||||||
get alg() {
|
|
||||||
return this.jwk.alg
|
|
||||||
}
|
|
||||||
|
|
||||||
get kid() {
|
|
||||||
return this.jwk.kid
|
|
||||||
}
|
|
||||||
|
|
||||||
get crv() {
|
|
||||||
return (this.jwk as undefined | Extract<Jwk, {crv: unknown}>)?.crv
|
|
||||||
}
|
|
||||||
|
|
||||||
get canVerify() {
|
|
||||||
return this.use === 'sig'
|
|
||||||
}
|
|
||||||
|
|
||||||
get canSign() {
|
|
||||||
return this.use === 'sig' && this.isPrivate && !this.isSymetric
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* All the algorithms that this key can be used with. If `alg` is provided,
|
|
||||||
* this set will only contain that algorithm.
|
|
||||||
*/
|
|
||||||
@cachedGetter
|
|
||||||
get algorithms(): readonly string[] {
|
|
||||||
return Array.from(jwkAlgorithms(this.jwk))
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a signed JWT
|
|
||||||
*/
|
|
||||||
abstract createJwt(header: JwtHeader, payload: JwtPayload): Promise<Jwt>
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Verify the signature, headers and payload of a JWT
|
|
||||||
*/
|
|
||||||
abstract verifyJwt<
|
|
||||||
P extends VerifyPayload = JwtPayload,
|
|
||||||
C extends string = string,
|
|
||||||
>(token: Jwt, options?: VerifyOptions<C>): Promise<VerifyResult<P, C>>
|
|
||||||
}
|
|
||||||
@@ -1,200 +0,0 @@
|
|||||||
import {Jwk} from './jwk'
|
|
||||||
import {Jwks} from './jwks'
|
|
||||||
import {Jwt, JwtHeader, JwtPayload} from './jwt'
|
|
||||||
import {unsafeDecodeJwt} from './jwt-decode'
|
|
||||||
import {VerifyOptions} from './jwt-verify'
|
|
||||||
import {Key} from './key'
|
|
||||||
import {
|
|
||||||
cachedGetter,
|
|
||||||
isDefined,
|
|
||||||
matchesAny,
|
|
||||||
Override,
|
|
||||||
preferredOrderCmp,
|
|
||||||
} from './util'
|
|
||||||
|
|
||||||
export type JwtSignHeader = Override<JwtHeader, Pick<KeySearch, 'alg' | 'kid'>>
|
|
||||||
|
|
||||||
export type JwtPayloadGetter<P = JwtPayload> = (
|
|
||||||
header: JwtHeader,
|
|
||||||
key: Key,
|
|
||||||
) => P | PromiseLike<P>
|
|
||||||
|
|
||||||
export type KeySearch = {
|
|
||||||
use?: 'sig' | 'enc'
|
|
||||||
kid?: string | string[]
|
|
||||||
alg?: string | string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
const extractPrivateJwk = (key: Key): Jwk | undefined => key.privateJwk
|
|
||||||
const extractPublicJwk = (key: Key): Jwk | undefined => key.publicJwk
|
|
||||||
|
|
||||||
export class Keyset<K extends Key = Key> implements Iterable<K> {
|
|
||||||
constructor(
|
|
||||||
private readonly keys: readonly K[],
|
|
||||||
/**
|
|
||||||
* The preferred algorithms to use when signing a JWT using this keyset.
|
|
||||||
*/
|
|
||||||
readonly preferredSigningAlgorithms: readonly string[] = [
|
|
||||||
'EdDSA',
|
|
||||||
'ES256K',
|
|
||||||
'ES256',
|
|
||||||
// https://datatracker.ietf.org/doc/html/rfc7518#section-3.5
|
|
||||||
'PS256',
|
|
||||||
'PS384',
|
|
||||||
'PS512',
|
|
||||||
'HS256',
|
|
||||||
'HS384',
|
|
||||||
'HS512',
|
|
||||||
],
|
|
||||||
) {
|
|
||||||
if (!keys.length) throw new Error('Keyset is empty')
|
|
||||||
|
|
||||||
const kids = new Set<string>()
|
|
||||||
for (const {kid} of keys) {
|
|
||||||
if (!kid) continue
|
|
||||||
|
|
||||||
if (kids.has(kid)) throw new Error(`Duplicate key id: ${kid}`)
|
|
||||||
else kids.add(kid)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@cachedGetter
|
|
||||||
get signAlgorithms(): readonly string[] {
|
|
||||||
const algorithms = new Set<string>()
|
|
||||||
for (const key of this) {
|
|
||||||
if (key.use !== 'sig') continue
|
|
||||||
for (const alg of key.algorithms) {
|
|
||||||
algorithms.add(alg)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return Object.freeze(
|
|
||||||
[...algorithms].sort(preferredOrderCmp(this.preferredSigningAlgorithms)),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@cachedGetter
|
|
||||||
get publicJwks(): Jwks {
|
|
||||||
return {
|
|
||||||
keys: Array.from(this, extractPublicJwk).filter(isDefined),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@cachedGetter
|
|
||||||
get privateJwks(): Jwks {
|
|
||||||
return {
|
|
||||||
keys: Array.from(this, extractPrivateJwk).filter(isDefined),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
has(kid: string): boolean {
|
|
||||||
return this.keys.some(key => key.kid === kid)
|
|
||||||
}
|
|
||||||
|
|
||||||
get(search: KeySearch): K {
|
|
||||||
for (const key of this.list(search)) {
|
|
||||||
return key
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new TypeError(
|
|
||||||
`Key not found ${search.kid || search.alg || '<unknown>'}`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
*list(search: KeySearch): Generator<K> {
|
|
||||||
// Optimization: Empty string or empty array will not match any key
|
|
||||||
if (search.kid?.length === 0) return
|
|
||||||
if (search.alg?.length === 0) return
|
|
||||||
|
|
||||||
for (const key of this) {
|
|
||||||
if (search.use && key.use !== search.use) continue
|
|
||||||
|
|
||||||
if (Array.isArray(search.kid)) {
|
|
||||||
if (!key.kid || !search.kid.includes(key.kid)) continue
|
|
||||||
} else if (search.kid) {
|
|
||||||
if (key.kid !== search.kid) continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Array.isArray(search.alg)) {
|
|
||||||
if (!search.alg.some(a => key.algorithms.includes(a))) continue
|
|
||||||
} else if (typeof search.alg === 'string') {
|
|
||||||
if (!key.algorithms.includes(search.alg)) continue
|
|
||||||
}
|
|
||||||
|
|
||||||
yield key
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
findSigningKey(search: Omit<KeySearch, 'use'>): [key: Key, alg: string] {
|
|
||||||
const {kid, alg} = search
|
|
||||||
const matchingKeys: Key[] = []
|
|
||||||
|
|
||||||
for (const key of this.list({kid, alg, use: 'sig'})) {
|
|
||||||
// Not a signing key
|
|
||||||
if (!key.canSign) continue
|
|
||||||
|
|
||||||
// Skip negotiation if a specific "alg" was provided
|
|
||||||
if (typeof alg === 'string') return [key, alg]
|
|
||||||
|
|
||||||
matchingKeys.push(key)
|
|
||||||
}
|
|
||||||
|
|
||||||
const isAllowedAlg = matchesAny(alg)
|
|
||||||
const candidates = matchingKeys.map(
|
|
||||||
key => [key, key.algorithms.filter(isAllowedAlg)] as const,
|
|
||||||
)
|
|
||||||
|
|
||||||
// Return the first candidates that matches the preferred algorithms
|
|
||||||
for (const prefAlg of this.preferredSigningAlgorithms) {
|
|
||||||
for (const [matchingKey, matchingAlgs] of candidates) {
|
|
||||||
if (matchingAlgs.includes(prefAlg)) return [matchingKey, prefAlg]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return any candidate
|
|
||||||
for (const [matchingKey, matchingAlgs] of candidates) {
|
|
||||||
for (const alg of matchingAlgs) {
|
|
||||||
return [matchingKey, alg]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new TypeError(`No singing key found for ${kid || alg || '<unknown>'}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
[Symbol.iterator](): IterableIterator<K> {
|
|
||||||
return this.keys.values()
|
|
||||||
}
|
|
||||||
|
|
||||||
async sign(
|
|
||||||
{alg: searchAlg, kid: searchKid, ...header}: JwtSignHeader,
|
|
||||||
payload: JwtPayload | JwtPayloadGetter,
|
|
||||||
) {
|
|
||||||
const [key, alg] = this.findSigningKey({alg: searchAlg, kid: searchKid})
|
|
||||||
const protectedHeader = {...header, alg, kid: key.kid}
|
|
||||||
|
|
||||||
if (typeof payload === 'function') {
|
|
||||||
payload = await payload(protectedHeader, key)
|
|
||||||
}
|
|
||||||
|
|
||||||
return key.createJwt(protectedHeader, payload)
|
|
||||||
}
|
|
||||||
|
|
||||||
async verify<
|
|
||||||
P extends Record<string, unknown> = JwtPayload,
|
|
||||||
C extends string = string,
|
|
||||||
>(token: Jwt, options?: VerifyOptions<C>) {
|
|
||||||
const {header} = unsafeDecodeJwt(token)
|
|
||||||
const {kid, alg} = header
|
|
||||||
|
|
||||||
const errors: unknown[] = []
|
|
||||||
|
|
||||||
for (const key of this.list({use: 'sig', kid, alg})) {
|
|
||||||
try {
|
|
||||||
return await key.verifyJwt<P, C>(token, options)
|
|
||||||
} catch (err) {
|
|
||||||
errors.push(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new AggregateError(errors, 'Unable to verify signature')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
// eslint-disable-next-line @typescript-eslint/ban-types
|
|
||||||
export type Simplify<T> = { [K in keyof T]: T[K] } & {}
|
|
||||||
export type Override<T, V> = Simplify<V & Omit<T, keyof V>>
|
|
||||||
|
|
||||||
export type RequiredKey<T, K extends string> = Simplify<
|
|
||||||
string extends K
|
|
||||||
? T
|
|
||||||
: {
|
|
||||||
[L in K]: Exclude<L extends keyof T ? T[L] : unknown, undefined>
|
|
||||||
} & Omit<T, K>
|
|
||||||
>
|
|
||||||
|
|
||||||
export const isDefined = <T>(i: T | undefined): i is T => i !== undefined
|
|
||||||
|
|
||||||
export const preferredOrderCmp =
|
|
||||||
<T>(order: readonly T[]) =>
|
|
||||||
(a: T, b: T) => {
|
|
||||||
const aIdx = order.indexOf(a)
|
|
||||||
const bIdx = order.indexOf(b)
|
|
||||||
if (aIdx === bIdx) return 0
|
|
||||||
if (aIdx === -1) return 1
|
|
||||||
if (bIdx === -1) return -1
|
|
||||||
return aIdx - bIdx
|
|
||||||
}
|
|
||||||
|
|
||||||
export function matchesAny<T extends string | number | symbol | boolean>(
|
|
||||||
value: null | undefined | T | readonly T[],
|
|
||||||
): (v: unknown) => v is T {
|
|
||||||
return value == null
|
|
||||||
? (v): v is T => true
|
|
||||||
: Array.isArray(value)
|
|
||||||
? (v): v is T => value.includes(v)
|
|
||||||
: (v): v is T => v === value
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Decorator to cache the result of a getter on a class instance.
|
|
||||||
*/
|
|
||||||
export const cachedGetter = <T extends object, V>(
|
|
||||||
target: (this: T) => V,
|
|
||||||
_context: ClassGetterDecoratorContext<T, V>,
|
|
||||||
) => {
|
|
||||||
return function (this: T) {
|
|
||||||
const value = target.call(this)
|
|
||||||
Object.defineProperty(this, target.name, {
|
|
||||||
get: () => value,
|
|
||||||
enumerable: true,
|
|
||||||
configurable: true,
|
|
||||||
})
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const decoder = new TextDecoder()
|
|
||||||
export const ui8ToString = (value: Uint8Array) => decoder.decode(value)
|
|
||||||
Reference in New Issue
Block a user