decent base

This commit is contained in:
Hailey
2024-04-09 13:04:33 -07:00
parent ec58082dbb
commit 1461c0a34f
45 changed files with 2276 additions and 74 deletions
+1 -1
View File
@@ -63,7 +63,6 @@
"@lingui/react": "^4.5.0",
"@mattermost/react-native-paste-input": "^0.6.4",
"@miblanchard/react-native-slider": "^2.3.1",
"@pagopa/io-react-native-jwt": "^1.1.0",
"@radix-ui/react-dropdown-menu": "^2.0.6",
"@react-native-async-storage/async-storage": "1.23.1",
"@react-native-masked-view/masked-view": "0.3.0",
@@ -133,6 +132,7 @@
"expo-web-browser": "~12.8.2",
"fast-text-encoding": "^1.0.6",
"history": "^5.3.0",
"jose": "^5.2.4",
"js-sha256": "^0.9.0",
"jwt-decode": "^4.0.0",
"lande": "^1.0.10",
@@ -1,11 +1,47 @@
import {Key} from '../jwk'
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 type {Key}
export class CryptoImplementation {
public async createKey(algs: string[]): Promise<Key> {
return CryptoKey.generate(undefined, algs)
}
export interface CryptoImplementation {
createKey(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}`)
}
}
@@ -0,0 +1,173 @@
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')
}
}
View File
+78
View File
@@ -0,0 +1,78 @@
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
}
}
@@ -0,0 +1,16 @@
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 })
}
}
@@ -0,0 +1,290 @@
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)
}
}
@@ -0,0 +1,89 @@
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
}
@@ -0,0 +1,6 @@
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'
@@ -0,0 +1,28 @@
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}
}
}
@@ -0,0 +1,81 @@
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,
)
}
}
@@ -0,0 +1,287 @@
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')
}
@@ -0,0 +1,37 @@
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}
@@ -0,0 +1,139 @@
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
}
+160
View File
@@ -0,0 +1,160 @@
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')
}
@@ -0,0 +1,81 @@
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`,
)
}
}
}
}
@@ -0,0 +1,10 @@
// 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')
+174
View File
@@ -0,0 +1,174 @@
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)))
)
}
@@ -0,0 +1,35 @@
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 }
}
}
@@ -0,0 +1,2 @@
export * from './identity-resolver'
export * from './universal-identity-resolver'
@@ -0,0 +1,52 @@
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,
}),
)
}
}
@@ -0,0 +1,44 @@
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'))
}
})
}
}
@@ -0,0 +1,47 @@
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())
}
}
@@ -0,0 +1,52 @@
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()
}
}
+9
View File
@@ -0,0 +1,9 @@
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,
)
}
+113
View File
@@ -0,0 +1,113 @@
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()
}
}
@@ -0,0 +1,6 @@
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'
@@ -0,0 +1,2 @@
export type ObjectStoreSchema = NonNullable<unknown>
export type DatabaseSchema = Record<string, ObjectStoreSchema>
@@ -0,0 +1,20 @@
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
}
+2
View File
@@ -0,0 +1,2 @@
export * from './jose-key'
export * from './jose-keyset'
+115
View File
@@ -0,0 +1,115 @@
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})
}
}
@@ -0,0 +1,16 @@
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),
),
),
)
}
}
+9
View File
@@ -0,0 +1,9 @@
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
}
+2 -2
View File
@@ -1,6 +1,6 @@
import { Jwk } from './jwk.js'
import {Jwk} from './jwk'
declare const process: undefined | { versions?: { node?: string } }
declare const process: undefined | {versions?: {node?: string}}
const IS_NODE_RUNTIME =
typeof process !== 'undefined' && typeof process?.versions?.node === 'string'
+9 -9
View File
@@ -1,9 +1,9 @@
export * from './alg.js'
export * from './jwk.js'
export * from './jwks.js'
export * from './jwt.js'
export * from './jwt-decode.js'
export * from './jwt-verify.js'
export * from './key.js'
export * from './keyset.js'
export * from './util.js'
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'
+2 -2
View File
@@ -1,6 +1,6 @@
import { z } from 'zod'
import {z} from 'zod'
import { jwkPubSchema, jwkSchema } from './jwk.js'
import {jwkPubSchema, jwkSchema} from './jwk'
export const jwksSchema = z
.object({
+5 -11
View File
@@ -1,18 +1,12 @@
import { b64uDecode } from '@atproto/b64'
import { ui8ToString } from './util.js'
import {
JwtHeader,
JwtPayload,
jwtHeaderSchema,
jwtPayloadSchema,
} from './jwt.js'
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('.')
const {0: headerEnc, 1: payloadEnc, length} = jwt.split('.')
if (length > 3 || length < 2) {
throw new TypeError('invalid JWT input')
}
@@ -28,5 +22,5 @@ export function unsafeDecodeJwt(jwt: string): {
JSON.parse(ui8ToString(b64uDecode(payloadEnc!))),
)
return { header, payload }
return {header, payload}
}
+2 -2
View File
@@ -1,5 +1,5 @@
import { JwtHeader, JwtPayload } from './jwt.js'
import { RequiredKey } from './util.js'
import {JwtHeader, JwtPayload} from './jwt'
import {RequiredKey} from './util'
export type VerifyOptions<C extends string = string> = {
audience?: string | readonly string[]
+2 -2
View File
@@ -1,6 +1,6 @@
import { z } from 'zod'
import {z} from 'zod'
import { jwkPubSchema } from './jwk.js'
import {jwkPubSchema} from './jwk'
export const JWT_REGEXP = /^[A-Za-z0-9_-]{2,}(?:\.[A-Za-z0-9_-]{2,}){1,2}$/
export const jwtSchema = z
+11 -11
View File
@@ -1,8 +1,8 @@
import { jwkAlgorithms } from './alg.js'
import { Jwk, jwkSchema } from './jwk.js'
import { VerifyOptions, VerifyPayload, VerifyResult } from './jwt-verify.js'
import { Jwt, JwtHeader, JwtPayload } from './jwt.js'
import { cachedGetter } from './util.js'
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) {
@@ -11,13 +11,13 @@ export abstract class Key {
}
get isPrivate(): boolean {
const { jwk } = this
const {jwk} = this
if ('d' in jwk && jwk.d !== undefined) return true
return this.isSymetric
}
get isSymetric(): boolean {
const { jwk } = this
const {jwk} = this
if ('k' in jwk && jwk.k !== undefined) return true
return false
}
@@ -30,7 +30,7 @@ export abstract class Key {
get publicJwk(): Jwk | undefined {
if (this.isSymetric) return undefined
if (this.isPrivate) {
const { d: _, ...jwk } = this.jwk as any
const {d: _, ...jwk} = this.jwk as any
return jwk
}
return this.jwk
@@ -39,8 +39,8 @@ export abstract class Key {
@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 })
const {kty, crv, e, n, x, y} = this.jwk as any
return jwkSchema.parse({crv, e, kty, n, x, y})
}
get use() {
@@ -60,7 +60,7 @@ export abstract class Key {
}
get crv() {
return (this.jwk as undefined | Extract<Jwk, { crv: unknown }>)?.crv
return (this.jwk as undefined | Extract<Jwk, {crv: unknown}>)?.crv
}
get canVerify() {
+20 -20
View File
@@ -1,16 +1,16 @@
import { Jwk } from './jwk.js'
import { Jwks } from './jwks.js'
import { unsafeDecodeJwt } from './jwt-decode.js'
import { VerifyOptions } from './jwt-verify.js'
import { Jwt, JwtHeader, JwtPayload } from './jwt.js'
import { Key } from './key.js'
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 {
Override,
cachedGetter,
isDefined,
matchesAny,
Override,
preferredOrderCmp,
} from './util.js'
} from './util'
export type JwtSignHeader = Override<JwtHeader, Pick<KeySearch, 'alg' | 'kid'>>
@@ -50,7 +50,7 @@ export class Keyset<K extends Key = Key> implements Iterable<K> {
if (!keys.length) throw new Error('Keyset is empty')
const kids = new Set<string>()
for (const { kid } of keys) {
for (const {kid} of keys) {
if (!kid) continue
if (kids.has(kid)) throw new Error(`Duplicate key id: ${kid}`)
@@ -87,7 +87,7 @@ export class Keyset<K extends Key = Key> implements Iterable<K> {
}
has(kid: string): boolean {
return this.keys.some((key) => key.kid === kid)
return this.keys.some(key => key.kid === kid)
}
get(search: KeySearch): K {
@@ -115,7 +115,7 @@ export class Keyset<K extends Key = Key> implements Iterable<K> {
}
if (Array.isArray(search.alg)) {
if (!search.alg.some((a) => key.algorithms.includes(a))) continue
if (!search.alg.some(a => key.algorithms.includes(a))) continue
} else if (typeof search.alg === 'string') {
if (!key.algorithms.includes(search.alg)) continue
}
@@ -125,10 +125,10 @@ export class Keyset<K extends Key = Key> implements Iterable<K> {
}
findSigningKey(search: Omit<KeySearch, 'use'>): [key: Key, alg: string] {
const { kid, alg } = search
const {kid, alg} = search
const matchingKeys: Key[] = []
for (const key of this.list({ kid, alg, use: 'sig' })) {
for (const key of this.list({kid, alg, use: 'sig'})) {
// Not a signing key
if (!key.canSign) continue
@@ -140,7 +140,7 @@ export class Keyset<K extends Key = Key> implements Iterable<K> {
const isAllowedAlg = matchesAny(alg)
const candidates = matchingKeys.map(
(key) => [key, key.algorithms.filter(isAllowedAlg)] as const,
key => [key, key.algorithms.filter(isAllowedAlg)] as const,
)
// Return the first candidates that matches the preferred algorithms
@@ -165,11 +165,11 @@ export class Keyset<K extends Key = Key> implements Iterable<K> {
}
async sign(
{ alg: searchAlg, kid: searchKid, ...header }: JwtSignHeader,
{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 }
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)
@@ -182,12 +182,12 @@ export class Keyset<K extends Key = Key> implements Iterable<K> {
P extends Record<string, unknown> = JwtPayload,
C extends string = string,
>(token: Jwt, options?: VerifyOptions<C>) {
const { header } = unsafeDecodeJwt(token)
const { kid, alg } = header
const {header} = unsafeDecodeJwt(token)
const {kid, alg} = header
const errors: unknown[] = []
for (const key of this.list({ use: 'sig', kid, alg })) {
for (const key of this.list({use: 'sig', kid, alg})) {
try {
return await key.verifyJwt<P, C>(token, options)
} catch (err) {
+4 -2
View File
@@ -1,6 +1,5 @@
import React from 'react'
import * as Browser from 'expo-web-browser'
import {OAuthClientFactory} from '@atproto/oauth-client'
import {
buildOAuthUrl,
@@ -12,9 +11,11 @@ import {
OAUTH_RESPONSE_TYPES,
OAUTH_SCOPE,
} from 'lib/oauth'
import {CryptoImplementation} from '#/oauth-client-temp/client/crypto-implementation'
import {OAuthClientFactory} from '#/oauth-client-temp/client/oauth-client-factory'
// TODO remove hack
const serviceUrl = 'http://localhost:2583/oauth/authorize'
const serviceUrl = 'http://localhost'
// Service URL here is just a placeholder, this isn't how it will actually work
export function useLogin(serviceUrl: string | undefined) {
@@ -29,6 +30,7 @@ export function useLogin(serviceUrl: string | undefined) {
dpop_bound_access_tokens: DPOP_BOUND_ACCESS_TOKENS,
application_type: OAUTH_APPLICATION_TYPE,
},
cryptoImplementation: new CryptoImplementation(crypto),
})
if (!serviceUrl) return
+5 -8
View File
@@ -4478,14 +4478,6 @@
mkdirp "^1.0.4"
rimraf "^3.0.2"
"@pagopa/io-react-native-jwt@^1.1.0":
version "1.1.0"
resolved "https://registry.yarnpkg.com/@pagopa/io-react-native-jwt/-/io-react-native-jwt-1.1.0.tgz#9a8e99672a683a32a27785eb01a7f108561ca8dd"
integrity sha512-R/Cgiu3Qb/7LnzQstUTGkNnsKfQ5lc/O3eSAkzZPGQqQb4hoSch4N/2JgqXRZPeTNfFA2vDmYbV6fSidMDL2xA==
dependencies:
abab "^2.0.6"
zod "^3.21.4"
"@pmmmwh/react-refresh-webpack-plugin@^0.5.11", "@pmmmwh/react-refresh-webpack-plugin@^0.5.3":
version "0.5.11"
resolved "https://registry.yarnpkg.com/@pmmmwh/react-refresh-webpack-plugin/-/react-refresh-webpack-plugin-0.5.11.tgz#7c2268cedaa0644d677e8c4f377bc8fb304f714a"
@@ -15244,6 +15236,11 @@ jose@^5.0.1:
resolved "https://registry.yarnpkg.com/jose/-/jose-5.1.3.tgz#303959d85c51b5cb14725f930270b72be56abdca"
integrity sha512-GPExOkcMsCLBTi1YetY2LmkoY559fss0+0KVa6kOfb2YFe84nAM7Nm/XzuZozah4iHgmBGrCOHL5/cy670SBRw==
jose@^5.2.4:
version "5.2.4"
resolved "https://registry.yarnpkg.com/jose/-/jose-5.2.4.tgz#c0d296caeeed0b8444a8b8c3b68403d61aa4ed72"
integrity sha512-6ScbIk2WWCeXkmzF6bRPmEuaqy1m8SbsRFMa/FLrSCkGIhj8OLVG/IH+XHVmNMx/KUo8cVWEE6oKR4dJ+S0Rkg==
js-base64@^3.7.2:
version "3.7.5"
resolved "https://registry.yarnpkg.com/js-base64/-/js-base64-3.7.5.tgz#21e24cf6b886f76d6f5f165bfcd69cc55b9e3fca"