diff --git a/modules/expo-bluesky-oauth-client/index.ts b/modules/expo-bluesky-oauth-client/index.ts index ca28960b93..05f53f9803 100644 --- a/modules/expo-bluesky-oauth-client/index.ts +++ b/modules/expo-bluesky-oauth-client/index.ts @@ -1,6 +1,6 @@ -export * from './src/crypto-subtle' -export * from './src/jose-key' -export * from './src/rn-crypto-key' -export * from './src/rn-oauth-client-factory' -export * from './src/rn-oauth-database' -export * from './src/util.web' +export * from './src-old/crypto-subtle' +export * from './src-old/jose-key' +export * from './src-old/rn-crypto-key' +export * from './src-old/rn-oauth-client-factory' +export * from './src-old/rn-oauth-database' +export * from './src-old/util.web' diff --git a/modules/expo-bluesky-oauth-client/src/crypto-subtle.ts b/modules/expo-bluesky-oauth-client/src-old/crypto-subtle.ts similarity index 100% rename from modules/expo-bluesky-oauth-client/src/crypto-subtle.ts rename to modules/expo-bluesky-oauth-client/src-old/crypto-subtle.ts diff --git a/modules/expo-bluesky-oauth-client/src/crypto-subtle.web.ts b/modules/expo-bluesky-oauth-client/src-old/crypto-subtle.web.ts similarity index 100% rename from modules/expo-bluesky-oauth-client/src/crypto-subtle.web.ts rename to modules/expo-bluesky-oauth-client/src-old/crypto-subtle.web.ts diff --git a/modules/expo-bluesky-oauth-client/src/jose-key.ts b/modules/expo-bluesky-oauth-client/src-old/jose-key.ts similarity index 100% rename from modules/expo-bluesky-oauth-client/src/jose-key.ts rename to modules/expo-bluesky-oauth-client/src-old/jose-key.ts diff --git a/modules/expo-bluesky-oauth-client/src/native-types.ts b/modules/expo-bluesky-oauth-client/src-old/native-types.ts similarity index 100% rename from modules/expo-bluesky-oauth-client/src/native-types.ts rename to modules/expo-bluesky-oauth-client/src-old/native-types.ts diff --git a/modules/expo-bluesky-oauth-client/src/rn-crypto-key.ts b/modules/expo-bluesky-oauth-client/src-old/rn-crypto-key.ts similarity index 100% rename from modules/expo-bluesky-oauth-client/src/rn-crypto-key.ts rename to modules/expo-bluesky-oauth-client/src-old/rn-crypto-key.ts diff --git a/modules/expo-bluesky-oauth-client/src/rn-crypto-key.web.ts b/modules/expo-bluesky-oauth-client/src-old/rn-crypto-key.web.ts similarity index 100% rename from modules/expo-bluesky-oauth-client/src/rn-crypto-key.web.ts rename to modules/expo-bluesky-oauth-client/src-old/rn-crypto-key.web.ts diff --git a/modules/expo-bluesky-oauth-client/src/rn-oauth-client-factory.native.ts b/modules/expo-bluesky-oauth-client/src-old/rn-oauth-client-factory.native.ts similarity index 100% rename from modules/expo-bluesky-oauth-client/src/rn-oauth-client-factory.native.ts rename to modules/expo-bluesky-oauth-client/src-old/rn-oauth-client-factory.native.ts diff --git a/modules/expo-bluesky-oauth-client/src/rn-oauth-client-factory.ts b/modules/expo-bluesky-oauth-client/src-old/rn-oauth-client-factory.ts similarity index 100% rename from modules/expo-bluesky-oauth-client/src/rn-oauth-client-factory.ts rename to modules/expo-bluesky-oauth-client/src-old/rn-oauth-client-factory.ts diff --git a/modules/expo-bluesky-oauth-client/src/rn-oauth-database.native.ts b/modules/expo-bluesky-oauth-client/src-old/rn-oauth-database.native.ts similarity index 100% rename from modules/expo-bluesky-oauth-client/src/rn-oauth-database.native.ts rename to modules/expo-bluesky-oauth-client/src-old/rn-oauth-database.native.ts diff --git a/modules/expo-bluesky-oauth-client/src/rn-oauth-database.ts b/modules/expo-bluesky-oauth-client/src-old/rn-oauth-database.ts similarity index 100% rename from modules/expo-bluesky-oauth-client/src/rn-oauth-database.ts rename to modules/expo-bluesky-oauth-client/src-old/rn-oauth-database.ts diff --git a/modules/expo-bluesky-oauth-client/src/store.ts b/modules/expo-bluesky-oauth-client/src-old/store.ts similarity index 100% rename from modules/expo-bluesky-oauth-client/src/store.ts rename to modules/expo-bluesky-oauth-client/src-old/store.ts diff --git a/modules/expo-bluesky-oauth-client/src/store.web.ts b/modules/expo-bluesky-oauth-client/src-old/store.web.ts similarity index 100% rename from modules/expo-bluesky-oauth-client/src/store.web.ts rename to modules/expo-bluesky-oauth-client/src-old/store.web.ts diff --git a/modules/expo-bluesky-oauth-client/src/util.web.ts b/modules/expo-bluesky-oauth-client/src-old/util.web.ts similarity index 100% rename from modules/expo-bluesky-oauth-client/src/util.web.ts rename to modules/expo-bluesky-oauth-client/src-old/util.web.ts diff --git a/modules/expo-bluesky-oauth-client/src/oauth-client-react-native.ts b/modules/expo-bluesky-oauth-client/src/oauth-client-react-native.ts new file mode 100644 index 0000000000..4ce738202e --- /dev/null +++ b/modules/expo-bluesky-oauth-client/src/oauth-client-react-native.ts @@ -0,0 +1,44 @@ +import {requireNativeModule} from 'expo-modules-core' +import {Jwk, Jwt} from '@atproto/jwk' + +const NativeModule = requireNativeModule('ExpoBlueskyOAuthClient') + +const LINKING_ERROR = + 'The package ExpoBlueskyOAuthClient is not linked. Make sure you have run `expo install expo-bluesky-oauth-client` and rebuilt your app.' + +export const OauthClientReactNative = (NativeModule as null) || { + getRandomValues(_length: number): Uint8Array { + throw new Error(LINKING_ERROR) + }, + + /** + * @throws if the algorithm is not supported ("sha256" must be supported) + */ + digest(_bytes: Uint8Array, _algorithm: string): Uint8Array { + throw new Error(LINKING_ERROR) + }, + + /** + * Create a private JWK for the given algorithm. The JWK should have a "use" + * an does not need a "kid" property. + * + * @throws if the algorithm is not supported ("ES256" must be supported) + */ + generateJwk(_algo: string): Jwk { + throw new Error(LINKING_ERROR) + }, + + createJwt(_header: unknown, _payload: unknown, _jwk: unknown): Jwt { + throw new Error(LINKING_ERROR) + }, + + verifyJwt( + _token: Jwt, + _jwk: Jwk, + ): { + payload: Record + protectedHeader: Record + } { + throw new Error(LINKING_ERROR) + }, +} diff --git a/modules/expo-bluesky-oauth-client/src/react-native-crypto-implementation.ts b/modules/expo-bluesky-oauth-client/src/react-native-crypto-implementation.ts new file mode 100644 index 0000000000..757e5615a0 --- /dev/null +++ b/modules/expo-bluesky-oauth-client/src/react-native-crypto-implementation.ts @@ -0,0 +1,27 @@ +import {CryptoImplementaton, DigestAlgorithm, Key} from '@atproto/oauth-client' + +import {OauthClientReactNative} from './oauth-client-react-native' +import {ReactNativeKey} from './react-native-key' + +export class ReactNativeCryptoImplementation implements CryptoImplementaton { + async createKey(algs: string[]): Promise { + const bytes = await this.getRandomValues(12) + const kid = Array.from(bytes, byteToHex).join('') + return ReactNativeKey.generate(kid, algs) + } + + async getRandomValues(length: number): Promise { + return OauthClientReactNative.getRandomValues(length) + } + + async digest( + bytes: Uint8Array, + algorithm: DigestAlgorithm, + ): Promise { + return OauthClientReactNative.digest(bytes, algorithm.name) + } +} + +function byteToHex(b: number): string { + return b.toString(16).padStart(2, '0') +} diff --git a/modules/expo-bluesky-oauth-client/src/react-native-key.ts b/modules/expo-bluesky-oauth-client/src/react-native-key.ts new file mode 100644 index 0000000000..03d27db691 --- /dev/null +++ b/modules/expo-bluesky-oauth-client/src/react-native-key.ts @@ -0,0 +1,110 @@ +import { + jwkValidator, + Jwt, + JwtHeader, + jwtHeaderSchema, + JwtPayload, + jwtPayloadSchema, + Key, + VerifyOptions, + VerifyPayload, + VerifyResult, +} from '@atproto/jwk' + +import {OauthClientReactNative} from './oauth-client-react-native' + +export class ReactNativeKey extends Key { + static async generate(kid: string, allowedAlgos: string[]) { + for (const algo of allowedAlgos) { + try { + // Note: OauthClientReactNative.generatePrivateJwk should throw if it + // doesn't support the algorithm. + const jwk = await OauthClientReactNative.generateJwk(algo) + const use = jwk.use || 'sig' + return new ReactNativeKey(jwkValidator.parse({...jwk, use, kid})) + } catch { + // Ignore, try next one + } + } + + throw new Error('No supported algorithms') + } + + async createJwt(header: JwtHeader, payload: JwtPayload): Promise { + return OauthClientReactNative.createJwt(header, payload, this.jwk) + } + + async verifyJwt< + P extends VerifyPayload = JwtPayload, + C extends string = string, + >(token: Jwt, options?: VerifyOptions): Promise> { + const result = await OauthClientReactNative.verifyJwt(token, this.jwk) + + const payload = jwtPayloadSchema.parse(result.payload) + const protectedHeader = jwtHeaderSchema.parse(result.protectedHeader) + + if (options?.audience != null) { + const audience = Array.isArray(options.audience) + ? options.audience + : [options.audience] + if (!audience.includes(payload.aud)) { + throw new Error('Invalid audience') + } + } + + if (options?.issuer != null) { + const issuer = Array.isArray(options.issuer) + ? options.issuer + : [options.issuer] + if (!issuer.includes(payload.iss)) { + throw new Error('Invalid issuer') + } + } + + if (options?.subject != null && payload.sub !== options.subject) { + throw new Error('Invalid subject') + } + + if (options?.typ != null && protectedHeader.typ !== options.typ) { + throw new Error('Invalid type') + } + + if (options?.requiredClaims != null) { + for (const key of options.requiredClaims) { + if ( + !Object.hasOwn(payload, key) || + (payload as Record)[key] === undefined + ) { + throw new Error(`Missing claim: ${key}`) + } + } + } + + if (payload.iat == null) { + throw new Error('Missing issued at') + } + + const now = (options?.currentDate?.getTime() ?? Date.now()) / 1e3 + const clockTolerance = options?.clockTolerance ?? 0 + + if (options?.maxTokenAge != null) { + if (payload.iat < now - options.maxTokenAge + clockTolerance) { + throw new Error('Invalid issued at') + } + } + + if (payload.nbf != null) { + if (payload.nbf > now - clockTolerance) { + throw new Error('Invalid not before') + } + } + + if (payload.exp != null) { + if (payload.exp < now + clockTolerance) { + throw new Error('Invalid expiration') + } + } + + return {payload, protectedHeader} as VerifyResult + } +} diff --git a/modules/expo-bluesky-oauth-client/src/react-native-store-with-key.ts b/modules/expo-bluesky-oauth-client/src/react-native-store-with-key.ts new file mode 100644 index 0000000000..b65e9a46ed --- /dev/null +++ b/modules/expo-bluesky-oauth-client/src/react-native-store-with-key.ts @@ -0,0 +1,51 @@ +import {GenericStore, Value} from '@atproto/caching' +import {Jwk} from '@atproto/jwk' + +import {ReactNativeKey} from './react-native-key.js' +import {ReactNativeStore} from './react-native-store.js' + +type ExposedValue = Value & {dpopKey: ReactNativeKey} +type StoredValue = Omit & { + dpopKey: Jwk +} + +/** + * Uses a {@link ReactNativeStore} to store values that contain a + * {@link ReactNativeKey} as `dpopKey` property. This works by serializing the + * {@link Key} to a JWK before storing it, and deserializing it back to a + * {@link ReactNativeKey} when retrieving the value. + */ +export class ReactNativeStoreWithKey + implements GenericStore +{ + internalStore: ReactNativeStore> + + constructor( + protected valueExpiresAt: (value: StoredValue) => null | Date, + ) { + this.internalStore = new ReactNativeStore(valueExpiresAt) + } + + async set(key: string, value: V): Promise { + const {dpopKey, ...rest} = value + if (!dpopKey.privateJwk) throw new Error('dpopKey.privateJwk is required') + await this.internalStore.set(key, { + ...rest, + dpopKey: dpopKey.privateJwk, + }) + } + + async get(key: string): Promise { + const value = await this.internalStore.get(key) + if (!value) return undefined + + return { + ...value, + dpopKey: new ReactNativeKey(value.dpopKey), + } as V + } + + async del(key: string): Promise { + await this.internalStore.del(key) + } +} diff --git a/modules/expo-bluesky-oauth-client/src/react-native-store.ts b/modules/expo-bluesky-oauth-client/src/react-native-store.ts new file mode 100644 index 0000000000..518a72243d --- /dev/null +++ b/modules/expo-bluesky-oauth-client/src/react-native-store.ts @@ -0,0 +1,25 @@ +import {GenericStore, Value} from '@atproto/caching' +import Storage from '@react-native-async-storage/async-storage' + +export class ReactNativeStore + implements GenericStore +{ + constructor(protected valueExpiresAt: (value: V) => null | Date) { + throw new Error('Not implemented') + } + + async get(key: string): Promise { + const itemJson = await Storage.getItem(key) + if (itemJson == null) return undefined + + return JSON.parse(itemJson) as V + } + + async set(key: string, value: V): Promise { + await Storage.setItem(key, JSON.stringify(value)) + } + + async del(key: string): Promise { + await Storage.delete(key) + } +}