diff --git a/modules/expo-bluesky-oauth-client/src/react-native-oauth-client-factory.native.ts b/modules/expo-bluesky-oauth-client/src/react-native-oauth-client-factory.native.ts new file mode 100644 index 0000000000..754a26f95f --- /dev/null +++ b/modules/expo-bluesky-oauth-client/src/react-native-oauth-client-factory.native.ts @@ -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 + + 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 + // }) + // } +} diff --git a/modules/expo-bluesky-oauth-client/src/react-native-oauth-client-factory.ts b/modules/expo-bluesky-oauth-client/src/react-native-oauth-client-factory.ts new file mode 100644 index 0000000000..0e6468d9ea --- /dev/null +++ b/modules/expo-bluesky-oauth-client/src/react-native-oauth-client-factory.ts @@ -0,0 +1 @@ +export * from '@atproto/oauth-client-browser/src/browser-oauth-client-factory' diff --git a/modules/expo-bluesky-oauth-client/src/react-native-oauth-database.native.ts b/modules/expo-bluesky-oauth-client/src/react-native-oauth-database.native.ts new file mode 100644 index 0000000000..96a7ae74d7 --- /dev/null +++ b/modules/expo-bluesky-oauth-client/src/react-native-oauth-database.native.ts @@ -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 { + 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 + dpopNonceCache: Item + handleCache: Item + metadataCache: Item +} + +export type DatabaseStore = GenericStore & { + getKeys: () => Promise +} + +const STORES = [ + 'state', + 'session', + + 'didCache', + 'dpopNonceCache', + 'handleCache', + 'metadataCache', +] as const + +export class RNOAuthDatabase { + async delete(key: string) { + await Storage.removeItem(key) + } + + protected createStore( + dbName: N, + { + encode, + decode, + maxAge, + }: { + encode: (value: V) => Schema[N]['value'] | PromiseLike + decode: (encoded: Schema[N]['value']) => V | PromiseLike + maxAge?: number + }, + ): DatabaseStore { + 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 { + return this.createStore('session', { + encode: ({dpopKey, ...session}) => ({ + ...session, + dpopKey: encodeKey(dpopKey), + }), + decode: async ({dpopKey, ...encoded}) => ({ + ...encoded, + dpopKey: await decodeKey(dpopKey), + }), + }) + } + + getStateStore(): DatabaseStore { + return this.createStore('state', { + encode: ({dpopKey, ...session}) => ({ + ...session, + dpopKey: encodeKey(dpopKey), + }), + decode: async ({dpopKey, ...encoded}) => ({ + ...encoded, + dpopKey: await decodeKey(dpopKey), + }), + }) + } + + getDpopNonceCache(): undefined | DatabaseStore { + 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 { + return this.createStore('didCache', { + maxAge: 60e3, + encode: value => value, + decode: encoded => encoded, + }) + } + + getHandleCache(): undefined | DatabaseStore { + return this.createStore('handleCache', { + maxAge: 60e3, + encode: value => value, + decode: encoded => encoded, + }) + } + + getMetadataCache(): undefined | DatabaseStore { + 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() + } +} diff --git a/modules/expo-bluesky-oauth-client/src/react-native-oauth-database.ts b/modules/expo-bluesky-oauth-client/src/react-native-oauth-database.ts new file mode 100644 index 0000000000..1fc298076b --- /dev/null +++ b/modules/expo-bluesky-oauth-client/src/react-native-oauth-database.ts @@ -0,0 +1 @@ +export * from '@atproto/oauth-client-browser/src/browser-oauth-database' diff --git a/modules/expo-bluesky-oauth-client/src/react-native-store.web.ts b/modules/expo-bluesky-oauth-client/src/react-native-store.web.ts new file mode 100644 index 0000000000..b0aef4a5db --- /dev/null +++ b/modules/expo-bluesky-oauth-client/src/react-native-store.web.ts @@ -0,0 +1 @@ +export * from '@atproto/oauth-client-browser/src/indexed-db-store'