Merge branch 'hailey/expo-oauth-helper' into hailey/oauth
This commit is contained in:
@@ -31,6 +31,7 @@ module.exports = {
|
||||
},
|
||||
},
|
||||
],
|
||||
'bsky-internal/use-typed-gates': 'error',
|
||||
'simple-import-sort/imports': [
|
||||
'warn',
|
||||
{
|
||||
|
||||
@@ -3,5 +3,6 @@
|
||||
module.exports = {
|
||||
rules: {
|
||||
'avoid-unwrapped-text': require('./avoid-unwrapped-text'),
|
||||
'use-typed-gates': require('./use-typed-gates'),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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.",
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
<manifest>
|
||||
</manifest>
|
||||
+52
@@ -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<String, String> {
|
||||
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()
|
||||
)
|
||||
}
|
||||
}
|
||||
+35
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+35
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"platforms": ["ios", "tvos", "android", "web"],
|
||||
"ios": {
|
||||
"modules": ["ExpoBlueskyOAuthClientModule"]
|
||||
},
|
||||
"android": {
|
||||
"modules": ["expo.modules.blueskyoauthclient.ExpoBlueskyOAuthClientModule"]
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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..<byteLength).map { _ in UInt8.random(in: UInt8.min...UInt8.max) }
|
||||
return Data(bytes)
|
||||
}
|
||||
|
||||
public static func generateKeyPair(kid: String?) throws -> (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: "")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)!
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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<Key> {
|
||||
return await RnCryptoKey.generate(undefined, ['ES256'])
|
||||
}
|
||||
|
||||
getRandomValues(byteLength: number): Uint8Array {
|
||||
return NativeModule.getRandomValues(byteLength)
|
||||
}
|
||||
|
||||
async digest(bytes: Uint8Array): Promise<Uint8Array> {
|
||||
return await NativeModule.digest(bytes)
|
||||
}
|
||||
}
|
||||
@@ -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<Key> {
|
||||
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<Uint8Array> {
|
||||
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}`)
|
||||
}
|
||||
}
|
||||
@@ -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<string> {
|
||||
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<C>): Promise<VerifyResult<P, C>> {
|
||||
const result = await NativeModule.verifyJwt(
|
||||
JSON.stringify(this.publicJwk),
|
||||
token,
|
||||
JSON.stringify(options),
|
||||
)
|
||||
return result
|
||||
// return result as VerifyResult<P, C>
|
||||
}
|
||||
|
||||
static async fromImportable(
|
||||
input: Importable,
|
||||
kid?: string,
|
||||
): Promise<JoseKey> {
|
||||
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<JoseKey> {
|
||||
const keyLike = await importPKCS8(pem, '', {extractable: true})
|
||||
return this.fromJWK(await exportJWK(keyLike), kid)
|
||||
}
|
||||
|
||||
static async fromJWK(
|
||||
input: string | Record<string, unknown>,
|
||||
inputKid?: string,
|
||||
): Promise<JoseKey> {
|
||||
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})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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<RNCryptoKey> {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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<PopupStateData>
|
||||
readonly sessionStore: DatabaseStore<Session>
|
||||
|
||||
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
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
import {GenericStore, Value} from '@atproto/caching'
|
||||
import {DidDocument} from '@atproto/did'
|
||||
import {ResolvedHandle} from '@atproto/handle-resolver'
|
||||
import {Key} from '@atproto/jwk'
|
||||
import {WebcryptoKey} from '@atproto/jwk-webcrypto'
|
||||
import {InternalStateData, Session, TokenSet} from '@atproto/oauth-client'
|
||||
import {OAuthServerMetadata} from '@atproto/oauth-server-metadata'
|
||||
import Storage from '@react-native-async-storage/async-storage'
|
||||
|
||||
type Item = {
|
||||
value: string
|
||||
expiresAt: null | Date
|
||||
}
|
||||
|
||||
type EncodedKey = {
|
||||
keyId: string
|
||||
keyPair: CryptoKeyPair
|
||||
}
|
||||
|
||||
function encodeKey(key: Key): EncodedKey {
|
||||
if (!(key instanceof WebcryptoKey) || !key.kid) {
|
||||
throw new Error('Invalid key object')
|
||||
}
|
||||
return {
|
||||
keyId: key.kid,
|
||||
keyPair: key.cryptoKeyPair,
|
||||
}
|
||||
}
|
||||
|
||||
async function decodeKey(encoded: EncodedKey): Promise<Key> {
|
||||
return WebcryptoKey.fromKeypair(encoded.keyId, encoded.keyPair)
|
||||
}
|
||||
|
||||
export type Schema = {
|
||||
state: Item<{
|
||||
dpopKey: EncodedKey
|
||||
|
||||
iss: string
|
||||
nonce: string
|
||||
verifier?: string
|
||||
appState?: string
|
||||
}>
|
||||
session: Item<{
|
||||
dpopKey: EncodedKey
|
||||
|
||||
tokenSet: TokenSet
|
||||
}>
|
||||
|
||||
didCache: Item<DidDocument>
|
||||
dpopNonceCache: Item<string>
|
||||
handleCache: Item<ResolvedHandle>
|
||||
metadataCache: Item<OAuthServerMetadata>
|
||||
}
|
||||
|
||||
export type DatabaseStore<V extends Value> = GenericStore<string, V> & {
|
||||
getKeys: () => Promise<string[]>
|
||||
}
|
||||
|
||||
const STORES = [
|
||||
'state',
|
||||
'session',
|
||||
|
||||
'didCache',
|
||||
'dpopNonceCache',
|
||||
'handleCache',
|
||||
'metadataCache',
|
||||
] as const
|
||||
|
||||
export class BrowserOAuthDatabase {
|
||||
async delete(key: string) {
|
||||
await Storage.removeItem(key)
|
||||
}
|
||||
|
||||
protected createStore<N extends keyof Schema, V extends Value>(
|
||||
dbName: N,
|
||||
{
|
||||
encode,
|
||||
decode,
|
||||
maxAge,
|
||||
}: {
|
||||
encode: (value: V) => Schema[N]['value'] | PromiseLike<Schema[N]['value']>
|
||||
decode: (encoded: Schema[N]['value']) => V | PromiseLike<V>
|
||||
maxAge?: number
|
||||
},
|
||||
): DatabaseStore<V> {
|
||||
return {
|
||||
get: async key => {
|
||||
const itemJson = await Storage.getItem(`${dbName}.${key}`)
|
||||
if (itemJson == null) return undefined
|
||||
|
||||
const item = JSON.parse(itemJson) as Schema[N]
|
||||
|
||||
// Too old, proactively delete
|
||||
if (item.expiresAt != null && item.expiresAt < new Date()) {
|
||||
await this.delete(`${dbName}.${key}`)
|
||||
return undefined
|
||||
}
|
||||
|
||||
// Item found and valid. Decode
|
||||
return decode(item.value)
|
||||
},
|
||||
|
||||
getKeys: async () => {
|
||||
const keys = await Storage.getAllKeys()
|
||||
return keys.filter(key => key.startsWith(`${dbName}.`)) as string[]
|
||||
},
|
||||
|
||||
set: async (key, value) => {
|
||||
const item = {
|
||||
value: await encode(value),
|
||||
expiresAt: maxAge == null ? null : new Date(Date.now() + maxAge),
|
||||
} as Schema[N]
|
||||
|
||||
await Storage.setItem(`${dbName}.${key}`, JSON.stringify(item))
|
||||
},
|
||||
|
||||
del: async key => {
|
||||
await this.delete(`${dbName}.${key}`)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
getSessionStore(): DatabaseStore<Session> {
|
||||
return this.createStore('session', {
|
||||
encode: ({dpopKey, ...session}) => ({
|
||||
...session,
|
||||
dpopKey: encodeKey(dpopKey),
|
||||
}),
|
||||
decode: async ({dpopKey, ...encoded}) => ({
|
||||
...encoded,
|
||||
dpopKey: await decodeKey(dpopKey),
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
getStateStore(): DatabaseStore<InternalStateData> {
|
||||
return this.createStore('state', {
|
||||
encode: ({dpopKey, ...session}) => ({
|
||||
...session,
|
||||
dpopKey: encodeKey(dpopKey),
|
||||
}),
|
||||
decode: async ({dpopKey, ...encoded}) => ({
|
||||
...encoded,
|
||||
dpopKey: await decodeKey(dpopKey),
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
getDpopNonceCache(): undefined | DatabaseStore<string> {
|
||||
return this.createStore('dpopNonceCache', {
|
||||
// No time limit. It is better to try with a potentially outdated nonce
|
||||
// and potentially succeed rather than make requests without a nonce and
|
||||
// 100% fail.
|
||||
encode: value => value,
|
||||
decode: encoded => encoded,
|
||||
})
|
||||
}
|
||||
|
||||
getDidCache(): undefined | DatabaseStore<DidDocument> {
|
||||
return this.createStore('didCache', {
|
||||
maxAge: 60e3,
|
||||
encode: value => value,
|
||||
decode: encoded => encoded,
|
||||
})
|
||||
}
|
||||
|
||||
getHandleCache(): undefined | DatabaseStore<ResolvedHandle> {
|
||||
return this.createStore('handleCache', {
|
||||
maxAge: 60e3,
|
||||
encode: value => value,
|
||||
decode: encoded => encoded,
|
||||
})
|
||||
}
|
||||
|
||||
getMetadataCache(): undefined | DatabaseStore<OAuthServerMetadata> {
|
||||
return this.createStore('metadataCache', {
|
||||
maxAge: 60e3,
|
||||
encode: value => value,
|
||||
decode: encoded => encoded,
|
||||
})
|
||||
}
|
||||
|
||||
async cleanup() {
|
||||
await Promise.all(
|
||||
STORES.map(
|
||||
async storeName =>
|
||||
[
|
||||
storeName,
|
||||
await tx
|
||||
.objectStore(storeName)
|
||||
.index('expiresAt')
|
||||
.getAllKeys(query),
|
||||
] as const,
|
||||
),
|
||||
)
|
||||
|
||||
const storesWithInvalidKeys = res.filter(r => r[1].length > 0)
|
||||
|
||||
await db.transaction(
|
||||
storesWithInvalidKeys.map(r => r[0]),
|
||||
'readwrite',
|
||||
tx =>
|
||||
Promise.all(
|
||||
storesWithInvalidKeys.map(async ([name, keys]) =>
|
||||
tx.objectStore(name).delete(keys),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
async [Symbol.asyncDispose]() {
|
||||
await this.cleanup()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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<V> = {
|
||||
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<Key> {
|
||||
return WebcryptoKey.fromKeypair(encoded.keyId, encoded.keyPair)
|
||||
}
|
||||
|
||||
export type PopupStateData =
|
||||
| PromiseRejectedResult
|
||||
| PromiseFulfilledResult<string>
|
||||
|
||||
export type Schema = {
|
||||
popup: Item<PopupStateData>
|
||||
state: Item<{
|
||||
dpopKey: EncodedKey
|
||||
|
||||
iss: string
|
||||
nonce: string
|
||||
verifier?: string
|
||||
appState?: string
|
||||
}>
|
||||
session: Item<{
|
||||
dpopKey: EncodedKey
|
||||
|
||||
tokenSet: TokenSet
|
||||
}>
|
||||
|
||||
didCache: Item<DidDocument>
|
||||
dpopNonceCache: Item<string>
|
||||
handleCache: Item<ResolvedHandle>
|
||||
metadataCache: Item<OAuthServerMetadata>
|
||||
}
|
||||
|
||||
export type DatabaseStore<V extends Value> = GenericStore<string, V> & {
|
||||
getKeys: () => Promise<string[]>
|
||||
}
|
||||
|
||||
const STORES = [
|
||||
'popup',
|
||||
'state',
|
||||
'session',
|
||||
|
||||
'didCache',
|
||||
'dpopNonceCache',
|
||||
'handleCache',
|
||||
'metadataCache',
|
||||
] as const
|
||||
|
||||
export class BrowserOAuthDatabase {
|
||||
#dbPromise = DB.open<Schema>(
|
||||
'@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<N extends keyof Schema, R>(
|
||||
storeName: N,
|
||||
mode: 'readonly' | 'readwrite',
|
||||
fn: (s: DBObjectStore<Schema[N]>) => R | Promise<R>,
|
||||
): Promise<R> {
|
||||
const db = await this.#dbPromise
|
||||
return await db.transaction([storeName], mode, tx =>
|
||||
fn(tx.objectStore(storeName)),
|
||||
)
|
||||
}
|
||||
|
||||
protected createStore<N extends keyof Schema, V extends Value>(
|
||||
name: N,
|
||||
{
|
||||
encode,
|
||||
decode,
|
||||
maxAge,
|
||||
}: {
|
||||
encode: (value: V) => Schema[N]['value'] | PromiseLike<Schema[N]['value']>
|
||||
decode: (encoded: Schema[N]['value']) => V | PromiseLike<V>
|
||||
maxAge?: number
|
||||
},
|
||||
): DatabaseStore<V> {
|
||||
return {
|
||||
get: async key => {
|
||||
// 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<Session> {
|
||||
return this.createStore('session', {
|
||||
encode: ({dpopKey, ...session}) => ({
|
||||
...session,
|
||||
dpopKey: encodeKey(dpopKey),
|
||||
}),
|
||||
decode: async ({dpopKey, ...encoded}) => ({
|
||||
...encoded,
|
||||
dpopKey: await decodeKey(dpopKey),
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
getStateStore(): DatabaseStore<InternalStateData> {
|
||||
return this.createStore('state', {
|
||||
encode: ({dpopKey, ...session}) => ({
|
||||
...session,
|
||||
dpopKey: encodeKey(dpopKey),
|
||||
}),
|
||||
decode: async ({dpopKey, ...encoded}) => ({
|
||||
...encoded,
|
||||
dpopKey: await decodeKey(dpopKey),
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
getPopupStore(): DatabaseStore<PopupStateData> {
|
||||
return this.createStore('popup', {
|
||||
encode: value => value,
|
||||
decode: encoded => encoded,
|
||||
})
|
||||
}
|
||||
|
||||
getDpopNonceCache(): undefined | DatabaseStore<string> {
|
||||
return this.createStore('dpopNonceCache', {
|
||||
// No time limit. It is better to try with a potentially outdated nonce
|
||||
// and potentially succeed rather than make requests without a nonce and
|
||||
// 100% fail.
|
||||
encode: value => value,
|
||||
decode: encoded => encoded,
|
||||
})
|
||||
}
|
||||
|
||||
getDidCache(): undefined | DatabaseStore<DidDocument> {
|
||||
return this.createStore('didCache', {
|
||||
maxAge: 60e3,
|
||||
encode: value => value,
|
||||
decode: encoded => encoded,
|
||||
})
|
||||
}
|
||||
|
||||
getHandleCache(): undefined | DatabaseStore<ResolvedHandle> {
|
||||
return this.createStore('handleCache', {
|
||||
maxAge: 60e3,
|
||||
encode: value => value,
|
||||
decode: encoded => encoded,
|
||||
})
|
||||
}
|
||||
|
||||
getMetadataCache(): undefined | DatabaseStore<OAuthServerMetadata> {
|
||||
return this.createStore('metadataCache', {
|
||||
maxAge: 60e3,
|
||||
encode: value => value,
|
||||
decode: encoded => encoded,
|
||||
})
|
||||
}
|
||||
|
||||
async cleanup() {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
)
|
||||
}
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bsky.app",
|
||||
"version": "1.76.0",
|
||||
"version": "1.77.0",
|
||||
"private": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
|
||||
@@ -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'
|
||||
@@ -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<E extends keyof LogEvents>(
|
||||
}
|
||||
}
|
||||
|
||||
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}.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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: <SearchScreenPostResults query={query} sort="top" />,
|
||||
},
|
||||
{
|
||||
title: _(msg`Latest`),
|
||||
component: <SearchScreenPostResults query={query} sort="latest" />,
|
||||
},
|
||||
{
|
||||
title: _(msg`People`),
|
||||
component: <SearchScreenUserResults query={query} />,
|
||||
},
|
||||
]
|
||||
} else {
|
||||
return [
|
||||
{
|
||||
title: _(msg`People`),
|
||||
component: <SearchScreenUserResults query={query} />,
|
||||
},
|
||||
]
|
||||
}
|
||||
} else {
|
||||
if (hasSession) {
|
||||
return [
|
||||
{
|
||||
title: _(msg`Posts`),
|
||||
component: <SearchScreenPostResults query={query} />,
|
||||
},
|
||||
{
|
||||
title: _(msg`Users`),
|
||||
component: <SearchScreenUserResults query={query} />,
|
||||
},
|
||||
]
|
||||
} else {
|
||||
return [
|
||||
{
|
||||
title: _(msg`Users`),
|
||||
component: <SearchScreenUserResults query={query} />,
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
}, [hasSession, isNewSearch, _, query])
|
||||
|
||||
if (hasSession) {
|
||||
return query ? (
|
||||
<Pager
|
||||
@@ -347,16 +404,13 @@ export function SearchScreenInner({
|
||||
<CenteredView
|
||||
sideBorders
|
||||
style={[pal.border, pal.view, styles.tabBarContainer]}>
|
||||
<TabBar items={SECTIONS_LOGGEDIN} {...props} />
|
||||
<TabBar items={sections.map(section => section.title)} {...props} />
|
||||
</CenteredView>
|
||||
)}
|
||||
initialPage={0}>
|
||||
<View>
|
||||
<SearchScreenPostResults query={query} />
|
||||
</View>
|
||||
<View>
|
||||
<SearchScreenUserResults query={query} />
|
||||
</View>
|
||||
{sections.map((section, i) => (
|
||||
<View key={i}>{section.component}</View>
|
||||
))}
|
||||
</Pager>
|
||||
) : (
|
||||
<View>
|
||||
@@ -389,13 +443,13 @@ export function SearchScreenInner({
|
||||
<CenteredView
|
||||
sideBorders
|
||||
style={[pal.border, pal.view, styles.tabBarContainer]}>
|
||||
<TabBar items={SECTIONS_LOGGEDOUT} {...props} />
|
||||
<TabBar items={sections.map(section => section.title)} {...props} />
|
||||
</CenteredView>
|
||||
)}
|
||||
initialPage={0}>
|
||||
<View>
|
||||
<SearchScreenUserResults query={query} />
|
||||
</View>
|
||||
{sections.map((section, i) => (
|
||||
<View key={i}>{section.component}</View>
|
||||
))}
|
||||
</Pager>
|
||||
) : (
|
||||
<CenteredView sideBorders style={pal.border}>
|
||||
|
||||
Reference in New Issue
Block a user