diff --git a/metro.config.js b/metro.config.js index a49d95f9aa..80d2e34baf 100644 --- a/metro.config.js +++ b/metro.config.js @@ -1,7 +1,47 @@ // Learn more https://docs.expo.io/guides/customizing-metro +const path = require('path') const {getDefaultConfig} = require('expo/metro-config') const cfg = getDefaultConfig(__dirname) +if (process.env.ATPROTO_ROOT) { + const atprotoRoot = path.resolve(process.cwd(), process.env.ATPROTO_ROOT) + + // Watch folders are used as roots for the virtual file system. Any file that + // needs to be resolved by the metro bundler must be within one of the watch + // folders. Since we will be resolving dependencies from the atproto packages, + // we need to add the atproto root to the watch folders so that the + cfg.watchFolders ||= [] + cfg.watchFolders.push(atprotoRoot) + + const resolveRequest = cfg.resolver.resolveRequest + cfg.resolver.resolveRequest = (context, moduleName, platform) => { + // Alias @atproto/* modules to the corresponding package in the atproto root + if (moduleName.startsWith('@atproto/')) { + const [, packageName] = moduleName.split('/', 2) + const packagePath = path.join(atprotoRoot, 'packages', packageName) + return context.resolveRequest(context, packagePath, platform) + } + + // Polyfills are added by the build process and are not actual dependencies + // of the @atproto/* packages. Resolve those from here. + if ( + moduleName.startsWith('@babel/') && + context.originModulePath.startsWith(atprotoRoot) + ) { + return { + type: 'sourceFile', + filePath: require.resolve(moduleName), + } + } + + return (resolveRequest || context.resolveRequest)( + context, + moduleName, + platform, + ) + } +} + cfg.resolver.sourceExts = process.env.RN_SRC_EXT ? process.env.RN_SRC_EXT.split(',').concat(cfg.resolver.sourceExts) : cfg.resolver.sourceExts 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..e47060aa95 --- /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(): Map { + val keyIdString = 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 mapOf( + "privateKey" to privateJwk.toJSONString(), + "publicKey" to publicJwk.toJSONString() + ) + } +} 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..d97c5752f8 --- /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 android.util.Log +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, algorithmName: String -> + if(algorithmName != "sha256") throw Exception("Unsupported algorithm") + return@AsyncFunction CryptoUtil().digest(value) + } + + Function("getRandomValues") { byteLength: Int -> + return@Function CryptoUtil().getRandomValues(byteLength) + } + + AsyncFunction("generateJwk") { algorithim: String? -> + if (algorithim != "ES256") { + throw Exception("Unsupported algorithm") + } + return@AsyncFunction CryptoUtil().generateKeyPair() + } + + AsyncFunction("createJwt") { header: String, payload: String, jwk: String -> + return@AsyncFunction JWTUtil().createJwt(header, payload, jwk) + } + + AsyncFunction("verifyJwt") { token: String, jwk: String -> + return@AsyncFunction JWTUtil().verifyJwt(token, jwk) + } + } +} diff --git a/modules/expo-bluesky-oauth-client/android/src/main/java/expo/modules/blueskyoauthclient/JWK.kt b/modules/expo-bluesky-oauth-client/android/src/main/java/expo/modules/blueskyoauthclient/JWK.kt new file mode 100644 index 0000000000..9f9f1dd629 --- /dev/null +++ b/modules/expo-bluesky-oauth-client/android/src/main/java/expo/modules/blueskyoauthclient/JWK.kt @@ -0,0 +1,9 @@ +package expo.modules.blueskyoauthclient + +import expo.modules.kotlin.records.Record +import expo.modules.kotlin.records.Field + +class JWKPair( + @Field val privateKey: String, + @Field val publicKey: String +) : Record \ No newline at end of file diff --git a/modules/expo-bluesky-oauth-client/android/src/main/java/expo/modules/blueskyoauthclient/JWT.kt b/modules/expo-bluesky-oauth-client/android/src/main/java/expo/modules/blueskyoauthclient/JWT.kt new file mode 100644 index 0000000000..4990fa66a2 --- /dev/null +++ b/modules/expo-bluesky-oauth-client/android/src/main/java/expo/modules/blueskyoauthclient/JWT.kt @@ -0,0 +1,9 @@ +package expo.modules.blueskyoauthclient + +import expo.modules.kotlin.records.Record +import expo.modules.kotlin.records.Field + +class JWTVerifyResponse( + @Field var protectedHeader: String, + @Field var payload: String, +) : Record \ No newline at end of file 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..f01d4a79bc --- /dev/null +++ b/modules/expo-bluesky-oauth-client/android/src/main/java/expo/modules/blueskyoauthclient/JWTUtil.kt @@ -0,0 +1,44 @@ +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(header: String, payload: String, jwk: String): String { + val parsedKey = ECKey.parse(jwk) + val parsedHeader = JWSHeader.parse(header) + val parsedPayload = JWTClaimsSet.parse(payload) + + val signer = ECDSASigner(parsedKey) + val jwt = SignedJWT(parsedHeader, parsedPayload) + jwt.sign(signer) + + return jwt.serialize() + } + + fun verifyJwt(token: String, jwk: String): Map { + try { + val parsedKey = ECKey.parse(jwk) + val jwt = SignedJWT.parse(token) + val verifier = ECDSAVerifier(parsedKey) + + if (!jwt.verify(verifier)) { + throw Exception("Invalid signature") + } + + val header = jwt.header + val payload = jwt.payload + + return mapOf( + "payload" to payload.toString(), + "protectedHeader" to header.toString() + ) + } catch(e: Exception) { + throw e + } + } +} 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..79e975ebc9 --- /dev/null +++ b/modules/expo-bluesky-oauth-client/index.ts @@ -0,0 +1,4 @@ +export * from './src/oauth-client-react-native' +export * from './src/react-native-crypto-implementation' +export * from './src/react-native-key' +export * from './src/react-native-store-with-key' 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..a3c26069ae --- /dev/null +++ b/modules/expo-bluesky-oauth-client/ios/CryptoUtil.swift @@ -0,0 +1,50 @@ +import CryptoKit +import JOSESwift +import ExpoModulesCore + +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.. [String:String] { + let keyIdString = 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 [ + "privateKey": privateJWK.toJson(), + "publicKey": publicJWK.toJson() + ] + } +} + +extension Data { + func base64URLEncodedString() -> String { + return self.base64EncodedString().replacingOccurrences(of: "+", with: "-").replacingOccurrences(of: "/", with: "_").replacingOccurrences(of: "=", with: "") + } +} + +extension String { + func toField() -> Field { + return Field(wrappedValue: self) + } + func toNullableField() -> Field { + return Field(wrappedValue: self) + } +} 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..386b8d6430 --- /dev/null +++ b/modules/expo-bluesky-oauth-client/ios/ExpoBlueskyOAuthClientModule.swift @@ -0,0 +1,49 @@ +import ExpoModulesCore +import JOSESwift + +public class ExpoBlueskyOAuthClientModule: Module { + public func definition() -> ModuleDefinition { + Name("ExpoBlueskyOAuthClient") + + AsyncFunction("digest") { (data: Data, algorithmName: String, promise: Promise) in + if algorithmName != "sha256" { + promise.reject("Error", "Algorithim not supported") + return + } + 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 ("generateJwk") { (algo: String?, promise: Promise) in + if algo != "ES256" { + promise.reject("GenerateKeyError", "Algorithim not supported.") + return + } + + let keypair = try? CryptoUtil.generateKeyPair() + + guard keypair != nil else { + promise.reject("GenerateKeyError", "Error generating JWK.") + return + } + + promise.resolve(keypair) + } + + AsyncFunction("createJwt") { (header: String, payload: String, jwk: String, promise: Promise) in + guard let jwt = JWTUtil.createJwt(header: header, payload: payload, jwk: jwk) else { + promise.reject("JWTError", "Error creating JWT.") + return + } + promise.resolve(jwt) + } + + AsyncFunction("verifyJwt") { (token: String, jwk: String, promise: Promise) in + promise.resolve(JWTUtil.verifyJwt(token: token, jwk: jwk)) + } + } +} 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..72148a9cbf --- /dev/null +++ b/modules/expo-bluesky-oauth-client/ios/JWK.swift @@ -0,0 +1,32 @@ +import ExpoModulesCore +import JOSESwift + +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/JWT.swift b/modules/expo-bluesky-oauth-client/ios/JWT.swift new file mode 100644 index 0000000000..18c68cbf04 --- /dev/null +++ b/modules/expo-bluesky-oauth-client/ios/JWT.swift @@ -0,0 +1,9 @@ +import ExpoModulesCore +import JOSESwift + +struct JWTVerifyResponse : Record { + @Field + var payload: String? + @Field + var protectedHeader: String? +} 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..5997f587f1 --- /dev/null +++ b/modules/expo-bluesky-oauth-client/ios/JWTUtil.swift @@ -0,0 +1,106 @@ +import ExpoModulesCore +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 { + print("Error creating JWK from JWK string.") + 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(header: String, payload: String, jwk: String) -> String? { + guard let header = headerStringToPayload(header), + let payload = payloadStringToPayload(payload), + let key = try? jsonToPrivateKey(jwk) + 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(token: String, jwk: String) -> [String: Any]? { + guard let key = try? jsonToPublicKey(jwk), + let jws = try? JWS(compactSerialization: token), + let verifier = Verifier(verifyingAlgorithm: .ES256, key: key), + let validation = try? jws.validate(using: verifier) + else { + return nil + } + + let header = validation.header + let payload = String(data: validation.payload.data(), encoding: .utf8) + + guard let payload = payload else { + return nil + } + + var protectedHeader: [String:Any] = [:] + protectedHeader["alg"] = "ES256" + if header.jku != nil { + protectedHeader["jku"] = header.jku?.absoluteString + } + if header.kid != nil { + protectedHeader["kid"] = header.kid + } + if header.typ != nil { + protectedHeader["typ"] = header.typ + } + if header.cty != nil { + protectedHeader["cty"] = header.cty + } + if header.crit != nil { + protectedHeader["crit"] = header.crit + } + + return [ + "payload": payload, + "protectedHeader": protectedHeader + ] + } +} 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..cf29ac193e --- /dev/null +++ b/modules/expo-bluesky-oauth-client/src/oauth-client-react-native.ts @@ -0,0 +1,50 @@ +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) + */ + async digest(_bytes: Uint8Array, _algorithm: string): Promise { + 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) + */ + async generateJwk( + _algo: string, + ): Promise<{publicKey: string; privateKey: string}> { + throw new Error(LINKING_ERROR) + }, + + async createJwt( + _header: unknown, + _payload: unknown, + _jwk: unknown, + ): Promise { + throw new Error(LINKING_ERROR) + }, + + async verifyJwt( + _token: Jwt, + _jwk: Jwk, + ): Promise<{ + payload: string + protectedHeader: string + }> { + 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..26f58dc7b2 --- /dev/null +++ b/modules/expo-bluesky-oauth-client/src/react-native-crypto-implementation.ts @@ -0,0 +1,27 @@ +import {CryptoImplementation, DigestAlgorithm, Key} from '@atproto/oauth-client' + +import {OauthClientReactNative} from './oauth-client-react-native' +import {ReactNativeKey} from './react-native-key' + +export class ReactNativeCryptoImplementation implements CryptoImplementation { + async createKey(algs: string[]): Promise { + const bytes = await this.getRandomValues(12) + const kid = Array.from(bytes, byteToHex).join('') + return await 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..d13754ddce --- /dev/null +++ b/modules/expo-bluesky-oauth-client/src/react-native-key.ts @@ -0,0 +1,119 @@ +import { + jwkValidator, + Jwt, + JwtHeader, + 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 res = await OauthClientReactNative.generateJwk(algo) + const jwk = JSON.parse(res.privateKey) as Record + 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 await OauthClientReactNative.createJwt( + JSON.stringify(header), + JSON.stringify(payload), + JSON.stringify(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) + + let payloadParsed = JSON.parse(result.payload) + const payload = jwtPayloadSchema.parse(payloadParsed) + + let protectedHeaderParsed = JSON.parse(result.protectedHeader) + const protectedHeader = jwtPayloadSchema.parse(protectedHeaderParsed) + + 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}`) + } + } + } + + console.log(payload) + + 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-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..ed6a292c79 --- /dev/null +++ b/modules/expo-bluesky-oauth-client/src/react-native-oauth-client-factory.native.ts @@ -0,0 +1,149 @@ +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 + plcDirectoryUrl?: string + atprotoLexiconUrl?: string +} + +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, + plcDirectoryUrl, + atprotoLexiconUrl, + }: 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, + plcDirectoryUrl, + atprotoLexiconUrl, + 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) { + console.log(options) + return await this.authorize(input, options) + } + + async signInCallback(callback: string) { + // const redirectUri = new URL(this.clientMetadata.redirect_uris[0]) + // if (location.pathname !== redirectUri.pathname) return null + // + const params = new URL(callback).searchParams + // Only if the query string contains oauth callback params + if ( + !params.has('iss') || + !params.has('state') || + !(params.has('code') || params.has('error')) + ) { + console.log('no') + return null + } else { + console.log('has!') + } + // + // // 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..c85275f343 --- /dev/null +++ b/modules/expo-bluesky-oauth-client/src/react-native-oauth-database.native.ts @@ -0,0 +1,210 @@ +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: CryptoKey +} + +function encodeKey(key: Key): EncodedKey { + 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-with-key.ts b/modules/expo-bluesky-oauth-client/src/react-native-store-with-key.ts new file mode 100644 index 0000000000..435e566cf5 --- /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' +import {ReactNativeStore} from './react-native-store' + +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..0a3d186d07 --- /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.removeItem(key) + } +} 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' diff --git a/modules/expo-scroll-forwarder/src/ExpoScrollForwarderView.ios.tsx b/modules/expo-scroll-forwarder/src/ExpoScrollForwarderView.ios.tsx index a91aebd4dc..21a2b9fb26 100644 --- a/modules/expo-scroll-forwarder/src/ExpoScrollForwarderView.ios.tsx +++ b/modules/expo-scroll-forwarder/src/ExpoScrollForwarderView.ios.tsx @@ -1,5 +1,6 @@ -import {requireNativeViewManager} from 'expo-modules-core' import * as React from 'react' +import {requireNativeViewManager} from 'expo-modules-core' + import {ExpoScrollForwarderViewProps} from './ExpoScrollForwarder.types' const NativeView: React.ComponentType = diff --git a/modules/expo-scroll-forwarder/src/ExpoScrollForwarderView.tsx b/modules/expo-scroll-forwarder/src/ExpoScrollForwarderView.tsx index 93e69333fd..7eb52b78bd 100644 --- a/modules/expo-scroll-forwarder/src/ExpoScrollForwarderView.tsx +++ b/modules/expo-scroll-forwarder/src/ExpoScrollForwarderView.tsx @@ -1,4 +1,5 @@ import React from 'react' + import {ExpoScrollForwarderViewProps} from './ExpoScrollForwarder.types' export function ExpoScrollForwarderView({ children, diff --git a/package.json b/package.json index 7b4e454514..895d164568 100644 --- a/package.json +++ b/package.json @@ -136,6 +136,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", @@ -189,7 +190,7 @@ "tippy.js": "^6.3.7", "tlds": "^1.234.0", "zeego": "^1.6.2", - "zod": "^3.20.2" + "zod": "^3.22.4" }, "devDependencies": { "@atproto/dev-env": "^0.3.5", @@ -263,7 +264,8 @@ }, "resolutions": { "@types/react": "^18", - "**/zeed-dom": "0.10.9" + "**/zeed-dom": "0.10.9", + "browserify-sign": "4.2.2" }, "jest": { "preset": "jest-expo/ios", diff --git a/src/lib/oauth.ts b/src/lib/oauth.ts new file mode 100644 index 0000000000..391ca85059 --- /dev/null +++ b/src/lib/oauth.ts @@ -0,0 +1,12 @@ +import {isWeb} from 'platform/detection' + +export const OAUTH_CLIENT_ID = 'http://localhost/' +export const OAUTH_REDIRECT_URI = 'http://127.0.0.1:2583/' +export const OAUTH_SCOPE = 'openid profile email phone offline_access' +export const OAUTH_GRANT_TYPES = [ + 'authorization_code', + 'refresh_token', +] as const +export const OAUTH_RESPONSE_TYPES = ['code', 'code id_token'] as const +export const DPOP_BOUND_ACCESS_TOKENS = true +export const OAUTH_APPLICATION_TYPE = isWeb ? 'web' : 'native' // TODO what should we put here for native diff --git a/src/screens/Login/ForgotPasswordForm.tsx b/src/screens/Login/ForgotPasswordForm.tsx index ec30bab4a8..e69de29bb2 100644 --- a/src/screens/Login/ForgotPasswordForm.tsx +++ b/src/screens/Login/ForgotPasswordForm.tsx @@ -1,184 +0,0 @@ -import React, {useEffect, useState} from 'react' -import {ActivityIndicator, Keyboard, View} from 'react-native' -import {ComAtprotoServerDescribeServer} from '@atproto/api' -import {BskyAgent} from '@atproto/api' -import {msg, Trans} from '@lingui/macro' -import {useLingui} from '@lingui/react' -import * as EmailValidator from 'email-validator' - -import {useAnalytics} from '#/lib/analytics/analytics' -import {isNetworkError} from '#/lib/strings/errors' -import {cleanError} from '#/lib/strings/errors' -import {logger} from '#/logger' -import {atoms as a, useTheme} from '#/alf' -import {Button, ButtonText} from '#/components/Button' -import {FormError} from '#/components/forms/FormError' -import {HostingProvider} from '#/components/forms/HostingProvider' -import * as TextField from '#/components/forms/TextField' -import {At_Stroke2_Corner0_Rounded as At} from '#/components/icons/At' -import {Text} from '#/components/Typography' -import {FormContainer} from './FormContainer' - -type ServiceDescription = ComAtprotoServerDescribeServer.OutputSchema - -export const ForgotPasswordForm = ({ - error, - serviceUrl, - serviceDescription, - setError, - setServiceUrl, - onPressBack, - onEmailSent, -}: { - error: string - serviceUrl: string - serviceDescription: ServiceDescription | undefined - setError: (v: string) => void - setServiceUrl: (v: string) => void - onPressBack: () => void - onEmailSent: () => void -}) => { - const t = useTheme() - const [isProcessing, setIsProcessing] = useState(false) - const [email, setEmail] = useState('') - const {screen} = useAnalytics() - const {_} = useLingui() - - useEffect(() => { - screen('Signin:ForgotPassword') - }, [screen]) - - const onPressSelectService = React.useCallback(() => { - Keyboard.dismiss() - }, []) - - const onPressNext = async () => { - if (!EmailValidator.validate(email)) { - return setError(_(msg`Your email appears to be invalid.`)) - } - - setError('') - setIsProcessing(true) - - try { - const agent = new BskyAgent({service: serviceUrl}) - await agent.com.atproto.server.requestPasswordReset({email}) - onEmailSent() - } catch (e: any) { - const errMsg = e.toString() - logger.warn('Failed to request password reset', {error: e}) - setIsProcessing(false) - if (isNetworkError(e)) { - setError( - _( - msg`Unable to contact your service. Please check your Internet connection.`, - ), - ) - } else { - setError(cleanError(errMsg)) - } - } - } - - return ( - Reset password}> - - - Hosting provider - - - - - - Email address - - - - - - - - - - Enter the email you used to create your account. We'll send you a - "reset code" so you can set a new password. - - - - - - - - - {!serviceDescription || isProcessing ? ( - - ) : ( - - )} - {!serviceDescription || isProcessing ? ( - - Processing... - - ) : undefined} - - - - - - ) -} diff --git a/src/screens/Login/LoginForm.tsx b/src/screens/Login/LoginForm.tsx index 17fc323688..6f20354be0 100644 --- a/src/screens/Login/LoginForm.tsx +++ b/src/screens/Login/LoginForm.tsx @@ -1,34 +1,16 @@ -import React, {useRef, useState} from 'react' -import { - ActivityIndicator, - Keyboard, - LayoutAnimation, - TextInput, - View, -} from 'react-native' -import { - ComAtprotoServerCreateSession, - ComAtprotoServerDescribeServer, -} from '@atproto/api' +import React from 'react' +import {Keyboard, View} from 'react-native' +import {ComAtprotoServerDescribeServer} from '@atproto/api' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useAnalytics} from '#/lib/analytics/analytics' -import {isNetworkError} from '#/lib/strings/errors' -import {cleanError} from '#/lib/strings/errors' -import {createFullHandle} from '#/lib/strings/handles' -import {logger} from '#/logger' -import {useSessionApi} from '#/state/session' -import {atoms as a, useTheme} from '#/alf' -import {Button, ButtonIcon, ButtonText} from '#/components/Button' +import {useLogin} from '#/screens/Login/hooks/useLogin' +import {atoms as a} from '#/alf' +import {Button, ButtonText} from '#/components/Button' import {FormError} from '#/components/forms/FormError' import {HostingProvider} from '#/components/forms/HostingProvider' import * as TextField from '#/components/forms/TextField' -import {At_Stroke2_Corner0_Rounded as At} from '#/components/icons/At' -import {Lock_Stroke2_Corner0_Rounded as Lock} from '#/components/icons/Lock' -import {Ticket_Stroke2_Corner0_Rounded as Ticket} from '#/components/icons/Ticket' -import {Loader} from '#/components/Loader' -import {Text} from '#/components/Typography' import {FormContainer} from './FormContainer' type ServiceDescription = ComAtprotoServerDescribeServer.OutputSchema @@ -37,113 +19,26 @@ export const LoginForm = ({ error, serviceUrl, serviceDescription, - initialHandle, - setError, setServiceUrl, - onPressRetryConnect, onPressBack, - onPressForgotPassword, }: { error: string serviceUrl: string serviceDescription: ServiceDescription | undefined - initialHandle: string setError: (v: string) => void setServiceUrl: (v: string) => void onPressRetryConnect: () => void onPressBack: () => void - onPressForgotPassword: () => void }) => { const {track} = useAnalytics() - const t = useTheme() - const [isProcessing, setIsProcessing] = useState(false) - const [isAuthFactorTokenNeeded, setIsAuthFactorTokenNeeded] = - useState(false) - const [identifier, setIdentifier] = useState(initialHandle) - const [password, setPassword] = useState('') - const [authFactorToken, setAuthFactorToken] = useState('') - const passwordInputRef = useRef(null) const {_} = useLingui() - const {login} = useSessionApi() + const {openAuthSession} = useLogin() const onPressSelectService = React.useCallback(() => { Keyboard.dismiss() track('Signin:PressedSelectService') }, [track]) - const onPressNext = async () => { - if (isProcessing) return - Keyboard.dismiss() - LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut) - setError('') - setIsProcessing(true) - - try { - // try to guess the handle if the user just gave their own username - let fullIdent = identifier - if ( - !identifier.includes('@') && // not an email - !identifier.includes('.') && // not a domain - serviceDescription && - serviceDescription.availableUserDomains.length > 0 - ) { - let matched = false - for (const domain of serviceDescription.availableUserDomains) { - if (fullIdent.endsWith(domain)) { - matched = true - } - } - if (!matched) { - fullIdent = createFullHandle( - identifier, - serviceDescription.availableUserDomains[0], - ) - } - } - - // TODO remove double login - await login( - { - service: serviceUrl, - identifier: fullIdent, - password, - authFactorToken: authFactorToken.trim(), - }, - 'LoginForm', - ) - } catch (e: any) { - const errMsg = e.toString() - LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut) - setIsProcessing(false) - if ( - e instanceof ComAtprotoServerCreateSession.AuthFactorTokenRequiredError - ) { - setIsAuthFactorTokenNeeded(true) - } else if (errMsg.includes('Token is invalid')) { - logger.debug('Failed to login due to invalid 2fa token', { - error: errMsg, - }) - setError(_(msg`Invalid 2FA confirmation code.`)) - } else if (errMsg.includes('Authentication Required')) { - logger.debug('Failed to login due to invalid credentials', { - error: errMsg, - }) - setError(_(msg`Invalid username or password`)) - } else if (isNetworkError(e)) { - logger.warn('Failed to login due to network error', {error: errMsg}) - setError( - _( - msg`Unable to contact your service. Please check your Internet connection.`, - ), - ) - } else { - logger.warn('Failed to login', {error: errMsg}) - setError(cleanError(errMsg)) - } - } - } - - const isReady = !!serviceDescription && !!identifier && !!password return ( Sign in}> @@ -156,115 +51,8 @@ export const LoginForm = ({ onOpenDialog={onPressSelectService} /> - - - Account - - - - - { - passwordInputRef.current?.focus() - }} - blurOnSubmit={false} // prevents flickering due to onSubmitEditing going to next field - value={identifier} - onChangeText={str => - setIdentifier((str || '').toLowerCase().trim()) - } - editable={!isProcessing} - accessibilityHint={_( - msg`Input the username or email address you used at signup`, - )} - /> - - - - - - - - - - {isAuthFactorTokenNeeded && ( - - - 2FA Confirmation - - - - - - - Check your email for a login code and enter it here. - - - )} - + - - {!serviceDescription && error ? ( - - ) : !serviceDescription ? ( - <> - - - Connecting... - - - ) : isReady ? ( - - ) : undefined} + ) diff --git a/src/screens/Login/PasswordUpdatedForm.tsx b/src/screens/Login/PasswordUpdatedForm.tsx deleted file mode 100644 index 5407f3f1e3..0000000000 --- a/src/screens/Login/PasswordUpdatedForm.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import React, {useEffect} from 'react' -import {View} from 'react-native' -import {msg, Trans} from '@lingui/macro' -import {useLingui} from '@lingui/react' - -import {useAnalytics} from '#/lib/analytics/analytics' -import {atoms as a, useBreakpoints} from '#/alf' -import {Button, ButtonText} from '#/components/Button' -import {Text} from '#/components/Typography' -import {FormContainer} from './FormContainer' - -export const PasswordUpdatedForm = ({ - onPressNext, -}: { - onPressNext: () => void -}) => { - const {screen} = useAnalytics() - const {_} = useLingui() - const {gtMobile} = useBreakpoints() - - useEffect(() => { - screen('Signin:PasswordUpdatedForm') - }, [screen]) - - return ( - - - Password updated! - - - You can now sign in with your new password. - - - - - - ) -} diff --git a/src/screens/Login/SetNewPasswordForm.tsx b/src/screens/Login/SetNewPasswordForm.tsx index 88f7ec5416..e69de29bb2 100644 --- a/src/screens/Login/SetNewPasswordForm.tsx +++ b/src/screens/Login/SetNewPasswordForm.tsx @@ -1,192 +0,0 @@ -import React, {useEffect, useState} from 'react' -import {ActivityIndicator, View} from 'react-native' -import {BskyAgent} from '@atproto/api' -import {msg, Trans} from '@lingui/macro' -import {useLingui} from '@lingui/react' - -import {useAnalytics} from '#/lib/analytics/analytics' -import {isNetworkError} from '#/lib/strings/errors' -import {cleanError} from '#/lib/strings/errors' -import {checkAndFormatResetCode} from '#/lib/strings/password' -import {logger} from '#/logger' -import {atoms as a, useTheme} from '#/alf' -import {Button, ButtonText} from '#/components/Button' -import {FormError} from '#/components/forms/FormError' -import * as TextField from '#/components/forms/TextField' -import {Lock_Stroke2_Corner0_Rounded as Lock} from '#/components/icons/Lock' -import {Ticket_Stroke2_Corner0_Rounded as Ticket} from '#/components/icons/Ticket' -import {Text} from '#/components/Typography' -import {FormContainer} from './FormContainer' - -export const SetNewPasswordForm = ({ - error, - serviceUrl, - setError, - onPressBack, - onPasswordSet, -}: { - error: string - serviceUrl: string - setError: (v: string) => void - onPressBack: () => void - onPasswordSet: () => void -}) => { - const {screen} = useAnalytics() - const {_} = useLingui() - const t = useTheme() - - useEffect(() => { - screen('Signin:SetNewPasswordForm') - }, [screen]) - - const [isProcessing, setIsProcessing] = useState(false) - const [resetCode, setResetCode] = useState('') - const [password, setPassword] = useState('') - - const onPressNext = async () => { - // Check that the code is correct. We do this again just incase the user enters the code after their pw and we - // don't get to call onBlur first - const formattedCode = checkAndFormatResetCode(resetCode) - // TODO Better password strength check - if (!formattedCode || !password) { - setError( - _( - msg`You have entered an invalid code. It should look like XXXXX-XXXXX.`, - ), - ) - return - } - - setError('') - setIsProcessing(true) - - try { - const agent = new BskyAgent({service: serviceUrl}) - await agent.com.atproto.server.resetPassword({ - token: formattedCode, - password, - }) - onPasswordSet() - } catch (e: any) { - const errMsg = e.toString() - logger.warn('Failed to set new password', {error: e}) - setIsProcessing(false) - if (isNetworkError(e)) { - setError( - _( - msg`Unable to contact your service. Please check your Internet connection.`, - ), - ) - } else { - setError(cleanError(errMsg)) - } - } - } - - const onBlur = () => { - const formattedCode = checkAndFormatResetCode(resetCode) - if (!formattedCode) { - setError( - _( - msg`You have entered an invalid code. It should look like XXXXX-XXXXX.`, - ), - ) - return - } - setResetCode(formattedCode) - } - - return ( - Set new password}> - - - You will receive an email with a "reset code." Enter that code here, - then enter your new password. - - - - - Reset code - - - setError('')} - onBlur={onBlur} - editable={!isProcessing} - accessibilityHint={_( - msg`Input code sent to your email for password reset`, - )} - /> - - - - - New password - - - - - - - - - - - - {isProcessing ? ( - - ) : ( - - )} - {isProcessing ? ( - - Updating... - - ) : undefined} - - - ) -} diff --git a/src/screens/Login/hooks/package.json b/src/screens/Login/hooks/package.json new file mode 100644 index 0000000000..3a1fb0a9b1 --- /dev/null +++ b/src/screens/Login/hooks/package.json @@ -0,0 +1,14 @@ +{ + "name": "hooks", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "repository": { + "type": "git", + "url": "https://github.com/bluesky-social/social-app.git" + }, + "private": true +} diff --git a/src/screens/Login/hooks/useLogin.ts b/src/screens/Login/hooks/useLogin.ts new file mode 100644 index 0000000000..1aa01b040d --- /dev/null +++ b/src/screens/Login/hooks/useLogin.ts @@ -0,0 +1,48 @@ +import React from 'react' +import * as Browser from 'expo-web-browser' + +import { + DPOP_BOUND_ACCESS_TOKENS, + OAUTH_APPLICATION_TYPE, + OAUTH_CLIENT_ID, + OAUTH_GRANT_TYPES, + OAUTH_REDIRECT_URI, + OAUTH_RESPONSE_TYPES, + OAUTH_SCOPE, +} from 'lib/oauth' +import {RNOAuthClientFactory} from '../../../../modules/expo-bluesky-oauth-client/src/react-native-oauth-client-factory.native' + +// Service URL here is just a placeholder, this isn't how it will actually work +export function useLogin() { + const openAuthSession = React.useCallback(async () => { + const oauthFactory = new RNOAuthClientFactory({ + clientMetadata: { + client_id: OAUTH_CLIENT_ID, + redirect_uris: [OAUTH_REDIRECT_URI], + grant_types: OAUTH_GRANT_TYPES, + response_types: OAUTH_RESPONSE_TYPES, + scope: OAUTH_SCOPE, + dpop_bound_access_tokens: DPOP_BOUND_ACCESS_TOKENS, + application_type: OAUTH_APPLICATION_TYPE, + }, + fetch: global.fetch, + }) + + const url = await oauthFactory.signIn('http://localhost:2583/') + + console.log(url.href) + + const authSession = await Browser.openAuthSessionAsync( + url.href, + OAUTH_REDIRECT_URI, + ) + + if (authSession.type !== 'success') { + return + } + }, []) + + return { + openAuthSession, + } +} diff --git a/src/screens/Login/hooks/useLogin.web.ts b/src/screens/Login/hooks/useLogin.web.ts new file mode 100644 index 0000000000..6c16bc1810 --- /dev/null +++ b/src/screens/Login/hooks/useLogin.web.ts @@ -0,0 +1,13 @@ +import React from 'react' + +export function useLogin(serviceUrl: string | undefined) { + const openAuthSession = React.useCallback(async () => { + if (!serviceUrl) return + + window.location.href = serviceUrl + }, [serviceUrl]) + + return { + openAuthSession, + } +} diff --git a/src/screens/Login/index.tsx b/src/screens/Login/index.tsx index 1fce63d298..42b355a730 100644 --- a/src/screens/Login/index.tsx +++ b/src/screens/Login/index.tsx @@ -4,17 +4,13 @@ import {LayoutAnimationConfig} from 'react-native-reanimated' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' -import {useAnalytics} from '#/lib/analytics/analytics' import {DEFAULT_SERVICE} from '#/lib/constants' import {logger} from '#/logger' import {useServiceQuery} from '#/state/queries/service' import {SessionAccount, useSession} from '#/state/session' import {useLoggedOutView} from '#/state/shell/logged-out' import {LoggedOutLayout} from '#/view/com/util/layouts/LoggedOutLayout' -import {ForgotPasswordForm} from '#/screens/Login/ForgotPasswordForm' import {LoginForm} from '#/screens/Login/LoginForm' -import {PasswordUpdatedForm} from '#/screens/Login/PasswordUpdatedForm' -import {SetNewPasswordForm} from '#/screens/Login/SetNewPasswordForm' import {atoms as a} from '#/alf' import {ChooseAccountForm} from './ChooseAccountForm' import {ScreenTransition} from './ScreenTransition' @@ -22,16 +18,12 @@ import {ScreenTransition} from './ScreenTransition' enum Forms { Login, ChooseAccount, - ForgotPassword, - SetNewPassword, - PasswordUpdated, } export const Login = ({onPressBack}: {onPressBack: () => void}) => { const {_} = useLingui() const {accounts} = useSession() - const {track} = useAnalytics() const {requestedAccountSwitchTo} = useLoggedOutView() const requestedAccount = accounts.find( acc => acc.did === requestedAccountSwitchTo, @@ -41,9 +33,6 @@ export const Login = ({onPressBack}: {onPressBack: () => void}) => { const [serviceUrl, setServiceUrl] = React.useState( requestedAccount?.service || DEFAULT_SERVICE, ) - const [initialHandle, setInitialHandle] = React.useState( - requestedAccount?.handle || '', - ) const [currentForm, setCurrentForm] = React.useState( requestedAccount ? Forms.Login @@ -62,7 +51,7 @@ export const Login = ({onPressBack}: {onPressBack: () => void}) => { if (account?.service) { setServiceUrl(account.service) } - setInitialHandle(account?.handle || '') + // TODO set the service URL. We really need to fix this though in general setCurrentForm(Forms.Login) } @@ -86,11 +75,6 @@ export const Login = ({onPressBack}: {onPressBack: () => void}) => { } }, [serviceError, serviceUrl, _]) - const onPressForgotPassword = () => { - track('Signin:PressedForgotPassword') - setCurrentForm(Forms.ForgotPassword) - } - let content = null let title = '' let description = '' @@ -104,13 +88,11 @@ export const Login = ({onPressBack}: {onPressBack: () => void}) => { error={error} serviceUrl={serviceUrl} serviceDescription={serviceDescription} - initialHandle={initialHandle} setError={setError} setServiceUrl={setServiceUrl} onPressBack={() => accounts.length ? gotoForm(Forms.ChooseAccount) : onPressBack() } - onPressForgotPassword={onPressForgotPassword} onPressRetryConnect={refetchService} /> ) @@ -125,41 +107,6 @@ export const Login = ({onPressBack}: {onPressBack: () => void}) => { /> ) break - case Forms.ForgotPassword: - title = _(msg`Forgot Password`) - description = _(msg`Let's get your password reset!`) - content = ( - gotoForm(Forms.Login)} - onEmailSent={() => gotoForm(Forms.SetNewPassword)} - /> - ) - break - case Forms.SetNewPassword: - title = _(msg`Forgot Password`) - description = _(msg`Let's get your password reset!`) - content = ( - gotoForm(Forms.ForgotPassword)} - onPasswordSet={() => gotoForm(Forms.PasswordUpdated)} - /> - ) - break - case Forms.PasswordUpdated: - title = _(msg`Password updated`) - description = _(msg`You can now sign in with your new password.`) - content = ( - gotoForm(Forms.Login)} /> - ) - break } return ( diff --git a/webpack.config.js b/webpack.config.js index 6f1de3b8b7..5aa2d47f5b 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -1,3 +1,5 @@ +const fs = require('fs') +const path = require('path') const createExpoWebpackConfigAsync = require('@expo/webpack-config') const {withAlias} = require('@expo/webpack-config/addons') const ReactRefreshWebpackPlugin = require('@pmmmwh/react-refresh-webpack-plugin') @@ -22,6 +24,25 @@ module.exports = async function (env, argv) { 'react-native$': 'react-native-web', 'react-native-webview': 'react-native-web-webview', }) + + if (process.env.ATPROTO_ROOT) { + const atprotoRoot = path.resolve(process.cwd(), process.env.ATPROTO_ROOT) + const atprotoPackages = path.join(atprotoRoot, 'packages') + + config = withAlias( + config, + Object.fromEntries( + fs + .readdirSync(atprotoPackages) + .map(pkgName => [pkgName, path.join(atprotoPackages, pkgName)]) + .filter(([_, pkgPath]) => + fs.existsSync(path.join(pkgPath, 'package.json')), + ) + .map(([pkgName, pkgPath]) => [`@atproto/${pkgName}`, pkgPath]), + ), + ) + } + config.module.rules = [ ...(config.module.rules || []), reactNativeWebWebviewConfiguration, diff --git a/yarn.lock b/yarn.lock index f9e8644cb1..63b027fed6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8616,6 +8616,15 @@ asap@~2.0.3, asap@~2.0.6: resolved "https://registry.yarnpkg.com/asap/-/asap-2.0.6.tgz#e50347611d7e690943208bbdafebcbc2fb866d46" integrity sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA== +asn1.js@^4.10.1: + version "4.10.1" + resolved "https://registry.yarnpkg.com/asn1.js/-/asn1.js-4.10.1.tgz#b9c2bf5805f1e64aadeed6df3a2bfafb5a73f5a0" + integrity sha512-p32cOF5q0Zqs9uBiONKYLm6BClCoBCM5O9JfeUSlnQLBTxYdTK+pW+nXflm8UkKd2UYlEbYz5qEi0JuZR9ckSw== + dependencies: + bn.js "^4.0.0" + inherits "^2.0.1" + minimalistic-assert "^1.0.0" + asn1.js@^5.0.1: version "5.4.1" resolved "https://registry.yarnpkg.com/asn1.js/-/asn1.js-5.4.1.tgz#11a980b84ebb91781ce35b0fdc2ee294e3783f07" @@ -9140,6 +9149,11 @@ bn.js@^4.0.0, bn.js@^4.11.8, bn.js@^4.11.9: resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-4.12.0.tgz#775b3f278efbb9718eec7361f483fb36fbbfea88" integrity sha512-c98Bf3tPniI+scsdk237ku1Dc3ujXQTSgyiPUDEOe7tRkhrqridvh8klBv0HCEso1OLOYcHuCv/cS6DNxKH+ZA== +bn.js@^5.0.0, bn.js@^5.2.1: + version "5.2.1" + resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-5.2.1.tgz#0bc527a6a0d18d0aa8d5b0538ce4a77dccfa7b70" + integrity sha512-eXRvHzWyYPBuB4NBy0cmYQjGitUrtqwbvlzP3G6VFnNRbsZQIxQ10PbKKHt8gZ/HW/D/747aDl+QkDqg3KQLMQ== + body-parser@1.20.1: version "1.20.1" resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.20.1.tgz#b1812a8912c195cd371a3ee5e66faa2338a5c668" @@ -9236,6 +9250,41 @@ browser-process-hrtime@^1.0.0: resolved "https://registry.yarnpkg.com/browser-process-hrtime/-/browser-process-hrtime-1.0.0.tgz#3c9b4b7d782c8121e56f10106d84c0d0ffc94626" integrity sha512-9o5UecI3GhkpM6DrXr69PblIuWxPKk9Y0jHBRhdocZ2y7YECBFCsHm79Pr3OyR2AvjhDkabFJaDJMYRazHgsow== +browserify-aes@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/browserify-aes/-/browserify-aes-1.2.0.tgz#326734642f403dabc3003209853bb70ad428ef48" + integrity sha512-+7CHXqGuspUn/Sl5aO7Ea0xWGAtETPXNSAjHo48JfLdPWcMng33Xe4znFvQweqc/uzk5zSOI3H52CYnjCfb5hA== + dependencies: + buffer-xor "^1.0.3" + cipher-base "^1.0.0" + create-hash "^1.1.0" + evp_bytestokey "^1.0.3" + inherits "^2.0.1" + safe-buffer "^5.0.1" + +browserify-rsa@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/browserify-rsa/-/browserify-rsa-4.1.0.tgz#b2fd06b5b75ae297f7ce2dc651f918f5be158c8d" + integrity sha512-AdEER0Hkspgno2aR97SAf6vi0y0k8NuOpGnVH3O99rcA5Q6sh8QxcngtHuJ6uXwnfAXNM4Gn1Gb7/MV1+Ymbog== + dependencies: + bn.js "^5.0.0" + randombytes "^2.0.1" + +browserify-sign@4.2.2: + version "4.2.2" + resolved "https://registry.yarnpkg.com/browserify-sign/-/browserify-sign-4.2.2.tgz#e78d4b69816d6e3dd1c747e64e9947f9ad79bc7e" + integrity sha512-1rudGyeYY42Dk6texmv7c4VcQ0EsvVbLwZkA+AQB7SxvXxmcD93jcHie8bzecJ+ChDlmAm2Qyu0+Ccg5uhZXCg== + dependencies: + bn.js "^5.2.1" + browserify-rsa "^4.1.0" + create-hash "^1.2.0" + create-hmac "^1.1.7" + elliptic "^6.5.4" + inherits "^2.0.4" + parse-asn1 "^5.1.6" + readable-stream "^3.6.2" + safe-buffer "^5.2.1" + browserslist@^4.0.0, browserslist@^4.14.5, browserslist@^4.18.1, browserslist@^4.21.10, browserslist@^4.21.4, browserslist@^4.21.9: version "4.21.10" resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.21.10.tgz#dbbac576628c13d3b2231332cb2ec5a46e015bb0" @@ -9281,6 +9330,11 @@ buffer-writer@2.0.0: resolved "https://registry.yarnpkg.com/buffer-writer/-/buffer-writer-2.0.0.tgz#ce7eb81a38f7829db09c873f2fbb792c0c98ec04" integrity sha512-a7ZpuTZU1TRtnwyCNW3I5dc0wWNC3VR9S++Ewyk2HHZdrO3CQJqSpd+95Us590V6AL7JqUAH2IwZ/398PmNFgw== +buffer-xor@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/buffer-xor/-/buffer-xor-1.0.3.tgz#26e61ed1422fb70dd42e6e36729ed51d855fe8d9" + integrity sha512-571s0T7nZWK6vB67HI5dyUF7wXiNcfaPPPTl6zYCNApANjIvYJTg7hlud/+cJpdAhS7dVzqMLmfhfHR3rAcOjQ== + buffer@5.6.0: version "5.6.0" resolved "https://registry.yarnpkg.com/buffer/-/buffer-5.6.0.tgz#a31749dc7d81d84db08abf937b6b8c4033f62786" @@ -9640,6 +9694,14 @@ ci-info@^3.2.0, ci-info@^3.3.0: resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-3.8.0.tgz#81408265a5380c929f0bc665d62256628ce9ef91" integrity sha512-eXTggHWSooYhq49F2opQhuHWgzucfF2YgODK4e1566GQs5BIfP30B0oenwBJHfWxAs2fyPB1s7Mg949zLf61Yw== +cipher-base@^1.0.0, cipher-base@^1.0.1, cipher-base@^1.0.3: + version "1.0.4" + resolved "https://registry.yarnpkg.com/cipher-base/-/cipher-base-1.0.4.tgz#8760e4ecc272f4c363532f926d874aae2c1397de" + integrity sha512-Kkht5ye6ZGmwv40uUDZztayT2ThLQGfnj/T71N/XzeZeo3nf8foyW7zGTsPYkEya3m5f3cAypH+qe7YOrM1U2Q== + dependencies: + inherits "^2.0.1" + safe-buffer "^5.0.1" + cjs-module-lexer@^1.0.0: version "1.2.3" resolved "https://registry.yarnpkg.com/cjs-module-lexer/-/cjs-module-lexer-1.2.3.tgz#6c370ab19f8a3394e318fe682686ec0ac684d107" @@ -10080,6 +10142,29 @@ cosmiconfig@^8.0.0: parse-json "^5.2.0" path-type "^4.0.0" +create-hash@^1.1.0, create-hash@^1.1.2, create-hash@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/create-hash/-/create-hash-1.2.0.tgz#889078af11a63756bcfb59bd221996be3a9ef196" + integrity sha512-z00bCGNHDG8mHAkP7CtT1qVu+bFQUPjYq/4Iv3C3kWjTFV10zIjfSoeqXo9Asws8gwSHDGj/hl2u4OGIjapeCg== + dependencies: + cipher-base "^1.0.1" + inherits "^2.0.1" + md5.js "^1.3.4" + ripemd160 "^2.0.1" + sha.js "^2.4.0" + +create-hmac@^1.1.4, create-hmac@^1.1.7: + version "1.1.7" + resolved "https://registry.yarnpkg.com/create-hmac/-/create-hmac-1.1.7.tgz#69170c78b3ab957147b2b8b04572e47ead2243ff" + integrity sha512-MJG9liiZ+ogc4TzUwuvbER1JRdgvUFSB5+VR/g5h82fGaIRWMWddtKBHi7/sVhfjQZ6SehlyhvQYrcYkaUIpLg== + dependencies: + cipher-base "^1.0.3" + create-hash "^1.1.0" + inherits "^2.0.1" + ripemd160 "^2.0.0" + safe-buffer "^5.0.1" + sha.js "^2.4.8" + create-jest@^29.7.0: version "29.7.0" resolved "https://registry.yarnpkg.com/create-jest/-/create-jest-29.7.0.tgz#a355c5b3cb1e1af02ba177fe7afd7feee49a5320" @@ -10945,6 +11030,19 @@ elliptic@^6.4.1: minimalistic-assert "^1.0.1" minimalistic-crypto-utils "^1.0.1" +elliptic@^6.5.4: + version "6.5.5" + resolved "https://registry.yarnpkg.com/elliptic/-/elliptic-6.5.5.tgz#c715e09f78b6923977610d4c2346d6ce22e6dded" + integrity sha512-7EjbcmUm17NQFu4Pmgmq2olYMj8nwMnpcddByChSUjArp8F5DQWcIcpriwO4ZToLNAJig0yiyjswfyGNje/ixw== + dependencies: + bn.js "^4.11.9" + brorand "^1.1.0" + hash.js "^1.0.0" + hmac-drbg "^1.0.1" + inherits "^2.0.4" + minimalistic-assert "^1.0.1" + minimalistic-crypto-utils "^1.0.1" + email-validator@^2.0.4: version "2.0.4" resolved "https://registry.yarnpkg.com/email-validator/-/email-validator-2.0.4.tgz#b8dfaa5d0dae28f1b03c95881d904d4e40bfe7ed" @@ -11644,6 +11742,14 @@ events@3.3.0, events@^3.2.0, events@^3.3.0: resolved "https://registry.yarnpkg.com/events/-/events-3.3.0.tgz#31a95ad0a924e2d2c419a813aeb2c4e878ea7400" integrity sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q== +evp_bytestokey@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/evp_bytestokey/-/evp_bytestokey-1.0.3.tgz#7fcbdb198dc71959432efe13842684e0525acb02" + integrity sha512-/f2Go4TognH/KvCISP7OUsHn85hT9nUkxxA9BEWxFn+Oj9o8ZNLm/40hdlgSLyuOimsrTKLUMEorQexp/aPQeA== + dependencies: + md5.js "^1.3.4" + safe-buffer "^5.1.1" + exec-async@^2.2.0: version "2.2.0" resolved "https://registry.yarnpkg.com/exec-async/-/exec-async-2.2.0.tgz#c7c5ad2eef3478d38390c6dd3acfe8af0efc8301" @@ -13031,6 +13137,23 @@ has@^1.0.3: dependencies: function-bind "^1.1.1" +hash-base@^3.0.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/hash-base/-/hash-base-3.1.0.tgz#55c381d9e06e1d2997a883b4a3fddfe7f0d3af33" + integrity sha512-1nmYp/rhMDiE7AYkDw+lLwlAzz0AntGIe51F3RfFfEqyQ3feY2eI/NcwC6umIQVOASPMsWJLJScWKSSvzL9IVA== + dependencies: + inherits "^2.0.4" + readable-stream "^3.6.0" + safe-buffer "^5.2.0" + +hash-base@~3.0: + version "3.0.4" + resolved "https://registry.yarnpkg.com/hash-base/-/hash-base-3.0.4.tgz#5fc8686847ecd73499403319a6b0a3f3f6ae4918" + integrity sha512-EeeoJKjTyt868liAlVmcv2ZsUfGHlE3Q+BICOXcZiwN3osr5Q/zFGYmTJpoIzuaSTAwndFy+GqhEwlU4L3j4Ow== + dependencies: + inherits "^2.0.1" + safe-buffer "^5.0.1" + hash.js@^1.0.0, hash.js@^1.0.3: version "1.1.7" resolved "https://registry.yarnpkg.com/hash.js/-/hash.js-1.1.7.tgz#0babca538e8d4ee4a0f8988d68866537a003cf42" @@ -15083,6 +15206,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" @@ -15832,6 +15960,15 @@ md5-file@^3.2.3: dependencies: buffer-alloc "^1.1.0" +md5.js@^1.3.4: + version "1.3.5" + resolved "https://registry.yarnpkg.com/md5.js/-/md5.js-1.3.5.tgz#b5d07b8e3216e3e27cd728d72f70d1e6a342005f" + integrity sha512-xitP+WxNPcTTOgnTJcrhM0xvdPepipPSf3I8EIpGKeFLjt3PlJLIDG3u8EX53ZIubkb+5U2+3rELYpEhHhzdkg== + dependencies: + hash-base "^3.0.0" + inherits "^2.0.1" + safe-buffer "^5.1.2" + md5@^2.2.1: version "2.3.0" resolved "https://registry.yarnpkg.com/md5/-/md5-2.3.0.tgz#c3da9a6aae3a30b46b7b0c349b87b110dc3bda4f" @@ -17061,6 +17198,18 @@ parent-module@^1.0.0: dependencies: callsites "^3.0.0" +parse-asn1@^5.1.6: + version "5.1.7" + resolved "https://registry.yarnpkg.com/parse-asn1/-/parse-asn1-5.1.7.tgz#73cdaaa822125f9647165625eb45f8a051d2df06" + integrity sha512-CTM5kuWR3sx9IFamcl5ErfPl6ea/N8IYwiJ+vpeB2g+1iknv7zBl5uPwbMbRVznRVbrNY6lGuDoE5b30grmbqg== + dependencies: + asn1.js "^4.10.1" + browserify-aes "^1.2.0" + evp_bytestokey "^1.0.3" + hash-base "~3.0" + pbkdf2 "^3.1.2" + safe-buffer "^5.2.1" + parse-json@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/parse-json/-/parse-json-4.0.0.tgz#be35f5425be1f7f6c747184f98a788cb99477ee0" @@ -17207,6 +17356,17 @@ pathe@^1.1.0: resolved "https://registry.yarnpkg.com/pathe/-/pathe-1.1.1.tgz#1dd31d382b974ba69809adc9a7a347e65d84829a" integrity sha512-d+RQGp0MAYTIaDBIMmOfMwz3E+LOZnxx1HZd5R18mmCZY0QBlK0LDZfPc8FW8Ed2DlvsuE6PRjroDY+wg4+j/Q== +pbkdf2@^3.1.2: + version "3.1.2" + resolved "https://registry.yarnpkg.com/pbkdf2/-/pbkdf2-3.1.2.tgz#dd822aa0887580e52f1a039dc3eda108efae3075" + integrity sha512-iuh7L6jA7JEGu2WxDwtQP1ddOpaJNC4KlDEFfdQajSGgGPNi4OyDc2R7QnbY2bR9QjBVGwgvTdNJZoE7RaxUMA== + dependencies: + create-hash "^1.1.2" + create-hmac "^1.1.4" + ripemd160 "^2.0.1" + safe-buffer "^5.0.1" + sha.js "^2.4.8" + peek-readable@^4.1.0: version "4.1.0" resolved "https://registry.yarnpkg.com/peek-readable/-/peek-readable-4.1.0.tgz#4ece1111bf5c2ad8867c314c81356847e8a62e72" @@ -18495,7 +18655,7 @@ ramda@^0.27.1: resolved "https://registry.yarnpkg.com/ramda/-/ramda-0.27.2.tgz#84463226f7f36dc33592f6f4ed6374c48306c3f1" integrity sha512-SbiLPU40JuJniHexQSAgad32hfwd+DRUdwF2PlVuI5RZD0/vahUco7R8vD86J/tcEKKF9vZrUVwgtmGCqlCKyA== -randombytes@^2.1.0: +randombytes@^2.0.1, randombytes@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/randombytes/-/randombytes-2.1.0.tgz#df6f84372f0270dc65cdf6291349ab7a473d4f2a" integrity sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ== @@ -18991,7 +19151,7 @@ readable-stream@^2.0.1, readable-stream@^2.0.2, readable-stream@~2.3.6: string_decoder "~1.1.1" util-deprecate "~1.0.1" -readable-stream@^3.0.6, readable-stream@^3.1.1, readable-stream@^3.4.0, readable-stream@^3.5.0, readable-stream@^3.6.0: +readable-stream@^3.0.6, readable-stream@^3.1.1, readable-stream@^3.4.0, readable-stream@^3.5.0, readable-stream@^3.6.0, readable-stream@^3.6.2: version "3.6.2" resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.6.2.tgz#56a9b36ea965c00c5a93ef31eb111a0f11056967" integrity sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA== @@ -19384,6 +19544,14 @@ rimraf@~2.6.2: dependencies: glob "^7.1.3" +ripemd160@^2.0.0, ripemd160@^2.0.1: + version "2.0.2" + resolved "https://registry.yarnpkg.com/ripemd160/-/ripemd160-2.0.2.tgz#a1c1a6f624751577ba5d07914cbc92850585890c" + integrity sha512-ii4iagi25WusVoiC4B4lq7pbXfAp3D9v5CwfkY33vffw2+pkDjY1D8GaN7spsxvCSx8dkPqOZCEZyfxcmJG2IA== + dependencies: + hash-base "^3.0.0" + inherits "^2.0.1" + rn-fetch-blob@^0.12.0: version "0.12.0" resolved "https://registry.yarnpkg.com/rn-fetch-blob/-/rn-fetch-blob-0.12.0.tgz#ec610d2f9b3f1065556b58ab9c106eeb256f3cba" @@ -19482,7 +19650,7 @@ safe-buffer@5.1.2, safe-buffer@~5.1.0, safe-buffer@~5.1.1: resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d" integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g== -safe-buffer@5.2.1, safe-buffer@>=5.1.0, safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@~5.2.0: +safe-buffer@5.2.1, safe-buffer@>=5.1.0, safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@^5.1.1, safe-buffer@^5.1.2, safe-buffer@^5.2.0, safe-buffer@^5.2.1, safe-buffer@~5.2.0: version "5.2.1" resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== @@ -19769,6 +19937,14 @@ sf-symbols-typescript@^1.0.0: resolved "https://registry.yarnpkg.com/sf-symbols-typescript/-/sf-symbols-typescript-1.0.0.tgz#94e9210bf27e7583f9749a0d07bd4f4937ea488f" integrity sha512-DkS7q3nN68dEMb4E18HFPDAvyrjDZK9YAQQF2QxeFu9gp2xRDXFMF8qLJ1EmQ/qeEGQmop4lmMM1WtYJTIcCMw== +sha.js@^2.4.0, sha.js@^2.4.8: + version "2.4.11" + resolved "https://registry.yarnpkg.com/sha.js/-/sha.js-2.4.11.tgz#37a5cf0b81ecbc6943de109ba2960d1b26584ae7" + integrity sha512-QMEp5B7cftE7APOjk5Y6xgrbWu+WkLVQwk8JNjZ8nKRciZaByEW6MubieAiToS7+dwvrjGhH8jRXz3MVd0AYqQ== + dependencies: + inherits "^2.0.1" + safe-buffer "^5.0.1" + shallow-clone@^3.0.0: version "3.0.1" resolved "https://registry.yarnpkg.com/shallow-clone/-/shallow-clone-3.0.1.tgz#8f2981ad92531f55035b01fb230769a40e02efa3" @@ -22345,7 +22521,12 @@ zeego@^1.6.2: "@radix-ui/react-dropdown-menu" "^2.0.1" sf-symbols-typescript "^1.0.0" -zod@^3.14.2, zod@^3.20.2, zod@^3.21.4: +zod@^3.14.2, zod@^3.21.4: version "3.22.2" resolved "https://registry.yarnpkg.com/zod/-/zod-3.22.2.tgz#3add8c682b7077c05ac6f979fea6998b573e157b" integrity sha512-wvWkphh5WQsJbVk1tbx1l1Ly4yg+XecD+Mq280uBGt9wa5BKSWf4Mhp6GmrkPixhMxmabYY7RbzlwVP32pbGCg== + +zod@^3.22.4: + version "3.22.4" + resolved "https://registry.yarnpkg.com/zod/-/zod-3.22.4.tgz#f31c3a9386f61b1f228af56faa9255e845cf3fff" + integrity sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==