Merge branch 'hailey/expo-oauth-helper' into hailey/oauth
This commit is contained in:
@@ -0,0 +1,143 @@
|
||||
import {Fetch} from '@atproto/fetch'
|
||||
import {UniversalIdentityResolver} from '@atproto/identity-resolver'
|
||||
import {
|
||||
OAuthAuthorizeOptions,
|
||||
OAuthClientFactory,
|
||||
OAuthResponseMode,
|
||||
OAuthResponseType,
|
||||
Session,
|
||||
} from '@atproto/oauth-client'
|
||||
import {OAuthClientMetadata} from '@atproto/oauth-client-metadata'
|
||||
import {IsomorphicOAuthServerMetadataResolver} from '@atproto/oauth-server-metadata-resolver'
|
||||
|
||||
import {ReactNativeCryptoImplementation} from './react-native-crypto-implementation'
|
||||
import {DatabaseStore} from './react-native-oauth-database'
|
||||
import {RNOAuthDatabase} from './react-native-oauth-database.native'
|
||||
|
||||
export type RNOAuthClientOptions = {
|
||||
responseMode?: OAuthResponseMode
|
||||
responseType?: OAuthResponseType
|
||||
clientMetadata: OAuthClientMetadata
|
||||
fetch?: Fetch
|
||||
crypto?: any
|
||||
}
|
||||
|
||||
export class RNOAuthClientFactory extends OAuthClientFactory {
|
||||
readonly sessionStore: DatabaseStore<Session>
|
||||
|
||||
constructor({
|
||||
clientMetadata,
|
||||
// "fragment" is safer as it is not sent to the server
|
||||
responseMode = 'fragment',
|
||||
fetch = globalThis.fetch,
|
||||
}: RNOAuthClientOptions) {
|
||||
const database = new RNOAuthDatabase()
|
||||
|
||||
super({
|
||||
clientMetadata,
|
||||
responseMode,
|
||||
fetch,
|
||||
cryptoImplementation: new ReactNativeCryptoImplementation(),
|
||||
sessionStore: database.getSessionStore(),
|
||||
stateStore: database.getStateStore(),
|
||||
metadataResolver: new IsomorphicOAuthServerMetadataResolver({
|
||||
fetch,
|
||||
cache: database.getMetadataCache(),
|
||||
}),
|
||||
identityResolver: UniversalIdentityResolver.from({
|
||||
fetch,
|
||||
didCache: database.getDidCache(),
|
||||
handleCache: database.getHandleCache(),
|
||||
}),
|
||||
dpopNonceCache: database.getDpopNonceCache(),
|
||||
})
|
||||
|
||||
this.sessionStore = database.getSessionStore()
|
||||
}
|
||||
|
||||
async restoreAll() {
|
||||
const sessionIds = await this.sessionStore.getKeys()
|
||||
return Object.fromEntries(
|
||||
await Promise.all(
|
||||
sessionIds.map(
|
||||
async sessionId =>
|
||||
[sessionId, await this.restore(sessionId, false)] as const,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// async init(sessionId?: string, forceRefresh = false) {
|
||||
// // // const signInResult = await this.signInCallback()
|
||||
// // if (signInResult) {
|
||||
// // return signInResult
|
||||
// // } else if (sessionId) {
|
||||
// // const client = await this.restore(sessionId, forceRefresh)
|
||||
// // return {client}
|
||||
// // } else {
|
||||
// // // TODO: we could restore any session from the store ?
|
||||
// // }
|
||||
// }
|
||||
|
||||
async signIn(input: string, options?: OAuthAuthorizeOptions) {
|
||||
return await this.authorize(input, options)
|
||||
}
|
||||
|
||||
// async signInCallback() {
|
||||
// const redirectUri = new URL(this.clientMetadata.redirect_uris[0])
|
||||
// if (location.pathname !== redirectUri.pathname) return null
|
||||
//
|
||||
// const params =
|
||||
// this.responseMode === 'query'
|
||||
// ? new URLSearchParams(location.search)
|
||||
// : new URLSearchParams(location.hash.slice(1))
|
||||
//
|
||||
// // Only if the query string contains oauth callback params
|
||||
// if (
|
||||
// !params.has('iss') ||
|
||||
// !params.has('state') ||
|
||||
// !(params.has('code') || params.has('error'))
|
||||
// ) {
|
||||
// return null
|
||||
// }
|
||||
//
|
||||
// // Replace the current history entry without the query string (this will
|
||||
// // prevent this 'if' branch to run again if the user refreshes the page)
|
||||
// history.replaceState(null, '', location.pathname)
|
||||
//
|
||||
// return this.callback(params)
|
||||
// .then(async result => {
|
||||
// if (result.state?.startsWith(POPUP_KEY_PREFIX)) {
|
||||
// const stateKey = result.state.slice(POPUP_KEY_PREFIX.length)
|
||||
//
|
||||
// await this.popupStore.set(stateKey, {
|
||||
// status: 'fulfilled',
|
||||
// value: result.client.sessionId,
|
||||
// })
|
||||
//
|
||||
// window.close() // continued in signInPopup
|
||||
// throw new Error('Login complete, please close the popup window.')
|
||||
// }
|
||||
//
|
||||
// return result
|
||||
// })
|
||||
// .catch(async err => {
|
||||
// // TODO: Throw a proper error from parent class to actually detect
|
||||
// // oauth authorization errors
|
||||
// const state = typeof (err as any)?.state
|
||||
// if (typeof state === 'string' && state?.startsWith(POPUP_KEY_PREFIX)) {
|
||||
// const stateKey = state.slice(POPUP_KEY_PREFIX.length)
|
||||
//
|
||||
// await this.popupStore.set(stateKey, {
|
||||
// status: 'rejected',
|
||||
// reason: err,
|
||||
// })
|
||||
//
|
||||
// window.close() // continued in signInPopup
|
||||
// throw new Error('Login complete, please close the popup window.')
|
||||
// }
|
||||
//
|
||||
// throw err
|
||||
// })
|
||||
// }
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from '@atproto/oauth-client-browser/src/browser-oauth-client-factory'
|
||||
@@ -0,0 +1,214 @@
|
||||
import {GenericStore, Value} from '@atproto/caching'
|
||||
import {DidDocument} from '@atproto/did'
|
||||
import {ResolvedHandle} from '@atproto/handle-resolver'
|
||||
import {Key} from '@atproto/jwk'
|
||||
import {WebcryptoKey} from '@atproto/jwk-webcrypto'
|
||||
import {InternalStateData, Session, TokenSet} from '@atproto/oauth-client'
|
||||
import {OAuthServerMetadata} from '@atproto/oauth-server-metadata'
|
||||
import Storage from '@react-native-async-storage/async-storage'
|
||||
|
||||
type Item = {
|
||||
value: string
|
||||
expiresAt: null | Date
|
||||
}
|
||||
|
||||
type EncodedKey = {
|
||||
keyId: string
|
||||
keyPair: CryptoKeyPair
|
||||
}
|
||||
|
||||
function encodeKey(key: Key): EncodedKey {
|
||||
if (!(key instanceof WebcryptoKey) || !key.kid) {
|
||||
throw new Error('Invalid key object')
|
||||
}
|
||||
return {
|
||||
keyId: key.kid,
|
||||
keyPair: key.cryptoKeyPair,
|
||||
}
|
||||
}
|
||||
|
||||
async function decodeKey(encoded: EncodedKey): Promise<Key> {
|
||||
return WebcryptoKey.fromKeypair(encoded.keyId, encoded.keyPair)
|
||||
}
|
||||
|
||||
export type Schema = {
|
||||
state: Item<{
|
||||
dpopKey: EncodedKey
|
||||
|
||||
iss: string
|
||||
nonce: string
|
||||
verifier?: string
|
||||
appState?: string
|
||||
}>
|
||||
session: Item<{
|
||||
dpopKey: EncodedKey
|
||||
|
||||
tokenSet: TokenSet
|
||||
}>
|
||||
|
||||
didCache: Item<DidDocument>
|
||||
dpopNonceCache: Item<string>
|
||||
handleCache: Item<ResolvedHandle>
|
||||
metadataCache: Item<OAuthServerMetadata>
|
||||
}
|
||||
|
||||
export type DatabaseStore<V extends Value> = GenericStore<string, V> & {
|
||||
getKeys: () => Promise<string[]>
|
||||
}
|
||||
|
||||
const STORES = [
|
||||
'state',
|
||||
'session',
|
||||
|
||||
'didCache',
|
||||
'dpopNonceCache',
|
||||
'handleCache',
|
||||
'metadataCache',
|
||||
] as const
|
||||
|
||||
export class RNOAuthDatabase {
|
||||
async delete(key: string) {
|
||||
await Storage.removeItem(key)
|
||||
}
|
||||
|
||||
protected createStore<N extends keyof Schema, V extends Value>(
|
||||
dbName: N,
|
||||
{
|
||||
encode,
|
||||
decode,
|
||||
maxAge,
|
||||
}: {
|
||||
encode: (value: V) => Schema[N]['value'] | PromiseLike<Schema[N]['value']>
|
||||
decode: (encoded: Schema[N]['value']) => V | PromiseLike<V>
|
||||
maxAge?: number
|
||||
},
|
||||
): DatabaseStore<V> {
|
||||
return {
|
||||
get: async key => {
|
||||
const itemJson = await Storage.getItem(`${dbName}.${key}`)
|
||||
if (itemJson == null) return undefined
|
||||
|
||||
const item = JSON.parse(itemJson) as Schema[N]
|
||||
|
||||
// Too old, proactively delete
|
||||
if (item.expiresAt != null && item.expiresAt < new Date()) {
|
||||
await this.delete(`${dbName}.${key}`)
|
||||
return undefined
|
||||
}
|
||||
|
||||
// Item found and valid. Decode
|
||||
return decode(item.value)
|
||||
},
|
||||
|
||||
getKeys: async () => {
|
||||
const keys = await Storage.getAllKeys()
|
||||
return keys.filter(key => key.startsWith(`${dbName}.`)) as string[]
|
||||
},
|
||||
|
||||
set: async (key, value) => {
|
||||
const item = {
|
||||
value: await encode(value),
|
||||
expiresAt: maxAge == null ? null : new Date(Date.now() + maxAge),
|
||||
} as Schema[N]
|
||||
|
||||
await Storage.setItem(`${dbName}.${key}`, JSON.stringify(item))
|
||||
},
|
||||
|
||||
del: async key => {
|
||||
await this.delete(`${dbName}.${key}`)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
getSessionStore(): DatabaseStore<Session> {
|
||||
return this.createStore('session', {
|
||||
encode: ({dpopKey, ...session}) => ({
|
||||
...session,
|
||||
dpopKey: encodeKey(dpopKey),
|
||||
}),
|
||||
decode: async ({dpopKey, ...encoded}) => ({
|
||||
...encoded,
|
||||
dpopKey: await decodeKey(dpopKey),
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
getStateStore(): DatabaseStore<InternalStateData> {
|
||||
return this.createStore('state', {
|
||||
encode: ({dpopKey, ...session}) => ({
|
||||
...session,
|
||||
dpopKey: encodeKey(dpopKey),
|
||||
}),
|
||||
decode: async ({dpopKey, ...encoded}) => ({
|
||||
...encoded,
|
||||
dpopKey: await decodeKey(dpopKey),
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
getDpopNonceCache(): undefined | DatabaseStore<string> {
|
||||
return this.createStore('dpopNonceCache', {
|
||||
// No time limit. It is better to try with a potentially outdated nonce
|
||||
// and potentially succeed rather than make requests without a nonce and
|
||||
// 100% fail.
|
||||
encode: value => value,
|
||||
decode: encoded => encoded,
|
||||
})
|
||||
}
|
||||
|
||||
getDidCache(): undefined | DatabaseStore<DidDocument> {
|
||||
return this.createStore('didCache', {
|
||||
maxAge: 60e3,
|
||||
encode: value => value,
|
||||
decode: encoded => encoded,
|
||||
})
|
||||
}
|
||||
|
||||
getHandleCache(): undefined | DatabaseStore<ResolvedHandle> {
|
||||
return this.createStore('handleCache', {
|
||||
maxAge: 60e3,
|
||||
encode: value => value,
|
||||
decode: encoded => encoded,
|
||||
})
|
||||
}
|
||||
|
||||
getMetadataCache(): undefined | DatabaseStore<OAuthServerMetadata> {
|
||||
return this.createStore('metadataCache', {
|
||||
maxAge: 60e3,
|
||||
encode: value => value,
|
||||
decode: encoded => encoded,
|
||||
})
|
||||
}
|
||||
|
||||
async cleanup() {
|
||||
await Promise.all(
|
||||
STORES.map(
|
||||
async storeName =>
|
||||
[
|
||||
storeName,
|
||||
await tx
|
||||
.objectStore(storeName)
|
||||
.index('expiresAt')
|
||||
.getAllKeys(query),
|
||||
] as const,
|
||||
),
|
||||
)
|
||||
|
||||
const storesWithInvalidKeys = res.filter(r => r[1].length > 0)
|
||||
|
||||
await db.transaction(
|
||||
storesWithInvalidKeys.map(r => r[0]),
|
||||
'readwrite',
|
||||
tx =>
|
||||
Promise.all(
|
||||
storesWithInvalidKeys.map(async ([name, keys]) =>
|
||||
tx.objectStore(name).delete(keys),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
async [Symbol.asyncDispose]() {
|
||||
await this.cleanup()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from '@atproto/oauth-client-browser/src/browser-oauth-database'
|
||||
@@ -0,0 +1 @@
|
||||
export * from '@atproto/oauth-client-browser/src/indexed-db-store'
|
||||
Reference in New Issue
Block a user