diff --git a/.eslintrc.js b/.eslintrc.js index a999fd24b0..29136d5dd0 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -31,6 +31,7 @@ module.exports = { }, }, ], + 'bsky-internal/use-typed-gates': 'error', 'simple-import-sort/imports': [ 'warn', { diff --git a/eslint/index.js b/eslint/index.js index daf5bd81d9..bb31a942d1 100644 --- a/eslint/index.js +++ b/eslint/index.js @@ -3,5 +3,6 @@ module.exports = { rules: { 'avoid-unwrapped-text': require('./avoid-unwrapped-text'), + 'use-typed-gates': require('./use-typed-gates'), }, } diff --git a/eslint/use-typed-gates.js b/eslint/use-typed-gates.js new file mode 100644 index 0000000000..3625a7da37 --- /dev/null +++ b/eslint/use-typed-gates.js @@ -0,0 +1,31 @@ +'use strict' + +exports.create = function create(context) { + return { + ImportSpecifier(node) { + if ( + !node.local || + node.local.type !== 'Identifier' || + node.local.name !== 'useGate' + ) { + return + } + if ( + node.parent.type !== 'ImportDeclaration' || + !node.parent.source || + node.parent.source.type !== 'Literal' + ) { + return + } + const source = node.parent.source.value + if (source.startsWith('.') || source.startsWith('#')) { + return + } + context.report({ + node, + message: + "Use useGate() from '#/lib/statsig/statsig' instead of the one on npm.", + }) + }, + } +} diff --git a/modules/expo-bluesky-oauth-client/android/build.gradle b/modules/expo-bluesky-oauth-client/android/build.gradle new file mode 100644 index 0000000000..f64e824a26 --- /dev/null +++ b/modules/expo-bluesky-oauth-client/android/build.gradle @@ -0,0 +1,93 @@ +apply plugin: 'com.android.library' +apply plugin: 'kotlin-android' +apply plugin: 'maven-publish' + +group = 'expo.modules.blueskyoauthclient' +version = '0.0.1' + +buildscript { + def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle") + if (expoModulesCorePlugin.exists()) { + apply from: expoModulesCorePlugin + applyKotlinExpoModulesCorePlugin() + } + + // Simple helper that allows the root project to override versions declared by this library. + ext.safeExtGet = { prop, fallback -> + rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback + } + + // Ensures backward compatibility + ext.getKotlinVersion = { + if (ext.has("kotlinVersion")) { + ext.kotlinVersion() + } else { + ext.safeExtGet("kotlinVersion", "1.8.10") + } + } + + repositories { + mavenCentral() + } + + dependencies { + classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:${getKotlinVersion()}") + } +} + +afterEvaluate { + publishing { + publications { + release(MavenPublication) { + from components.release + } + } + repositories { + maven { + url = mavenLocal().url + } + } + } +} + +android { + compileSdkVersion safeExtGet("compileSdkVersion", 33) + + def agpVersion = com.android.Version.ANDROID_GRADLE_PLUGIN_VERSION + if (agpVersion.tokenize('.')[0].toInteger() < 8) { + compileOptions { + sourceCompatibility JavaVersion.VERSION_11 + targetCompatibility JavaVersion.VERSION_11 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_11.majorVersion + } + } + + namespace "expo.modules.blueskyoauthclient" + defaultConfig { + minSdkVersion safeExtGet("minSdkVersion", 21) + targetSdkVersion safeExtGet("targetSdkVersion", 34) + versionCode 1 + versionName "0.0.1" + } + lintOptions { + abortOnError false + } + publishing { + singleVariant("release") { + withSourcesJar() + } + } +} + +repositories { + mavenCentral() +} + +dependencies { + implementation project(':expo-modules-core') + implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:${getKotlinVersion()}" + implementation "com.nimbusds:nimbus-jose-jwt:9.38-rc3" +} diff --git a/modules/expo-bluesky-oauth-client/android/src/main/AndroidManifest.xml b/modules/expo-bluesky-oauth-client/android/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..bdae66c8f5 --- /dev/null +++ b/modules/expo-bluesky-oauth-client/android/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + diff --git a/modules/expo-bluesky-oauth-client/android/src/main/java/expo/modules/blueskyoauthclient/CryptoUtil.kt b/modules/expo-bluesky-oauth-client/android/src/main/java/expo/modules/blueskyoauthclient/CryptoUtil.kt new file mode 100644 index 0000000000..2abce43009 --- /dev/null +++ b/modules/expo-bluesky-oauth-client/android/src/main/java/expo/modules/blueskyoauthclient/CryptoUtil.kt @@ -0,0 +1,52 @@ +package expo.modules.blueskyoauthclient + +import com.nimbusds.jose.Algorithm +import java.security.KeyPairGenerator +import java.security.MessageDigest +import java.security.interfaces.ECPublicKey +import java.security.interfaces.ECPrivateKey +import com.nimbusds.jose.jwk.Curve +import com.nimbusds.jose.jwk.ECKey +import com.nimbusds.jose.jwk.KeyUse +import java.util.UUID + +class CryptoUtil { + fun digest(data: ByteArray): ByteArray { + val digest = MessageDigest.getInstance("sha256") + return digest.digest(data) + } + + fun getRandomValues(byteLength: Int): ByteArray { + val random = ByteArray(byteLength) + java.security.SecureRandom().nextBytes(random) + return random + } + + fun generateKeyPair(keyId: String?): Pair { + val keyIdString = keyId ?: UUID.randomUUID().toString() + + val keyPairGen = KeyPairGenerator.getInstance("EC") + keyPairGen.initialize(Curve.P_256.toECParameterSpec()) + val keyPair = keyPairGen.generateKeyPair() + + val publicKey = keyPair.public as ECPublicKey + val privateKey = keyPair.private as ECPrivateKey + + val publicJwk = ECKey.Builder(Curve.P_256, publicKey) + .keyUse(KeyUse.SIGNATURE) + .algorithm(Algorithm.parse("ES256")) + .keyID(keyIdString) + .build() + val privateJwk = ECKey.Builder(Curve.P_256, publicKey) + .privateKey(privateKey) + .keyUse(KeyUse.SIGNATURE) + .keyID(keyIdString) + .algorithm(Algorithm.parse("ES256")) + .build() + + return Pair( + publicJwk.toString(), + privateJwk.toString() + ) + } +} diff --git a/modules/expo-bluesky-oauth-client/android/src/main/java/expo/modules/blueskyoauthclient/ExpoBlueskyOAuthClientModule.kt b/modules/expo-bluesky-oauth-client/android/src/main/java/expo/modules/blueskyoauthclient/ExpoBlueskyOAuthClientModule.kt new file mode 100644 index 0000000000..93906a3ab1 --- /dev/null +++ b/modules/expo-bluesky-oauth-client/android/src/main/java/expo/modules/blueskyoauthclient/ExpoBlueskyOAuthClientModule.kt @@ -0,0 +1,35 @@ +package expo.modules.blueskyoauthclient + +import expo.modules.kotlin.modules.Module +import expo.modules.kotlin.modules.ModuleDefinition + +class ExpoBlueskyOAuthClientModule : Module() { + override fun definition() = ModuleDefinition { + Name("ExpoBlueskyOAuthClient") + + AsyncFunction("digest") { value: ByteArray -> + return@AsyncFunction CryptoUtil().digest(value) + } + + Function("getRandomValues") { byteLength: Int -> + return@Function CryptoUtil().getRandomValues(byteLength) + } + + AsyncFunction("generateKeyPair") { keyId: String? -> + val res = CryptoUtil().generateKeyPair(keyId) + + return@AsyncFunction mapOf( + "publicKey" to res.first, + "privateKey" to res.second + ) + } + + AsyncFunction("createJwt") { jwkString: String, headerString: String, payloadString: String -> + return@AsyncFunction JWTUtil().createJwt(jwkString, headerString, payloadString) + } + + AsyncFunction("verifyJwt") { jwkString: String, tokenString: String, options: String? -> + return@AsyncFunction JWTUtil().verifyJwt(jwkString, tokenString, options) + } + } +} diff --git a/modules/expo-bluesky-oauth-client/android/src/main/java/expo/modules/blueskyoauthclient/JWTUtil.kt b/modules/expo-bluesky-oauth-client/android/src/main/java/expo/modules/blueskyoauthclient/JWTUtil.kt new file mode 100644 index 0000000000..8c099d198c --- /dev/null +++ b/modules/expo-bluesky-oauth-client/android/src/main/java/expo/modules/blueskyoauthclient/JWTUtil.kt @@ -0,0 +1,35 @@ +package expo.modules.blueskyoauthclient + +import com.nimbusds.jose.JWSHeader +import com.nimbusds.jose.crypto.ECDSASigner +import com.nimbusds.jose.crypto.ECDSAVerifier +import com.nimbusds.jose.jwk.ECKey +import com.nimbusds.jwt.JWTClaimsSet +import com.nimbusds.jwt.SignedJWT + + +class JWTUtil { + fun createJwt(jwkString: String, headerString: String, payloadString: String): String { + val key = ECKey.parse(jwkString) + val header = JWSHeader.parse(headerString) + val payload = JWTClaimsSet.parse(payloadString) + + val signer = ECDSASigner(key) + val jwt = SignedJWT(header, payload) + jwt.sign(signer) + + return jwt.serialize() + } + + fun verifyJwt(jwkString: String, tokenString: String, options: String?): Boolean { + return try { + val key = ECKey.parse(jwkString) + val jwt = SignedJWT.parse(tokenString) + val verifier = ECDSAVerifier(key) + + jwt.verify(verifier) + } catch(e: Exception) { + false + } + } +} diff --git a/modules/expo-bluesky-oauth-client/expo-module.config.json b/modules/expo-bluesky-oauth-client/expo-module.config.json new file mode 100644 index 0000000000..4e996dafe2 --- /dev/null +++ b/modules/expo-bluesky-oauth-client/expo-module.config.json @@ -0,0 +1,9 @@ +{ + "platforms": ["ios", "tvos", "android", "web"], + "ios": { + "modules": ["ExpoBlueskyOAuthClientModule"] + }, + "android": { + "modules": ["expo.modules.blueskyoauthclient.ExpoBlueskyOAuthClientModule"] + } +} diff --git a/modules/expo-bluesky-oauth-client/index.ts b/modules/expo-bluesky-oauth-client/index.ts new file mode 100644 index 0000000000..ca28960b93 --- /dev/null +++ b/modules/expo-bluesky-oauth-client/index.ts @@ -0,0 +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' diff --git a/modules/expo-bluesky-oauth-client/ios/CryptoUtil.swift b/modules/expo-bluesky-oauth-client/ios/CryptoUtil.swift new file mode 100644 index 0000000000..c9b95f81b9 --- /dev/null +++ b/modules/expo-bluesky-oauth-client/ios/CryptoUtil.swift @@ -0,0 +1,37 @@ +import CryptoKit +import JOSESwift + +class CryptoUtil { + // The equivalent of crypto.subtle.digest() with JS on web + public static func digest(data: Data) -> Data { + let hash = SHA256.hash(data: data) + return Data(hash) + } + + public static func getRandomValues(byteLength: Int) -> Data { + let bytes = (0.. (publicJWK: JWK, privateJWK: JWK)? { + let keyIdString = kid ?? UUID().uuidString + + let privateKey = P256.Signing.PrivateKey() + let publicKey = privateKey.publicKey + + let x = publicKey.x963Representation[1..<33].base64URLEncodedString() + let y = publicKey.x963Representation[33...].base64URLEncodedString() + let d = privateKey.rawRepresentation.base64URLEncodedString() + + let publicJWK = JWK(kty: "EC", use: "sig", crv: "P-256", kid: keyIdString, x: x, y: y, alg: "ES256") + let privateJWK = JWK(kty: "EC", use: "sig", crv: "P-256", kid: keyIdString, x: x, y: y, d: d, alg: "ES256") + + return (publicJWK, privateJWK) + } +} + +extension Data { + func base64URLEncodedString() -> String { + return self.base64EncodedString().replacingOccurrences(of: "+", with: "-").replacingOccurrences(of: "/", with: "_").replacingOccurrences(of: "=", with: "") + } +} diff --git a/modules/expo-bluesky-oauth-client/ios/ExpoBlueskyOAuthClient.podspec b/modules/expo-bluesky-oauth-client/ios/ExpoBlueskyOAuthClient.podspec new file mode 100644 index 0000000000..caeaaf4f2e --- /dev/null +++ b/modules/expo-bluesky-oauth-client/ios/ExpoBlueskyOAuthClient.podspec @@ -0,0 +1,22 @@ +Pod::Spec.new do |s| + s.name = 'ExpoBlueskyOAuthClient' + s.version = '0.0.1' + s.summary = 'A library of native functions to support Bluesky OAuth in React Native.' + s.description = 'A library of native functions to support Bluesky OAuth in React Native.' + s.author = '' + s.homepage = 'https://github.com/bluesky-social/social-app' + s.platforms = { :ios => '13.4', :tvos => '13.4' } + s.source = { git: '' } + s.static_framework = true + + s.dependency 'ExpoModulesCore' + s.dependency 'JOSESwift', '~> 2.3' + + # Swift/Objective-C compatibility + s.pod_target_xcconfig = { + 'DEFINES_MODULE' => 'YES', + 'SWIFT_COMPILATION_MODE' => 'wholemodule' + } + + s.source_files = "**/*.{h,m,mm,swift,hpp,cpp}" +end diff --git a/modules/expo-bluesky-oauth-client/ios/ExpoBlueskyOAuthClientModule.swift b/modules/expo-bluesky-oauth-client/ios/ExpoBlueskyOAuthClientModule.swift new file mode 100644 index 0000000000..729194916a --- /dev/null +++ b/modules/expo-bluesky-oauth-client/ios/ExpoBlueskyOAuthClientModule.swift @@ -0,0 +1,43 @@ +import ExpoModulesCore +import JOSESwift + +public class ExpoBlueskyOAuthClientModule: Module { + public func definition() -> ModuleDefinition { + Name("ExpoBlueskyOAuthClient") + + AsyncFunction("digest") { (data: Data, promise: Promise) in + promise.resolve(CryptoUtil.digest(data: data)) + } + + // We are going to leave this as sync to line up the APIs. It's fast, so not a big deal. + Function("getRandomValues") { (byteLength: Int) in + return CryptoUtil.getRandomValues(byteLength: byteLength) + } + + AsyncFunction ("generateKeyPair") { (kid: String?, promise: Promise) in + let keypair = try? CryptoUtil.generateKeyPair(kid: kid) + + guard let keypair = keypair else { + promise.reject("GenerateKeyError", "Error generating JWK.") + return + } + + promise.resolve([ + "publicKey": keypair.publicJWK.toJson(), + "privateKey": keypair.privateJWK.toJson() + ]) + } + + AsyncFunction("createJwt") { (jwk: String, header: String, payload: String, promise: Promise) in + guard let jwt = JWTUtil.createJwt(jwk, header: header, payload: payload) else { + promise.reject("JWTError", "Error creating JWT.") + return + } + promise.resolve(jwt) + } + + AsyncFunction("verifyJwt") { (jwk: String, token: String, options: String?, promise: Promise) in + promise.resolve(JWTUtil.verifyJwt(jwk, token: token, options: options)) + } + } +} diff --git a/modules/expo-bluesky-oauth-client/ios/JWK.swift b/modules/expo-bluesky-oauth-client/ios/JWK.swift new file mode 100644 index 0000000000..879d37a401 --- /dev/null +++ b/modules/expo-bluesky-oauth-client/ios/JWK.swift @@ -0,0 +1,29 @@ +struct JWK { + let kty: String + let use: String + let crv: String + let kid: String + let x: String + let y: String + var d: String? + let alg: String + + func toJson() -> String { + var dict: [String: Any] = [ + "kty": kty, + "use": use, + "crv": crv, + "kid": kid, + "x": x, + "y": y, + "alg": alg, + ] + + if let d = d { + dict["d"] = d + } + + let jsonData = try! JSONSerialization.data(withJSONObject: dict, options: []) + return String(data: jsonData, encoding: .utf8)! + } +} diff --git a/modules/expo-bluesky-oauth-client/ios/JWTUtil.swift b/modules/expo-bluesky-oauth-client/ios/JWTUtil.swift new file mode 100644 index 0000000000..d3dcdcee3d --- /dev/null +++ b/modules/expo-bluesky-oauth-client/ios/JWTUtil.swift @@ -0,0 +1,81 @@ +import JOSESwift + +class JWTUtil { + static func jsonToPrivateKey(_ jwkString: String) throws -> SecKey? { + guard let jsonData = jwkString.data(using: .utf8), + let jwk = try? JSONDecoder().decode(ECPrivateKey.self, from: jsonData), + let key = try? jwk.converted(to: SecKey.self) + else { + let jsonData = jwkString.data(using: .utf8)! + let jwk = try! JSONDecoder().decode(ECPrivateKey.self, from: jsonData) +// let key = try! jwk.converted(to: SecKey.self) + print("Error creating JWK from JWK string \(jwkString).") + return nil + } + + return key + } + + static func jsonToPublicKey(_ jwkString: String) throws -> SecKey? { + guard let jsonData = jwkString.data(using: .utf8), + let jwk = try? JSONDecoder().decode(ECPublicKey.self, from: jsonData), + let key = try? jwk.converted(to: SecKey.self) + else { + print("Error creating JWK from JWK string.") + return nil + } + + return key + } + + static func payloadStringToPayload(_ payloadString: String) -> Payload? { + guard let payloadData = payloadString.data(using: .utf8) else { + print("Error converting payload to data.") + return nil + } + + return Payload(payloadData) + } + + static func headerStringToPayload(_ headerString: String) -> JWSHeader? { + guard let headerData = headerString.data(using: .utf8) else { + print("Error converting header to data.") + return nil + } + + return JWSHeader(headerData) + } + + public static func createJwt(_ jwkString: String, header headerString: String, payload payloadString: String) -> String? { + guard let key = try? jsonToPrivateKey(jwkString), + let payload = payloadStringToPayload(payloadString), + let header = headerStringToPayload(headerString) + else + { + return nil + } + + let signer = Signer(signingAlgorithm: .ES256, key: key) + + guard let signer = signer, + let jws = try? JWS(header: header, payload: payload, signer: signer) + else { + print("Error creating JWS.") + return nil + } + + return jws.compactSerializedString + } + + public static func verifyJwt(_ jwkString: String, token tokenString: String, options optionsString: String?) -> Bool { + guard let key = try? jsonToPublicKey(jwkString), + let jws = try? JWS(compactSerialization: tokenString), + let verifier = Verifier(verifyingAlgorithm: .ES256, key: key), + let isVerified = try? jws.validate(using: verifier).isValid(for: verifier) + else { + return false + } + + return isVerified + } +} diff --git a/modules/expo-bluesky-oauth-client/src/crypto-subtle.ts b/modules/expo-bluesky-oauth-client/src/crypto-subtle.ts new file mode 100644 index 0000000000..c4375015a7 --- /dev/null +++ b/modules/expo-bluesky-oauth-client/src/crypto-subtle.ts @@ -0,0 +1,24 @@ +import {requireNativeModule} from 'expo-modules-core' +import {CryptoImplementation, Key} from '@atproto/oauth-client' + +import {RnCryptoKey} from './rn-crypto-key' + +// It loads the native module object from the JSI or falls back to +// the bridge module (from NativeModulesProxy) if the remote debugger is on. +const NativeModule = requireNativeModule('ExpoBlueskyOAuthClient') + +export class CryptoSubtle implements CryptoImplementation { + // We won't use the `algos` parameter here, as we will always use `ES256`. + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async createKey(algos: string[] = ['ES256']): Promise { + return await RnCryptoKey.generate(undefined, ['ES256']) + } + + getRandomValues(byteLength: number): Uint8Array { + return NativeModule.getRandomValues(byteLength) + } + + async digest(bytes: Uint8Array): Promise { + return await NativeModule.digest(bytes) + } +} diff --git a/modules/expo-bluesky-oauth-client/src/crypto-subtle.web.ts b/modules/expo-bluesky-oauth-client/src/crypto-subtle.web.ts new file mode 100644 index 0000000000..14936f65df --- /dev/null +++ b/modules/expo-bluesky-oauth-client/src/crypto-subtle.web.ts @@ -0,0 +1,48 @@ +import {WebcryptoKey} from '@atproto/jwk-webcrypto' +import {CryptoImplementation, DigestAlgorithm, Key} from '@atproto/oauth-client' + +// @ts-ignore web only, this silences some warnings +const crypto = global.crypto + +export class CryptoSubtle implements CryptoImplementation { + constructor(_: any) { + if (!crypto?.subtle) { + throw new Error( + 'Crypto with CryptoSubtle is required. If running in a browser, make sure the current page is loaded over HTTPS.', + ) + } + } + + async createKey(algs: string[]): Promise { + return WebcryptoKey.generate(undefined, algs) + } + + getRandomValues(byteLength: number): Uint8Array { + const bytes = new Uint8Array(byteLength) + crypto.getRandomValues(bytes) + return bytes + } + + async digest( + bytes: Uint8Array, + algorithm: DigestAlgorithm, + ): Promise { + const buffer = await crypto.subtle.digest( + digestAlgorithmToSubtle(algorithm), + bytes, + ) + return new Uint8Array(buffer) + } +} + +// @ts-ignore web only type +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}`) + } +} diff --git a/modules/expo-bluesky-oauth-client/src/jose-key.ts b/modules/expo-bluesky-oauth-client/src/jose-key.ts new file mode 100644 index 0000000000..fcf401e1dc --- /dev/null +++ b/modules/expo-bluesky-oauth-client/src/jose-key.ts @@ -0,0 +1,106 @@ +import {requireNativeModule} from 'expo-modules-core' +import {jwkSchema} from '@atproto/jwk' +import {Key} from '@atproto/jwk' +import { + exportJWK, + importJWK, + importPKCS8, + JWK, + KeyLike, + VerifyOptions, +} from 'jose' +import {JwtHeader, JwtPayload} from 'jwt-decode' + +const NativeModule = requireNativeModule('ExpoBlueskyOAuthClient') + +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): Promise { + 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}"`, + ) + } + + return await NativeModule.createJwt( + JSON.stringify(this.privateJwk), + JSON.stringify(header), + JSON.stringify(payload), + ) + } + + async verifyJwt< + P extends VerifyPayload = JwtPayload, + C extends string = string, + >(token: Jwt, options?: VerifyOptions): Promise> { + const result = await NativeModule.verifyJwt( + JSON.stringify(this.publicJwk), + token, + JSON.stringify(options), + ) + return result + // return result as VerifyResult + } + + static async fromImportable( + input: Importable, + kid?: string, + ): Promise { + 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 { + const keyLike = await importPKCS8(pem, '', {extractable: true}) + return this.fromJWK(await exportJWK(keyLike), kid) + } + + static async fromJWK( + input: string | Record, + inputKid?: string, + ): Promise { + 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}) + } +} diff --git a/modules/expo-bluesky-oauth-client/src/native-types.ts b/modules/expo-bluesky-oauth-client/src/native-types.ts new file mode 100644 index 0000000000..2ac1d7fa40 --- /dev/null +++ b/modules/expo-bluesky-oauth-client/src/native-types.ts @@ -0,0 +1,20 @@ +export interface CryptoKey { + algorithm: { + name: 'ECDSA' + namedCurve: 'P-256' + } + extractable: boolean + type: 'public' | 'private' + usages: ('sign' | 'verify')[] +} + +export interface NativeJWKKey { + crv: 'P-256' + ext: boolean + kty: 'EC' + x: string + y: string + use: 'sig' + alg: 'ES256' + kid: string +} diff --git a/modules/expo-bluesky-oauth-client/src/rn-crypto-key.ts b/modules/expo-bluesky-oauth-client/src/rn-crypto-key.ts new file mode 100644 index 0000000000..d5e4360889 --- /dev/null +++ b/modules/expo-bluesky-oauth-client/src/rn-crypto-key.ts @@ -0,0 +1,86 @@ +import {requireNativeModule} from 'expo-modules-core' +import {Jwk, jwkSchema} from '@atproto/jwk' + +import {JoseKey} from './jose-key' +import {CryptoKey, NativeJWKKey} from './native-types' + +const NativeModule = requireNativeModule('ExpoBlueskyOAuthClient') + +interface CryptoKeyPair { + privateKey: CryptoKey + publicKey: CryptoKey +} + +interface NativeJWKKeyPair { + privateKey: NativeJWKKey + publicKey: NativeJWKKey +} + +export class RnCryptoKey extends JoseKey { + static async generate( + kid: string | undefined, + _: string[] = ['ES256'], + __ = false, + ) { + const {privateKey, publicKey} = await NativeModule.generateKeyPair(kid) + + const nativeKeyPair = { + privateKey: JSON.parse(privateKey), + publicKey: JSON.parse(publicKey), + } + + return this.fromKeypair(nativeKeyPair.privateKey.kid, nativeKeyPair) + } + + static fromKeypair(kid: string, cryptoKeyPair: NativeJWKKeyPair) { + const use = cryptoKeyPair.privateKey.use ?? 'sig' + const alg = cryptoKeyPair.privateKey.alg ?? 'ES256' + + if (use !== 'sig') { + throw new TypeError('Unsupported JWK use') + } + + const webCryptoKeyPair: CryptoKeyPair = { + privateKey: { + algorithm: { + name: 'ECDSA', + namedCurve: 'P-256', + }, + extractable: true, + type: 'private', + usages: ['sign'], + }, + publicKey: { + algorithm: { + name: 'ECDSA', + namedCurve: 'P-256', + }, + extractable: true, + type: 'public', + usages: ['verify'], + }, + } + + return new RnCryptoKey( + jwkSchema.parse({...cryptoKeyPair.privateKey, use, kid, alg}), + webCryptoKeyPair, + ) + } + + 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 key is not exportable.') + } + + protected async getKey() { + return this.cryptoKeyPair.privateKey + } +} diff --git a/modules/expo-bluesky-oauth-client/src/rn-crypto-key.web.ts b/modules/expo-bluesky-oauth-client/src/rn-crypto-key.web.ts new file mode 100644 index 0000000000..b9fbd05758 --- /dev/null +++ b/modules/expo-bluesky-oauth-client/src/rn-crypto-key.web.ts @@ -0,0 +1,78 @@ +import {Jwk, jwkSchema} from '@atproto/jwk' +import {JoseKey} from '@atproto/jwk-jose' + +import {CryptoKey} from './native-types' +import {generateKeyPair, isSignatureKeyPair} from './util.web' + +// @ts-ignore web only, stops warnings for crypto being missing +const crypto = global.crypto + +// Global has this, but this stops the warnings +interface CryptoKeyPair { + privateKey: CryptoKey + publicKey: CryptoKey +} + +export class RNCryptoKey extends JoseKey { + static async generate( + kid: string = crypto.randomUUID(), + allowedAlgos: string[] = ['ES256'], + exportable = false, + ) { + const cryptoKeyPair: CryptoKeyPair = await generateKeyPair( + allowedAlgos, + exportable, + ) + return this.fromKeypair(kid, cryptoKeyPair) + } + + static async fromKeypair( + kid: string, + cryptoKeyPair: CryptoKeyPair, + ): Promise { + 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. + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + 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 ?? 'ES256' + + if (use !== 'sig') { + throw new TypeError('Unsupported JWK use') + } + + return new RNCryptoKey( + jwkSchema.parse({...jwk, use, kid, alg}), + cryptoKeyPair, + ) + } + + 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 key is not exportable.') + } + + protected async getKey() { + return this.cryptoKeyPair.privateKey + } +} diff --git a/modules/expo-bluesky-oauth-client/src/rn-oauth-client-factory.ts b/modules/expo-bluesky-oauth-client/src/rn-oauth-client-factory.ts new file mode 100644 index 0000000000..3e6f0c37cc --- /dev/null +++ b/modules/expo-bluesky-oauth-client/src/rn-oauth-client-factory.ts @@ -0,0 +1,153 @@ +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 {CryptoSubtle} from './crypto-subtle' +import { + BrowserOAuthDatabase, + DatabaseStore, + PopupStateData, +} from './rn-oauth-database' + +export type BrowserOauthClientFactoryOptions = { + responseMode?: OAuthResponseMode + responseType?: OAuthResponseType + clientMetadata: OAuthClientMetadata + fetch?: Fetch + crypto?: Crypto +} + +const POPUP_KEY_PREFIX = '@@oauth-popup-callback:' + +export class BrowserOAuthClientFactory extends OAuthClientFactory { + readonly popupStore: DatabaseStore + readonly sessionStore: DatabaseStore + + constructor({ + clientMetadata, + // "fragment" is safer as it is not sent to the server + responseMode = 'fragment', + responseType, + crypto = globalThis.crypto, + fetch = globalThis.fetch, + }: BrowserOauthClientFactoryOptions) { + const database = new BrowserOAuthDatabase() + + super({ + clientMetadata, + responseMode, + responseType, + fetch, + cryptoImplementation: new CryptoSubtle(crypto), + 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() + this.popupStore = database.getPopupStore() + } + + 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/rn-oauth-database.native.ts b/modules/expo-bluesky-oauth-client/src/rn-oauth-database.native.ts new file mode 100644 index 0000000000..8a10341910 --- /dev/null +++ b/modules/expo-bluesky-oauth-client/src/rn-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 BrowserOAuthDatabase { + 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/rn-oauth-database.ts b/modules/expo-bluesky-oauth-client/src/rn-oauth-database.ts new file mode 100644 index 0000000000..7b388dfc71 --- /dev/null +++ b/modules/expo-bluesky-oauth-client/src/rn-oauth-database.ts @@ -0,0 +1,269 @@ +import {GenericStore, Value} from '@atproto/caching' +import {DidDocument} from '@atproto/did' +import {ResolvedHandle} from '@atproto/handle-resolver' +import {DB, DBObjectStore} from '@atproto/indexed-db' +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' + +type Item = { + value: V + 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 PopupStateData = + | PromiseRejectedResult + | PromiseFulfilledResult + +export type Schema = { + popup: Item + 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 = [ + 'popup', + 'state', + 'session', + + 'didCache', + 'dpopNonceCache', + 'handleCache', + 'metadataCache', +] as const + +export class BrowserOAuthDatabase { + #dbPromise = DB.open( + '@atproto-oauth-client', + [ + db => { + for (const name of STORES) { + const store = db.createObjectStore(name) + store.createIndex('expiresAt', 'expiresAt', {unique: false}) + } + }, + ], + {durability: 'strict'}, + ) + + protected async run( + storeName: N, + mode: 'readonly' | 'readwrite', + fn: (s: DBObjectStore) => R | Promise, + ): Promise { + const db = await this.#dbPromise + return await db.transaction([storeName], mode, tx => + fn(tx.objectStore(storeName)), + ) + } + + protected createStore( + name: 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 => { + // Find item in store + const item = await this.run(name, 'readonly', dbStore => { + return dbStore.get(key) + }) + + // Not found + if (item === undefined) return undefined + + // Too old, proactively delete + if (item.expiresAt != null && item.expiresAt < new Date()) { + await this.run(name, 'readwrite', dbStore => { + return dbStore.delete(key) + }) + return undefined + } + + // Item found and valid. Decode + return decode(item.value) + }, + + getKeys: async () => { + const keys = await this.run(name, 'readonly', dbStore => { + return dbStore.getAllKeys() + }) + return keys.filter(key => typeof key === 'string') as string[] + }, + + set: async (key, value) => { + // Create encoded item record + const item = { + value: await encode(value), + expiresAt: maxAge == null ? null : new Date(Date.now() + maxAge), + } as Schema[N] + + // Store item record + await this.run(name, 'readwrite', dbStore => { + return dbStore.put(item, key) + }) + }, + + del: async key => { + // Delete + await this.run(name, 'readwrite', dbStore => { + return dbStore.delete(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), + }), + }) + } + + getPopupStore(): DatabaseStore { + return this.createStore('popup', { + encode: value => value, + decode: encoded => encoded, + }) + } + + 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() { + const db = await this.#dbPromise + const query = IDBKeyRange.lowerBound(new Date()) + const res = await db.transaction(STORES, 'readonly', tx => + 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]() { + // TODO: call cleanup at a constant interval ? + await this.cleanup() + + const db = await this.#dbPromise + await (db[Symbol.asyncDispose] || db[Symbol.dispose]).call(db) + } +} diff --git a/modules/expo-bluesky-oauth-client/src/store.ts b/modules/expo-bluesky-oauth-client/src/store.ts new file mode 100644 index 0000000000..e69de29bb2 diff --git a/modules/expo-bluesky-oauth-client/src/store.web.ts b/modules/expo-bluesky-oauth-client/src/store.web.ts new file mode 100644 index 0000000000..e69de29bb2 diff --git a/modules/expo-bluesky-oauth-client/src/util.web.ts b/modules/expo-bluesky-oauth-client/src/util.web.ts new file mode 100644 index 0000000000..654365aae5 --- /dev/null +++ b/modules/expo-bluesky-oauth-client/src/util.web.ts @@ -0,0 +1,41 @@ +// @ts-ignore web only, this silences errors throughout the whole file for crypto being missing +const crypto = global.crypto + +export async function generateKeyPair(algs: string[], extractable = false) { + const errors: unknown[] = [] + try { + return await crypto.subtle.generateKey( + { + name: 'ECDSA', + namedCurve: `P-256`, + }, + extractable, + ['sign', 'verify'], + ) + } catch (err) { + errors.push(err) + } + + console.log(errors) + throw new AggregateError(errors, 'Failed to generate keypair') +} + +export function isSignatureKeyPair( + v: unknown, + extractable?: boolean, +): v is CryptoKeyPair { + return ( + typeof v === 'object' && + v !== null && + 'privateKey' in v && + v.privateKey instanceof CryptoKey && + v.privateKey.type === 'private' && + (extractable == null || v.privateKey.extractable === extractable) && + v.privateKey.usages.includes('sign') && + 'publicKey' in v && + v.publicKey instanceof CryptoKey && + v.publicKey.type === 'public' && + v.publicKey.extractable === true && + v.publicKey.usages.includes('verify') + ) +} diff --git a/package.json b/package.json index e84e9b8146..d1d0c54c07 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bsky.app", - "version": "1.76.0", + "version": "1.77.0", "private": true, "engines": { "node": ">=18" diff --git a/src/lib/statsig/gates.ts b/src/lib/statsig/gates.ts new file mode 100644 index 0000000000..c755ad437e --- /dev/null +++ b/src/lib/statsig/gates.ts @@ -0,0 +1,8 @@ +export type Gate = + // Keep this alphabetic please. + | 'autoexpand_suggestions_on_profile_follow' + | 'disable_min_shell_on_foregrounding' + | 'disable_poll_on_discover' + | 'new_search' + | 'show_follow_back_label' + | 'start_session_with_following' diff --git a/src/lib/statsig/statsig.tsx b/src/lib/statsig/statsig.tsx index c164616217..159438647a 100644 --- a/src/lib/statsig/statsig.tsx +++ b/src/lib/statsig/statsig.tsx @@ -9,14 +9,21 @@ import { } from 'statsig-react-native-expo' import {logger} from '#/logger' +import {IS_TESTFLIGHT} from 'lib/app-info' import {useSession} from '../../state/session' import {LogEvents} from './events' +import {Gate} from './gates' export type {LogEvents} const statsigOptions = { environment: { - tier: process.env.NODE_ENV === 'development' ? 'development' : 'production', + tier: + process.env.NODE_ENV === 'development' + ? 'development' + : IS_TESTFLIGHT + ? 'staging' + : 'production', }, // Don't block on waiting for network. The fetched config will kick in on next load. // This ensures the UI is always consistent and doesn't update mid-session. @@ -69,7 +76,7 @@ export function logEvent( } } -export function useGate(gateName: string) { +export function useGate(gateName: Gate): boolean { const {isLoading, value} = useStatsigGate(gateName) if (isLoading) { // This should not happen because of waitForInitialization={true}. diff --git a/src/state/queries/search-posts.ts b/src/state/queries/search-posts.ts index 9bf3c0f9ec..ef8b083584 100644 --- a/src/state/queries/search-posts.ts +++ b/src/state/queries/search-posts.ts @@ -10,12 +10,19 @@ import {getAgent} from '#/state/session' import {embedViewRecordToPostView, getEmbeddedPost} from './util' const searchPostsQueryKeyRoot = 'search-posts' -const searchPostsQueryKey = ({query}: {query: string}) => [ +const searchPostsQueryKey = ({query, sort}: {query: string; sort?: string}) => [ searchPostsQueryKeyRoot, query, + sort, ] -export function useSearchPostsQuery({query}: {query: string}) { +export function useSearchPostsQuery({ + query, + sort, +}: { + query: string + sort?: 'top' | 'latest' +}) { return useInfiniteQuery< AppBskyFeedSearchPosts.OutputSchema, Error, @@ -23,14 +30,20 @@ export function useSearchPostsQuery({query}: {query: string}) { QueryKey, string | undefined >({ - queryKey: searchPostsQueryKey({query}), + queryKey: searchPostsQueryKey({query, sort}), queryFn: async ({pageParam}) => { - const res = await getAgent().app.bsky.feed.searchPosts({ - q: query, - limit: 25, - cursor: pageParam, - }) - return res.data + // waiting on new APIs + switch (sort) { + // case 'top': + // case 'latest': + default: + const res = await getAgent().app.bsky.feed.searchPosts({ + q: query, + limit: 25, + cursor: pageParam, + }) + return res.data + } }, initialPageParam: undefined, getNextPageParam: lastPage => lastPage.cursor, diff --git a/src/view/screens/Home.tsx b/src/view/screens/Home.tsx index 39bdac669c..a97f2d2986 100644 --- a/src/view/screens/Home.tsx +++ b/src/view/screens/Home.tsx @@ -19,6 +19,7 @@ import {Pager, PagerRef, RenderTabBarFnProps} from 'view/com/pager/Pager' import {CustomFeedEmptyState} from 'view/com/posts/CustomFeedEmptyState' import {FollowingEmptyState} from 'view/com/posts/FollowingEmptyState' import {FollowingEndOfFeed} from 'view/com/posts/FollowingEndOfFeed' +import {RnCryptoKey} from '../../../modules/expo-bluesky-oauth-client' import {HomeLoggedOutCTA} from '../com/auth/HomeLoggedOutCTA' import {HomeHeader} from '../com/home/HomeHeader' @@ -51,6 +52,18 @@ function HomeScreenReady({ preferences: UsePreferencesQueryResponse pinnedFeedInfos: FeedSourceInfo[] }) { + React.useEffect(() => { + ;(async () => { + const key = await RnCryptoKey.generate(undefined, ['ES256'], false) + console.log('public', key.publicJwk) + const jwt = await key.createJwt( + {alg: 'ES256', kid: key.kid}, + {sub: 'test'}, + ) + console.log(jwt) + })() + }, []) + const allFeeds = React.useMemo(() => { const feeds: FeedDescriptor[] = [] feeds.push('home') diff --git a/src/view/screens/Search/Search.tsx b/src/view/screens/Search/Search.tsx index c0f4cf1950..0f24252ce6 100644 --- a/src/view/screens/Search/Search.tsx +++ b/src/view/screens/Search/Search.tsx @@ -22,6 +22,7 @@ import {HITSLOP_10} from '#/lib/constants' import {usePalette} from '#/lib/hooks/usePalette' import {MagnifyingGlassIcon} from '#/lib/icons' import {NavigationProp} from '#/lib/routes/types' +import {useGate} from '#/lib/statsig/statsig' import {augmentSearchQuery} from '#/lib/strings/helpers' import {s} from '#/lib/styles' import {logger} from '#/logger' @@ -191,7 +192,13 @@ type SearchResultSlice = key: string } -function SearchScreenPostResults({query}: {query: string}) { +function SearchScreenPostResults({ + query, + sort, +}: { + query: string + sort?: 'top' | 'latest' +}) { const {_} = useLingui() const {currentAccount} = useSession() const [isPTR, setIsPTR] = React.useState(false) @@ -209,7 +216,7 @@ function SearchScreenPostResults({query}: {query: string}) { fetchNextPage, isFetchingNextPage, hasNextPage, - } = useSearchPostsQuery({query: augmentedQuery}) + } = useSearchPostsQuery({query: augmentedQuery, sort}) const onPullToRefresh = React.useCallback(async () => { setIsPTR(true) @@ -316,8 +323,6 @@ function SearchScreenUserResults({query}: {query: string}) { ) } -const SECTIONS_LOGGEDOUT = ['Users'] -const SECTIONS_LOGGEDIN = ['Posts', 'Users'] export function SearchScreenInner({ query, primarySearch, @@ -330,6 +335,9 @@ export function SearchScreenInner({ const setDrawerSwipeDisabled = useSetDrawerSwipeDisabled() const {hasSession} = useSession() const {isDesktop} = useWebMediaQueries() + const {_} = useLingui() + + const isNewSearch = useGate('new_search') const onPageSelected = React.useCallback( (index: number) => { @@ -339,6 +347,55 @@ export function SearchScreenInner({ [setDrawerSwipeDisabled, setMinimalShellMode], ) + const sections = React.useMemo(() => { + if (!query) return [] + if (isNewSearch) { + if (hasSession) { + return [ + { + title: _(msg`Top`), + component: , + }, + { + title: _(msg`Latest`), + component: , + }, + { + title: _(msg`People`), + component: , + }, + ] + } else { + return [ + { + title: _(msg`People`), + component: , + }, + ] + } + } else { + if (hasSession) { + return [ + { + title: _(msg`Posts`), + component: , + }, + { + title: _(msg`Users`), + component: , + }, + ] + } else { + return [ + { + title: _(msg`Users`), + component: , + }, + ] + } + } + }, [hasSession, isNewSearch, _, query]) + if (hasSession) { return query ? ( - + section.title)} {...props} /> )} initialPage={0}> - - - - - - + {sections.map((section, i) => ( + {section.component} + ))} ) : ( @@ -389,13 +443,13 @@ export function SearchScreenInner({ - + section.title)} {...props} /> )} initialPage={0}> - - - + {sections.map((section, i) => ( + {section.component} + ))} ) : (