Merge branch 'hailey/expo-oauth-helper' into hailey/oauth
# Conflicts: # modules/expo-bluesky-oauth-client/src/rn-oauth-client-factory.native.ts # modules/expo-bluesky-oauth-client/src/rn-oauth-database.native.ts
This commit is contained in:
+23
-5
@@ -22,8 +22,8 @@ class CryptoUtil {
|
|||||||
return random
|
return random
|
||||||
}
|
}
|
||||||
|
|
||||||
fun generateKeyPair(keyId: String?): Pair<String, String> {
|
fun generateKeyPair(): Any {
|
||||||
val keyIdString = keyId ?: UUID.randomUUID().toString()
|
val keyIdString = UUID.randomUUID().toString()
|
||||||
|
|
||||||
val keyPairGen = KeyPairGenerator.getInstance("EC")
|
val keyPairGen = KeyPairGenerator.getInstance("EC")
|
||||||
keyPairGen.initialize(Curve.P_256.toECParameterSpec())
|
keyPairGen.initialize(Curve.P_256.toECParameterSpec())
|
||||||
@@ -44,9 +44,27 @@ class CryptoUtil {
|
|||||||
.algorithm(Algorithm.parse("ES256"))
|
.algorithm(Algorithm.parse("ES256"))
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
return Pair(
|
|
||||||
publicJwk.toString(),
|
return JWKPair(
|
||||||
privateJwk.toString()
|
JWK(
|
||||||
|
alg = privateJwk.algorithm.toString(),
|
||||||
|
kty = privateJwk.keyType.toString(),
|
||||||
|
crv = privateJwk.curve.toString(),
|
||||||
|
x = privateJwk.x.toString(),
|
||||||
|
y = privateJwk.y.toString(),
|
||||||
|
d = privateJwk.d.toString(),
|
||||||
|
use = privateJwk.keyUse.toString(),
|
||||||
|
kid = privateJwk.keyID
|
||||||
|
),
|
||||||
|
JWK(
|
||||||
|
alg = publicJwk.algorithm.toString(),
|
||||||
|
kty = publicJwk.keyType.toString(),
|
||||||
|
crv = publicJwk.curve.toString(),
|
||||||
|
x = publicJwk.x.toString(),
|
||||||
|
y = publicJwk.y.toString(),
|
||||||
|
use = publicJwk.keyUse.toString(),
|
||||||
|
kid = publicJwk.keyID
|
||||||
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-11
@@ -1,5 +1,6 @@
|
|||||||
package expo.modules.blueskyoauthclient
|
package expo.modules.blueskyoauthclient
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
import expo.modules.kotlin.modules.Module
|
import expo.modules.kotlin.modules.Module
|
||||||
import expo.modules.kotlin.modules.ModuleDefinition
|
import expo.modules.kotlin.modules.ModuleDefinition
|
||||||
|
|
||||||
@@ -15,21 +16,19 @@ class ExpoBlueskyOAuthClientModule : Module() {
|
|||||||
return@Function CryptoUtil().getRandomValues(byteLength)
|
return@Function CryptoUtil().getRandomValues(byteLength)
|
||||||
}
|
}
|
||||||
|
|
||||||
AsyncFunction("generateKeyPair") { keyId: String? ->
|
AsyncFunction("generateJwk") { algorithim: String ->
|
||||||
val res = CryptoUtil().generateKeyPair(keyId)
|
if (algorithim != "ES256") {
|
||||||
|
throw Exception("Unsupported algorithm")
|
||||||
return@AsyncFunction mapOf(
|
}
|
||||||
"publicKey" to res.first,
|
return@AsyncFunction CryptoUtil().generateKeyPair()
|
||||||
"privateKey" to res.second
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
AsyncFunction("createJwt") { jwkString: String, headerString: String, payloadString: String ->
|
AsyncFunction("createJwt") { header: JWTHeader, payload: JWTPayload, jwk: JWK ->
|
||||||
return@AsyncFunction JWTUtil().createJwt(jwkString, headerString, payloadString)
|
return@AsyncFunction JWTUtil().createJwt(header, payload, jwk)
|
||||||
}
|
}
|
||||||
|
|
||||||
AsyncFunction("verifyJwt") { jwkString: String, tokenString: String, options: String? ->
|
AsyncFunction("verifyJwt") { token: String, jwk: JWK ->
|
||||||
return@AsyncFunction JWTUtil().verifyJwt(jwkString, tokenString, options)
|
return@AsyncFunction JWTUtil().verifyJwt(token, jwk)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+34
@@ -0,0 +1,34 @@
|
|||||||
|
package expo.modules.blueskyoauthclient
|
||||||
|
|
||||||
|
import expo.modules.kotlin.records.Record
|
||||||
|
import expo.modules.kotlin.records.Field
|
||||||
|
|
||||||
|
class JWK(
|
||||||
|
@Field var alg: String = "",
|
||||||
|
@Field var kty: String = "",
|
||||||
|
@Field var crv: String? = null,
|
||||||
|
@Field var x: String? = null,
|
||||||
|
@Field var y: String? = null,
|
||||||
|
@Field var e: String? = null,
|
||||||
|
@Field var n: String? = null,
|
||||||
|
@Field var d: String? = null,
|
||||||
|
@Field var use: String? = null,
|
||||||
|
@Field var kid: String? = null
|
||||||
|
) : Record {
|
||||||
|
fun toJson(): String {
|
||||||
|
val parts = mutableListOf<String>()
|
||||||
|
if (alg.isNotEmpty()) parts.add("\"alg\": \"$alg\"")
|
||||||
|
if (kty.isNotEmpty()) parts.add("\"kty\": \"$kty\"")
|
||||||
|
if (crv != null) parts.add("\"crv\": \"$crv\"")
|
||||||
|
if (x != null) parts.add("\"x\": \"$x\"")
|
||||||
|
if (y != null) parts.add("\"y\": \"$y\"")
|
||||||
|
if (e != null) parts.add("\"e\": \"$e\"")
|
||||||
|
if (n != null) parts.add("\"n\": \"$n\"")
|
||||||
|
if (d != null) parts.add("\"d\": \"$d\"")
|
||||||
|
if (use != null) parts.add("\"use\": \"$use\"")
|
||||||
|
if (kid != null) parts.add("\"kid\": \"$kid\"")
|
||||||
|
return "{ ${parts.joinToString()} }"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class JWKPair(@Field val privateKey: JWK, @Field val publicKey: JWK) : Record
|
||||||
+175
@@ -0,0 +1,175 @@
|
|||||||
|
package expo.modules.blueskyoauthclient
|
||||||
|
|
||||||
|
import expo.modules.kotlin.records.Record
|
||||||
|
import expo.modules.kotlin.records.Field
|
||||||
|
|
||||||
|
class JWTHeader(
|
||||||
|
@Field var alg: String = "",
|
||||||
|
@Field var jku: String? = null,
|
||||||
|
@Field var jwk: JWK? = null,
|
||||||
|
@Field var kid: String? = null,
|
||||||
|
@Field var typ: String? = null,
|
||||||
|
@Field var cty: String? = null,
|
||||||
|
@Field var crit: String? = null
|
||||||
|
) : Record {
|
||||||
|
fun toJson(): String {
|
||||||
|
val parts = mutableListOf<String>()
|
||||||
|
if (alg.isNotEmpty()) parts.add("\"alg\": \"$alg\"")
|
||||||
|
if (jku != null) parts.add("\"jku\": \"$jku\"")
|
||||||
|
if (jwk != null) parts.add("\"jwk\": ${jwk?.toJson()}")
|
||||||
|
if (kid != null) parts.add("\"kid\": \"$kid\"")
|
||||||
|
if (typ != null) parts.add("\"typ\": \"$typ\"")
|
||||||
|
if (cty != null) parts.add("\"cty\": \"$cty\"")
|
||||||
|
if (crit != null) parts.add("\"crit\": \"$crit\"")
|
||||||
|
return "{ ${parts.joinToString()} }"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class JWTPayload(
|
||||||
|
@Field var iss: String? = null,
|
||||||
|
@Field var aud: String? = null,
|
||||||
|
@Field var sub: String? = null,
|
||||||
|
@Field var exp: Int? = null,
|
||||||
|
@Field var nbr: Int? = null,
|
||||||
|
@Field var iat: Int? = null,
|
||||||
|
@Field var jti: String? = null,
|
||||||
|
@Field var htm: String? = null,
|
||||||
|
@Field var htu: String? = null,
|
||||||
|
@Field var ath: String? = null,
|
||||||
|
@Field var acr: String? = null,
|
||||||
|
@Field var azp: String? = null,
|
||||||
|
@Field var amr: String? = null,
|
||||||
|
@Field var cnf: JWTPayloadCNF? = null,
|
||||||
|
@Field var client_id: String? = null,
|
||||||
|
@Field var scope: String? = null,
|
||||||
|
@Field var nonce: String? = null,
|
||||||
|
@Field var at_hash: String? = null,
|
||||||
|
@Field var c_hash: String? = null,
|
||||||
|
@Field var s_hash: String? = null,
|
||||||
|
@Field var auth_time: Int? = null,
|
||||||
|
@Field var name: String? = null,
|
||||||
|
@Field var family_name: String? = null,
|
||||||
|
@Field var given_name: String? = null,
|
||||||
|
@Field var middle_name: String? = null,
|
||||||
|
@Field var nickname: String? = null,
|
||||||
|
@Field var preferred_username: String? = null,
|
||||||
|
@Field var gender: String? = null,
|
||||||
|
@Field var picture: String? = null,
|
||||||
|
@Field var profile: String? = null,
|
||||||
|
@Field var birthdate: String? = null,
|
||||||
|
@Field var zoneinfo: String? = null,
|
||||||
|
@Field var updated_at: Int? = null,
|
||||||
|
@Field var email: String? = null,
|
||||||
|
@Field var email_verified: Boolean? = null,
|
||||||
|
@Field var phone_number: String? = null,
|
||||||
|
@Field var phone_number_verified: Boolean? = null,
|
||||||
|
@Field var address: JWTPayloadAddress? = null,
|
||||||
|
@Field var authorization_details: JWTPayloadAuthorizationDetails? = null
|
||||||
|
) : Record {
|
||||||
|
fun toJson(): String {
|
||||||
|
val parts = mutableListOf<String>()
|
||||||
|
if (iss != null) parts.add("\"iss\": \"$iss\"")
|
||||||
|
if (aud != null) parts.add("\"aud\": \"$aud\"")
|
||||||
|
if (sub != null) parts.add("\"sub\": \"$sub\"")
|
||||||
|
if (exp != null) parts.add("\"exp\": $exp")
|
||||||
|
if (nbr != null) parts.add("\"nbr\": $nbr")
|
||||||
|
if (iat != null) parts.add("\"iat\": $iat")
|
||||||
|
if (jti != null) parts.add("\"jti\": \"$jti\"")
|
||||||
|
if (htm != null) parts.add("\"htm\": \"$htm\"")
|
||||||
|
if (htu != null) parts.add("\"htu\": \"$htu\"")
|
||||||
|
if (ath != null) parts.add("\"ath\": \"$ath\"")
|
||||||
|
if (acr != null) parts.add("\"acr\": \"$acr\"")
|
||||||
|
if (azp != null) parts.add("\"azp\": \"$azp\"")
|
||||||
|
if (amr != null) parts.add("\"amr\": \"$amr\"")
|
||||||
|
if (cnf != null) parts.add("\"cnf\": ${cnf?.toJson()}")
|
||||||
|
if (client_id != null) parts.add("\"client_id\": \"$client_id\"")
|
||||||
|
if (scope != null) parts.add("\"scope\": \"$scope\"")
|
||||||
|
if (nonce != null) parts.add("\"nonce\": \"$nonce\"")
|
||||||
|
if (at_hash != null) parts.add("\"at_hash\": \"$at_hash\"")
|
||||||
|
if (c_hash != null) parts.add("\"c_hash\": \"$c_hash\"")
|
||||||
|
if (s_hash != null) parts.add("\"s_hash\": \"$s_hash\"")
|
||||||
|
if (auth_time != null) parts.add("\"auth_time\": $auth_time")
|
||||||
|
if (name != null) parts.add("\"name\": \"$name\"")
|
||||||
|
if (family_name != null) parts.add("\"family_name\": \"$family_name\"")
|
||||||
|
if (given_name != null) parts.add("\"given_name\": \"$given_name\"")
|
||||||
|
if (middle_name != null) parts.add("\"middle_name\": \"$middle_name\"")
|
||||||
|
if (nickname != null) parts.add("\"nickname\": \"$nickname\"")
|
||||||
|
if (preferred_username != null) parts.add("\"preferred_username\": \"$preferred_username\"")
|
||||||
|
if (gender != null) parts.add("\"gender\": \"$gender\"")
|
||||||
|
if (picture != null) parts.add("\"picture\": \"$picture\"")
|
||||||
|
if (profile != null) parts.add("\"profile\": \"$profile\"")
|
||||||
|
if (birthdate != null) parts.add("\"birthdate\": \"$birthdate\"")
|
||||||
|
if (zoneinfo != null) parts.add("\"zoneinfo\": \"$zoneinfo\"")
|
||||||
|
if (updated_at != null) parts.add("\"updated_at\": $updated_at")
|
||||||
|
if (email != null) parts.add("\"email\": \"$email\"")
|
||||||
|
if (email_verified != null) parts.add("\"email_verified\": $email_verified")
|
||||||
|
if (phone_number != null) parts.add("\"phone_number\": \"$phone_number\"")
|
||||||
|
if (phone_number_verified != null) parts.add("\"phone_number_verified\": $phone_number_verified")
|
||||||
|
if (address != null) parts.add("\"address\": ${address?.toJson()}")
|
||||||
|
if (authorization_details != null) parts.add("\"authorization_details\": ${authorization_details?.toJson()}")
|
||||||
|
return "{ ${parts.joinToString()} }"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class JWTPayloadCNF(
|
||||||
|
@Field var jwk: JWK? = null,
|
||||||
|
@Field var jwe: String? = null,
|
||||||
|
@Field var jku: String? = null,
|
||||||
|
@Field var jkt: String? = null,
|
||||||
|
@Field var osc: String? = null
|
||||||
|
) : Record {
|
||||||
|
fun toJson(): String {
|
||||||
|
val parts = mutableListOf<String>()
|
||||||
|
if (jwk != null) parts.add("\"jwk\": ${jwk?.toJson()}")
|
||||||
|
if (jwe != null) parts.add("\"jwe\": \"$jwe\"")
|
||||||
|
if (jku != null) parts.add("\"jku\": \"$jku\"")
|
||||||
|
if (jkt != null) parts.add("\"jkt\": \"$jkt\"")
|
||||||
|
if (osc != null) parts.add("\"osc\": \"$osc\"")
|
||||||
|
return "{ ${parts.joinToString()} }"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class JWTPayloadAddress(
|
||||||
|
@Field var formatted: String? = null,
|
||||||
|
@Field var street_address: String? = null,
|
||||||
|
@Field var locality: String? = null,
|
||||||
|
@Field var region: String? = null,
|
||||||
|
@Field var postal_code: String? = null,
|
||||||
|
@Field var country: String? = null
|
||||||
|
) : Record {
|
||||||
|
fun toJson(): String {
|
||||||
|
val parts = mutableListOf<String>()
|
||||||
|
if (formatted != null) parts.add("\"formatted\": \"$formatted\"")
|
||||||
|
if (street_address != null) parts.add("\"street_address\": \"$street_address\"")
|
||||||
|
if (locality != null) parts.add("\"locality\": \"$locality\"")
|
||||||
|
if (region != null) parts.add("\"region\": \"$region\"")
|
||||||
|
if (postal_code != null) parts.add("\"postal_code\": \"$postal_code\"")
|
||||||
|
if (country != null) parts.add("\"country\": \"$country\"")
|
||||||
|
return "{ ${parts.joinToString()} }"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class JWTPayloadAuthorizationDetails(
|
||||||
|
@Field var type: String? = null,
|
||||||
|
@Field var locations: Array<String>? = null,
|
||||||
|
@Field var actions: Array<String>? = null,
|
||||||
|
@Field var datatypes: Array<String>? = null,
|
||||||
|
@Field var identifier: String? = null,
|
||||||
|
@Field var privileges: Array<String>? = null
|
||||||
|
) : Record {
|
||||||
|
fun toJson(): String {
|
||||||
|
val parts = mutableListOf<String>()
|
||||||
|
if (type != null) parts.add("\"type\": \"$type\"")
|
||||||
|
if (locations != null) parts.add("\"locations\": [${locations?.joinToString()}]")
|
||||||
|
if (actions != null) parts.add("\"actions\": [${actions?.joinToString()}]")
|
||||||
|
if (datatypes != null) parts.add("\"datatypes\": [${datatypes?.joinToString()}]")
|
||||||
|
if (identifier != null) parts.add("\"identifier\": \"$identifier\"")
|
||||||
|
if (privileges != null) parts.add("\"privileges\": [${privileges?.joinToString()}]")
|
||||||
|
return "{ ${parts.joinToString()} }"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class JWTVerifyResponse(
|
||||||
|
@Field var protectedHeader: JWTHeader = JWTHeader(),
|
||||||
|
@Field var payload: String = "",
|
||||||
|
) : Record
|
||||||
+49
-14
@@ -7,29 +7,64 @@ import com.nimbusds.jose.jwk.ECKey
|
|||||||
import com.nimbusds.jwt.JWTClaimsSet
|
import com.nimbusds.jwt.JWTClaimsSet
|
||||||
import com.nimbusds.jwt.SignedJWT
|
import com.nimbusds.jwt.SignedJWT
|
||||||
|
|
||||||
|
|
||||||
class JWTUtil {
|
class JWTUtil {
|
||||||
fun createJwt(jwkString: String, headerString: String, payloadString: String): String {
|
fun createJwt(header: JWTHeader, payload: JWTPayload, jwk: JWK): String {
|
||||||
val key = ECKey.parse(jwkString)
|
val parsedKey = ECKey.parse(jwk.toJson())
|
||||||
val header = JWSHeader.parse(headerString)
|
val parsedHeader = JWSHeader.parse(header.toJson())
|
||||||
val payload = JWTClaimsSet.parse(payloadString)
|
val parsedPayload = JWTClaimsSet.parse(payload.toJson())
|
||||||
|
|
||||||
val signer = ECDSASigner(key)
|
val signer = ECDSASigner(parsedKey)
|
||||||
val jwt = SignedJWT(header, payload)
|
val jwt = SignedJWT(parsedHeader, parsedPayload)
|
||||||
jwt.sign(signer)
|
jwt.sign(signer)
|
||||||
|
|
||||||
return jwt.serialize()
|
return jwt.serialize()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun verifyJwt(jwkString: String, tokenString: String, options: String?): Boolean {
|
fun verifyJwt(token: String, jwk: JWK): JWTVerifyResponse {
|
||||||
return try {
|
try {
|
||||||
val key = ECKey.parse(jwkString)
|
val parsedKey = ECKey.parse(jwk.toJson())
|
||||||
val jwt = SignedJWT.parse(tokenString)
|
val jwt = SignedJWT.parse(token)
|
||||||
val verifier = ECDSAVerifier(key)
|
val verifier = ECDSAVerifier(parsedKey)
|
||||||
|
|
||||||
jwt.verify(verifier)
|
if (!jwt.verify(verifier)) {
|
||||||
|
throw Exception("Invalid signature")
|
||||||
|
}
|
||||||
|
|
||||||
|
val header = jwt.header
|
||||||
|
val payload = jwt.payload
|
||||||
|
val ecKey = header.jwk?.toECKey()
|
||||||
|
val serializedJwk = if (ecKey != null) {
|
||||||
|
JWK(
|
||||||
|
alg = ecKey.algorithm.toString(),
|
||||||
|
kty = ecKey.keyType.toString(),
|
||||||
|
crv = ecKey.curve.toString(),
|
||||||
|
x = ecKey.x.toString(),
|
||||||
|
y = ecKey.y.toString(),
|
||||||
|
d = ecKey.d.toString(),
|
||||||
|
use = ecKey.keyUse.toString(),
|
||||||
|
kid = ecKey.keyID
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
val serializedHeader = JWTHeader(
|
||||||
|
alg = header.algorithm.toString(),
|
||||||
|
jku = header.jwkurl?.toString(),
|
||||||
|
jwk = serializedJwk,
|
||||||
|
kid = header.keyID,
|
||||||
|
typ = header.type?.toString(),
|
||||||
|
cty = header.contentType,
|
||||||
|
crit = header.criticalParams?.joinToString()
|
||||||
|
)
|
||||||
|
val serializedPayload = payload.toString()
|
||||||
|
|
||||||
|
return JWTVerifyResponse(
|
||||||
|
protectedHeader = serializedHeader,
|
||||||
|
payload = serializedPayload,
|
||||||
|
)
|
||||||
} catch(e: Exception) {
|
} catch(e: Exception) {
|
||||||
false
|
throw e
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
export * from './src/crypto-subtle'
|
export * from './src/oauth-client-react-native'
|
||||||
export * from './src/jose-key'
|
export * from './src/react-native-crypto-implementation'
|
||||||
export * from './src/rn-crypto-key'
|
export * from './src/react-native-key'
|
||||||
export * from './src/rn-oauth-client-factory'
|
export * from './src/react-native-store-with-key'
|
||||||
export * from './src/rn-oauth-database'
|
|
||||||
export * from './src/util.web'
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import CryptoKit
|
import CryptoKit
|
||||||
import JOSESwift
|
import JOSESwift
|
||||||
|
import ExpoModulesCore
|
||||||
|
|
||||||
class CryptoUtil {
|
class CryptoUtil {
|
||||||
// The equivalent of crypto.subtle.digest() with JS on web
|
// The equivalent of crypto.subtle.digest() with JS on web
|
||||||
@@ -13,8 +14,8 @@ class CryptoUtil {
|
|||||||
return Data(bytes)
|
return Data(bytes)
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func generateKeyPair(kid: String?) throws -> (publicJWK: JWK, privateJWK: JWK)? {
|
public static func generateKeyPair() throws -> JWKPair? {
|
||||||
let keyIdString = kid ?? UUID().uuidString
|
let keyIdString = UUID().uuidString
|
||||||
|
|
||||||
let privateKey = P256.Signing.PrivateKey()
|
let privateKey = P256.Signing.PrivateKey()
|
||||||
let publicKey = privateKey.publicKey
|
let publicKey = privateKey.publicKey
|
||||||
@@ -23,10 +24,27 @@ class CryptoUtil {
|
|||||||
let y = publicKey.x963Representation[33...].base64URLEncodedString()
|
let y = publicKey.x963Representation[33...].base64URLEncodedString()
|
||||||
let d = privateKey.rawRepresentation.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 publicJWK = JWK(
|
||||||
let privateJWK = JWK(kty: "EC", use: "sig", crv: "P-256", kid: keyIdString, x: x, y: y, d: d, alg: "ES256")
|
alg: "ES256".toField(),
|
||||||
|
kty: "EC".toField(),
|
||||||
|
crv: "P-256".toNullableField(),
|
||||||
|
x: x.toNullableField(),
|
||||||
|
y: y.toNullableField(),
|
||||||
|
use: "sig".toNullableField(),
|
||||||
|
kid: keyIdString.toNullableField()
|
||||||
|
)
|
||||||
|
let privateJWK = JWK(
|
||||||
|
alg: "ES256".toField(),
|
||||||
|
kty: "EC".toField(),
|
||||||
|
crv: "P-256".toNullableField(),
|
||||||
|
x: x.toNullableField(),
|
||||||
|
y: y.toNullableField(),
|
||||||
|
d: d.toNullableField(),
|
||||||
|
use: "sig".toNullableField(),
|
||||||
|
kid: keyIdString.toNullableField()
|
||||||
|
)
|
||||||
|
|
||||||
return (publicJWK, privateJWK)
|
return JWKPair(privateKey: privateJWK.toField(), publicKey: publicJWK.toField())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,3 +53,12 @@ extension Data {
|
|||||||
return self.base64EncodedString().replacingOccurrences(of: "+", with: "-").replacingOccurrences(of: "/", with: "_").replacingOccurrences(of: "=", with: "")
|
return self.base64EncodedString().replacingOccurrences(of: "+", with: "-").replacingOccurrences(of: "/", with: "_").replacingOccurrences(of: "=", with: "")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension String {
|
||||||
|
func toField() -> Field<String> {
|
||||||
|
return Field(wrappedValue: self)
|
||||||
|
}
|
||||||
|
func toNullableField() -> Field<String?> {
|
||||||
|
return Field(wrappedValue: self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,30 +14,32 @@ public class ExpoBlueskyOAuthClientModule: Module {
|
|||||||
return CryptoUtil.getRandomValues(byteLength: byteLength)
|
return CryptoUtil.getRandomValues(byteLength: byteLength)
|
||||||
}
|
}
|
||||||
|
|
||||||
AsyncFunction ("generateKeyPair") { (kid: String?, promise: Promise) in
|
AsyncFunction ("generateJwk") { (algo: String?, promise: Promise) in
|
||||||
let keypair = try? CryptoUtil.generateKeyPair(kid: kid)
|
if algo != "ES256" {
|
||||||
|
promise.reject("GenerateKeyError", "Algorithim not supported.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
guard let keypair = keypair else {
|
let keypair = try? CryptoUtil.generateKeyPair()
|
||||||
|
|
||||||
|
guard keypair != nil else {
|
||||||
promise.reject("GenerateKeyError", "Error generating JWK.")
|
promise.reject("GenerateKeyError", "Error generating JWK.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
promise.resolve([
|
promise.resolve(keypair)
|
||||||
"publicKey": keypair.publicJWK.toJson(),
|
|
||||||
"privateKey": keypair.privateJWK.toJson()
|
|
||||||
])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
AsyncFunction("createJwt") { (jwk: String, header: String, payload: String, promise: Promise) in
|
AsyncFunction("createJwt") { (header: JWTHeader, payload: JWTPayload, jwk: JWK, promise: Promise) in
|
||||||
guard let jwt = JWTUtil.createJwt(jwk, header: header, payload: payload) else {
|
guard let jwt = JWTUtil.createJwt(header: header, payload: payload, jwk: jwk) else {
|
||||||
promise.reject("JWTError", "Error creating JWT.")
|
promise.reject("JWTError", "Error creating JWT.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
promise.resolve(jwt)
|
promise.resolve(jwt)
|
||||||
}
|
}
|
||||||
|
|
||||||
AsyncFunction("verifyJwt") { (jwk: String, token: String, options: String?, promise: Promise) in
|
AsyncFunction("verifyJwt") { (token: String, jwk: JWK, promise: Promise) in
|
||||||
promise.resolve(JWTUtil.verifyJwt(jwk, token: token, options: options))
|
promise.resolve(JWTUtil.verifyJwt(token: token, jwk: jwk))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,29 +1,58 @@
|
|||||||
struct JWK {
|
import ExpoModulesCore
|
||||||
let kty: String
|
import JOSESwift
|
||||||
let use: String
|
|
||||||
let crv: String
|
struct JWK : Record {
|
||||||
let kid: String
|
@Field
|
||||||
let x: String
|
var alg: String
|
||||||
let y: String
|
@Field
|
||||||
|
var kty: String
|
||||||
|
@Field
|
||||||
|
var crv: String?
|
||||||
|
@Field
|
||||||
|
var x: String?
|
||||||
|
@Field
|
||||||
|
var y: String?
|
||||||
|
@Field
|
||||||
|
var e: String?
|
||||||
|
@Field
|
||||||
|
var n: String?
|
||||||
|
@Field
|
||||||
var d: String?
|
var d: String?
|
||||||
let alg: String
|
@Field
|
||||||
|
var use: String?
|
||||||
|
@Field
|
||||||
|
var kid: String?
|
||||||
|
|
||||||
func toJson() -> String {
|
func toField() -> Field<JWK> {
|
||||||
var dict: [String: Any] = [
|
return Field(wrappedValue: self)
|
||||||
"kty": kty,
|
}
|
||||||
"use": use,
|
|
||||||
"crv": crv,
|
|
||||||
"kid": kid,
|
|
||||||
"x": x,
|
|
||||||
"y": y,
|
|
||||||
"alg": alg,
|
|
||||||
]
|
|
||||||
|
|
||||||
if let d = d {
|
func toPrivateSecKey() throws -> SecKey? {
|
||||||
dict["d"] = d
|
let jsonData = try JSONSerialization.data(withJSONObject: self.toDictionary())
|
||||||
|
guard let jwk = try? JSONDecoder().decode(ECPrivateKey.self, from: jsonData),
|
||||||
|
let key = try? jwk.converted(to: SecKey.self)
|
||||||
|
else {
|
||||||
|
print("Error creating SecKey.")
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
|
||||||
let jsonData = try! JSONSerialization.data(withJSONObject: dict, options: [])
|
func toPublicSecKey() throws -> SecKey? {
|
||||||
return String(data: jsonData, encoding: .utf8)!
|
let jsonData = try JSONSerialization.data(withJSONObject: self.toDictionary())
|
||||||
|
guard let jwk = try? JSONDecoder().decode(ECPublicKey.self, from: jsonData),
|
||||||
|
let key = try? jwk.converted(to: SecKey.self)
|
||||||
|
else {
|
||||||
|
print("Error creating SecKey.")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return key
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct JWKPair : Record {
|
||||||
|
@Field
|
||||||
|
var privateKey: JWK
|
||||||
|
@Field
|
||||||
|
var publicKey: JWK
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,184 @@
|
|||||||
|
import ExpoModulesCore
|
||||||
|
import JOSESwift
|
||||||
|
|
||||||
|
struct JWTHeader : Record {
|
||||||
|
@Field
|
||||||
|
var alg: String = "ES256"
|
||||||
|
@Field
|
||||||
|
var jku: String?
|
||||||
|
@Field
|
||||||
|
var jwk: JWK
|
||||||
|
@Field
|
||||||
|
var kid: String?
|
||||||
|
@Field
|
||||||
|
var typ: String?
|
||||||
|
@Field
|
||||||
|
var cty: String?
|
||||||
|
@Field
|
||||||
|
var crit: String?
|
||||||
|
|
||||||
|
func toField() -> Field<JWTHeader> {
|
||||||
|
return Field(wrappedValue: self)
|
||||||
|
}
|
||||||
|
|
||||||
|
func toJWSHeader() throws -> JWSHeader? {
|
||||||
|
return JWSHeader(try JSONSerialization.data(withJSONObject: self.toDictionary()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct JWTPayload : Record {
|
||||||
|
@Field
|
||||||
|
var iss: String?
|
||||||
|
@Field
|
||||||
|
var aud: String?
|
||||||
|
@Field
|
||||||
|
var sub: String?
|
||||||
|
@Field
|
||||||
|
var exp: Int?
|
||||||
|
@Field
|
||||||
|
var nbr: Int?
|
||||||
|
@Field
|
||||||
|
var iat: Int?
|
||||||
|
@Field
|
||||||
|
var jti: String?
|
||||||
|
@Field
|
||||||
|
var htm: String?
|
||||||
|
@Field
|
||||||
|
var htu: String?
|
||||||
|
@Field
|
||||||
|
var ath: String?
|
||||||
|
@Field
|
||||||
|
var acr: String?
|
||||||
|
@Field
|
||||||
|
var azp: String?
|
||||||
|
@Field
|
||||||
|
var amr: String?
|
||||||
|
@Field
|
||||||
|
var cnf: JWTPayloadCNF?
|
||||||
|
@Field
|
||||||
|
var client_id: String?
|
||||||
|
@Field
|
||||||
|
var scope: String?
|
||||||
|
@Field
|
||||||
|
var nonce: String?
|
||||||
|
@Field
|
||||||
|
var at_hash: String?
|
||||||
|
@Field
|
||||||
|
var c_hash: String?
|
||||||
|
@Field
|
||||||
|
var s_hash: String?
|
||||||
|
@Field
|
||||||
|
var auth_time: Int?
|
||||||
|
@Field
|
||||||
|
var name: String?
|
||||||
|
@Field
|
||||||
|
var family_name: String?
|
||||||
|
@Field
|
||||||
|
var given_name: String?
|
||||||
|
@Field
|
||||||
|
var middle_name: String?
|
||||||
|
@Field
|
||||||
|
var nickname: String?
|
||||||
|
@Field
|
||||||
|
var preferred_username: String?
|
||||||
|
@Field
|
||||||
|
var gender: String?
|
||||||
|
@Field
|
||||||
|
var picture: String?
|
||||||
|
@Field
|
||||||
|
var profile: String?
|
||||||
|
@Field
|
||||||
|
var website: String?
|
||||||
|
@Field
|
||||||
|
var birthdate: String?
|
||||||
|
@Field
|
||||||
|
var zoneinfo: String?
|
||||||
|
@Field
|
||||||
|
var locale: String?
|
||||||
|
@Field
|
||||||
|
var updated_at: Int?
|
||||||
|
@Field
|
||||||
|
var email: String?
|
||||||
|
@Field
|
||||||
|
var email_verified: Bool?
|
||||||
|
@Field
|
||||||
|
var phone_number: String?
|
||||||
|
@Field
|
||||||
|
var phone_number_verified: Bool?
|
||||||
|
@Field
|
||||||
|
var address: JWTPayloadAddress?
|
||||||
|
@Field
|
||||||
|
var authorization_details: JWTPayloadAuthorizationDetails?
|
||||||
|
|
||||||
|
func toField() -> Field<JWTPayload> {
|
||||||
|
return Field(wrappedValue: self)
|
||||||
|
}
|
||||||
|
|
||||||
|
func toPayload() throws -> Payload {
|
||||||
|
return Payload(try JSONSerialization.data(withJSONObject: self.toDictionary()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct JWTPayloadCNF : Record {
|
||||||
|
@Field
|
||||||
|
var kid: String?
|
||||||
|
@Field
|
||||||
|
var jwk: JWK?
|
||||||
|
@Field
|
||||||
|
var jwe: String?
|
||||||
|
@Field
|
||||||
|
var jku: String?
|
||||||
|
@Field
|
||||||
|
var jkt: String?
|
||||||
|
@Field
|
||||||
|
var osc: String?
|
||||||
|
|
||||||
|
func toField() -> Field<JWTPayloadCNF> {
|
||||||
|
return Field(wrappedValue: self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct JWTPayloadAddress : Record {
|
||||||
|
@Field
|
||||||
|
var formatted: String?
|
||||||
|
@Field
|
||||||
|
var street_address: String?
|
||||||
|
@Field
|
||||||
|
var locality: String?
|
||||||
|
@Field
|
||||||
|
var region: String?
|
||||||
|
@Field
|
||||||
|
var postal_code: String?
|
||||||
|
@Field
|
||||||
|
var country: String?
|
||||||
|
|
||||||
|
func toField() -> Field<JWTPayloadAddress> {
|
||||||
|
return Field(wrappedValue: self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct JWTPayloadAuthorizationDetails : Record {
|
||||||
|
@Field
|
||||||
|
var type: String
|
||||||
|
@Field
|
||||||
|
var locations: [String]?
|
||||||
|
@Field
|
||||||
|
var actions: [String]?
|
||||||
|
@Field
|
||||||
|
var datatypes: [String]?
|
||||||
|
@Field
|
||||||
|
var identifier: String?
|
||||||
|
@Field
|
||||||
|
var privileges: [String]?
|
||||||
|
|
||||||
|
func toField() -> Field<JWTPayloadAuthorizationDetails> {
|
||||||
|
return Field(wrappedValue: self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct JWTVerifyResponse : Record {
|
||||||
|
@Field
|
||||||
|
var protectedHeader: JWTHeader
|
||||||
|
@Field
|
||||||
|
var payload: String
|
||||||
|
}
|
||||||
@@ -1,21 +1,7 @@
|
|||||||
|
import ExpoModulesCore
|
||||||
import JOSESwift
|
import JOSESwift
|
||||||
|
|
||||||
class JWTUtil {
|
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? {
|
static func jsonToPublicKey(_ jwkString: String) throws -> SecKey? {
|
||||||
guard let jsonData = jwkString.data(using: .utf8),
|
guard let jsonData = jwkString.data(using: .utf8),
|
||||||
let jwk = try? JSONDecoder().decode(ECPublicKey.self, from: jsonData),
|
let jwk = try? JSONDecoder().decode(ECPublicKey.self, from: jsonData),
|
||||||
@@ -46,12 +32,12 @@ class JWTUtil {
|
|||||||
return JWSHeader(headerData)
|
return JWSHeader(headerData)
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func createJwt(_ jwkString: String, header headerString: String, payload payloadString: String) -> String? {
|
public static func createJwt(header: JWTHeader, payload: JWTPayload, jwk: JWK) -> String? {
|
||||||
guard let key = try? jsonToPrivateKey(jwkString),
|
guard let header = try? header.toJWSHeader(),
|
||||||
let payload = payloadStringToPayload(payloadString),
|
let payload = try? payload.toPayload(),
|
||||||
let header = headerStringToPayload(headerString)
|
let key = try? jwk.toPrivateSecKey()
|
||||||
else
|
else {
|
||||||
{
|
print("didn't have one")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,15 +53,34 @@ class JWTUtil {
|
|||||||
return jws.compactSerializedString
|
return jws.compactSerializedString
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func verifyJwt(_ jwkString: String, token tokenString: String, options optionsString: String?) -> Bool {
|
public static func verifyJwt(token: String, jwk: JWK) -> JWTVerifyResponse? {
|
||||||
guard let key = try? jsonToPublicKey(jwkString),
|
guard let key = try? jwk.toPublicSecKey(),
|
||||||
let jws = try? JWS(compactSerialization: tokenString),
|
let jws = try? JWS(compactSerialization: token),
|
||||||
let verifier = Verifier(verifyingAlgorithm: .ES256, key: key),
|
let verifier = Verifier(verifyingAlgorithm: .ES256, key: key),
|
||||||
let isVerified = try? jws.validate(using: verifier).isValid(for: verifier)
|
let validation = try? jws.validate(using: verifier)
|
||||||
else {
|
else {
|
||||||
return false
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return isVerified
|
let header = validation.header
|
||||||
|
let serializedHeader = JWTHeader(
|
||||||
|
alg: "ES256",
|
||||||
|
jku: Field(wrappedValue: header.jku?.absoluteString),
|
||||||
|
kid: Field(wrappedValue:header.kid),
|
||||||
|
typ: Field(wrappedValue: header.typ),
|
||||||
|
cty: Field(wrappedValue: header.cty),
|
||||||
|
crit: Field(wrappedValue: header.cty)
|
||||||
|
)
|
||||||
|
|
||||||
|
let payload = String(data: validation.payload.data(), encoding: .utf8)
|
||||||
|
|
||||||
|
guard let payload = payload else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return JWTVerifyResponse(
|
||||||
|
protectedHeader: serializedHeader.toField(),
|
||||||
|
payload: payload.toField()
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
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}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
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})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
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,48 @@
|
|||||||
|
import {requireNativeModule} from 'expo-modules-core'
|
||||||
|
import {Jwk, Jwt, Key} 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<Uint8Array> {
|
||||||
|
throw new Error(LINKING_ERROR)
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a private JWK for the given algorithm. The JWK should have a "use"
|
||||||
|
* an does not need a "kid" property.
|
||||||
|
*
|
||||||
|
* @throws if the algorithm is not supported ("ES256" must be supported)
|
||||||
|
*/
|
||||||
|
async generateJwk(_algo: string): Promise<{publicKey: Key; privateKey: Key}> {
|
||||||
|
throw new Error(LINKING_ERROR)
|
||||||
|
},
|
||||||
|
|
||||||
|
async createJwt(
|
||||||
|
_header: unknown,
|
||||||
|
_payload: unknown,
|
||||||
|
_jwk: unknown,
|
||||||
|
): Promise<Jwt> {
|
||||||
|
throw new Error(LINKING_ERROR)
|
||||||
|
},
|
||||||
|
|
||||||
|
async verifyJwt(
|
||||||
|
_token: Jwt,
|
||||||
|
_jwk: Jwk,
|
||||||
|
): Promise<{
|
||||||
|
payload: string // this is a JSON response to make Swift a bit easier to work with
|
||||||
|
protectedHeader: Record<string, unknown>
|
||||||
|
}> {
|
||||||
|
throw new Error(LINKING_ERROR)
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -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<Key> {
|
||||||
|
const bytes = await this.getRandomValues(12)
|
||||||
|
const kid = Array.from(bytes, byteToHex).join('')
|
||||||
|
return await ReactNativeKey.generate(kid, algs)
|
||||||
|
}
|
||||||
|
|
||||||
|
async getRandomValues(length: number): Promise<Uint8Array> {
|
||||||
|
return OauthClientReactNative.getRandomValues(length)
|
||||||
|
}
|
||||||
|
|
||||||
|
async digest(
|
||||||
|
bytes: Uint8Array,
|
||||||
|
algorithm: DigestAlgorithm,
|
||||||
|
): Promise<Uint8Array> {
|
||||||
|
return OauthClientReactNative.digest(bytes, algorithm.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function byteToHex(b: number): string {
|
||||||
|
return b.toString(16).padStart(2, '0')
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
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 = res.privateKey
|
||||||
|
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<Jwt> {
|
||||||
|
return OauthClientReactNative.createJwt(header, payload, this.jwk)
|
||||||
|
}
|
||||||
|
|
||||||
|
async verifyJwt<
|
||||||
|
P extends VerifyPayload = JwtPayload,
|
||||||
|
C extends string = string,
|
||||||
|
>(token: Jwt, options?: VerifyOptions<C>): Promise<VerifyResult<P, C>> {
|
||||||
|
const result = await OauthClientReactNative.verifyJwt(token, this.jwk)
|
||||||
|
|
||||||
|
// TODO see if we can make these `undefined` or maybe update zod to allow `nullable()`
|
||||||
|
let payloadParsed = JSON.parse(result.payload)
|
||||||
|
payloadParsed = Object.fromEntries(
|
||||||
|
Object.entries(payloadParsed as object).filter(([_, v]) => v !== null),
|
||||||
|
)
|
||||||
|
const payload = jwtPayloadSchema.parse(payloadParsed)
|
||||||
|
|
||||||
|
// We don't need to validate this, because the native types ensure it is correct. But this is a TODO
|
||||||
|
// for the same reason above
|
||||||
|
const protectedHeader = result.protectedHeader
|
||||||
|
|
||||||
|
if (options?.audience != null) {
|
||||||
|
const audience = Array.isArray(options.audience)
|
||||||
|
? options.audience
|
||||||
|
: [options.audience]
|
||||||
|
if (!audience.includes(payload.aud)) {
|
||||||
|
throw new Error('Invalid audience')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.issuer != null) {
|
||||||
|
const issuer = Array.isArray(options.issuer)
|
||||||
|
? options.issuer
|
||||||
|
: [options.issuer]
|
||||||
|
if (!issuer.includes(payload.iss)) {
|
||||||
|
throw new Error('Invalid issuer')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.subject != null && payload.sub !== options.subject) {
|
||||||
|
throw new Error('Invalid subject')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.typ != null && protectedHeader.typ !== options.typ) {
|
||||||
|
throw new Error('Invalid type')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.requiredClaims != null) {
|
||||||
|
for (const key of options.requiredClaims) {
|
||||||
|
if (
|
||||||
|
!Object.hasOwn(payload, key) ||
|
||||||
|
(payload as Record<string, unknown>)[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<P, C>
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<V extends ExposedValue> = Omit<V, 'dpopKey'> & {
|
||||||
|
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<V extends ExposedValue>
|
||||||
|
implements GenericStore<string, V>
|
||||||
|
{
|
||||||
|
internalStore: ReactNativeStore<StoredValue<V>>
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
protected valueExpiresAt: (value: StoredValue<V>) => null | Date,
|
||||||
|
) {
|
||||||
|
this.internalStore = new ReactNativeStore(valueExpiresAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
async set(key: string, value: V): Promise<void> {
|
||||||
|
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<V | undefined> {
|
||||||
|
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<void> {
|
||||||
|
await this.internalStore.del(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import {GenericStore, Value} from '@atproto/caching'
|
||||||
|
import Storage from '@react-native-async-storage/async-storage'
|
||||||
|
|
||||||
|
export class ReactNativeStore<V extends Value>
|
||||||
|
implements GenericStore<string, V>
|
||||||
|
{
|
||||||
|
constructor(protected valueExpiresAt: (value: V) => null | Date) {
|
||||||
|
throw new Error('Not implemented')
|
||||||
|
}
|
||||||
|
|
||||||
|
async get(key: string): Promise<V | undefined> {
|
||||||
|
const itemJson = await Storage.getItem(key)
|
||||||
|
if (itemJson == null) return undefined
|
||||||
|
|
||||||
|
return JSON.parse(itemJson) as V
|
||||||
|
}
|
||||||
|
|
||||||
|
async set(key: string, value: V): Promise<void> {
|
||||||
|
await Storage.setItem(key, JSON.stringify(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
async del(key: string): Promise<void> {
|
||||||
|
await Storage.removeItem(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,149 +0,0 @@
|
|||||||
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 {DatabaseStore, RNOAuthDatabase} from './rn-oauth-database'
|
|
||||||
|
|
||||||
export type RNOAuthClientOptions = {
|
|
||||||
responseMode?: OAuthResponseMode
|
|
||||||
responseType?: OAuthResponseType
|
|
||||||
clientMetadata: OAuthClientMetadata
|
|
||||||
fetch?: Fetch
|
|
||||||
crypto?: Crypto
|
|
||||||
}
|
|
||||||
|
|
||||||
const POPUP_KEY_PREFIX = '@@oauth-popup-callback:'
|
|
||||||
|
|
||||||
export class RNOAuthClientFactory extends OAuthClientFactory {
|
|
||||||
readonly sessionStore: DatabaseStore<Session>
|
|
||||||
|
|
||||||
constructor({
|
|
||||||
clientMetadata,
|
|
||||||
// "fragment" is safer as it is not sent to the server
|
|
||||||
responseMode = 'fragment',
|
|
||||||
responseType,
|
|
||||||
crypto,
|
|
||||||
fetch = globalThis.fetch,
|
|
||||||
}: RNOAuthClientOptions) {
|
|
||||||
const database = new RNOAuthDatabase()
|
|
||||||
|
|
||||||
super({
|
|
||||||
clientMetadata,
|
|
||||||
responseMode,
|
|
||||||
responseType,
|
|
||||||
fetch,
|
|
||||||
cryptoImplementation: new CryptoSubtle(),
|
|
||||||
sessionStore: database.getSessionStore(),
|
|
||||||
stateStore: database.getStateStore(),
|
|
||||||
metadataResolver: new IsomorphicOAuthServerMetadataResolver({
|
|
||||||
fetch,
|
|
||||||
cache: database.getMetadataCache(),
|
|
||||||
}),
|
|
||||||
identityResolver: UniversalIdentityResolver.from({
|
|
||||||
fetch,
|
|
||||||
didCache: database.getDidCache(),
|
|
||||||
handleCache: database.getHandleCache(),
|
|
||||||
plcDirectoryUrl: 'http://localhost:2582', // dev-env
|
|
||||||
atprotoLexiconUrl: 'http://localhost:2584', // dev-env (bsky appview)
|
|
||||||
}),
|
|
||||||
dpopNonceCache: database.getDpopNonceCache(),
|
|
||||||
})
|
|
||||||
|
|
||||||
this.sessionStore = database.getSessionStore()
|
|
||||||
}
|
|
||||||
|
|
||||||
async restoreAll() {
|
|
||||||
const sessionIds = await this.sessionStore.getKeys()
|
|
||||||
return Object.fromEntries(
|
|
||||||
await Promise.all(
|
|
||||||
sessionIds.map(
|
|
||||||
async sessionId =>
|
|
||||||
[sessionId, await this.restore(sessionId, false)] as const,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
async init(sessionId?: string, forceRefresh = false) {
|
|
||||||
const signInResult = await this.signInCallback()
|
|
||||||
if (signInResult) {
|
|
||||||
return signInResult
|
|
||||||
} else if (sessionId) {
|
|
||||||
const client = await this.restore(sessionId, forceRefresh)
|
|
||||||
return {client}
|
|
||||||
} else {
|
|
||||||
// TODO: we could restore any session from the store ?
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async signIn(input: string, options?: OAuthAuthorizeOptions) {
|
|
||||||
return await this.authorize(input, options)
|
|
||||||
}
|
|
||||||
|
|
||||||
async signInCallback() {
|
|
||||||
const redirectUri = new URL(this.clientMetadata.redirect_uris[0])
|
|
||||||
if (location.pathname !== redirectUri.pathname) return null
|
|
||||||
|
|
||||||
const params =
|
|
||||||
this.responseMode === 'query'
|
|
||||||
? new URLSearchParams(location.search)
|
|
||||||
: new URLSearchParams(location.hash.slice(1))
|
|
||||||
|
|
||||||
// Only if the query string contains oauth callback params
|
|
||||||
if (
|
|
||||||
!params.has('iss') ||
|
|
||||||
!params.has('state') ||
|
|
||||||
!(params.has('code') || params.has('error'))
|
|
||||||
) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
// Replace the current history entry without the query string (this will
|
|
||||||
// prevent this 'if' branch to run again if the user refreshes the page)
|
|
||||||
history.replaceState(null, '', location.pathname)
|
|
||||||
|
|
||||||
return this.callback(params)
|
|
||||||
.then(async result => {
|
|
||||||
if (result.state?.startsWith(POPUP_KEY_PREFIX)) {
|
|
||||||
const stateKey = result.state.slice(POPUP_KEY_PREFIX.length)
|
|
||||||
|
|
||||||
await this.popupStore.set(stateKey, {
|
|
||||||
status: 'fulfilled',
|
|
||||||
value: result.client.sessionId,
|
|
||||||
})
|
|
||||||
|
|
||||||
window.close() // continued in signInPopup
|
|
||||||
throw new Error('Login complete, please close the popup window.')
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
})
|
|
||||||
.catch(async err => {
|
|
||||||
// TODO: Throw a proper error from parent class to actually detect
|
|
||||||
// oauth authorization errors
|
|
||||||
const state = typeof (err as any)?.state
|
|
||||||
if (typeof state === 'string' && state?.startsWith(POPUP_KEY_PREFIX)) {
|
|
||||||
const stateKey = state.slice(POPUP_KEY_PREFIX.length)
|
|
||||||
|
|
||||||
await this.popupStore.set(stateKey, {
|
|
||||||
status: 'rejected',
|
|
||||||
reason: err,
|
|
||||||
})
|
|
||||||
|
|
||||||
window.close() // continued in signInPopup
|
|
||||||
throw new Error('Login complete, please close the popup window.')
|
|
||||||
}
|
|
||||||
|
|
||||||
throw err
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,153 +0,0 @@
|
|||||||
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 {
|
|
||||||
DatabaseStore,
|
|
||||||
PopupStateData,
|
|
||||||
RNOAuthDatabase,
|
|
||||||
} from './rn-oauth-database'
|
|
||||||
|
|
||||||
export type RNOAuthClientOptions = {
|
|
||||||
responseMode?: OAuthResponseMode
|
|
||||||
responseType?: OAuthResponseType
|
|
||||||
clientMetadata: OAuthClientMetadata
|
|
||||||
fetch?: Fetch
|
|
||||||
crypto?: Crypto
|
|
||||||
}
|
|
||||||
|
|
||||||
const POPUP_KEY_PREFIX = '@@oauth-popup-callback:'
|
|
||||||
|
|
||||||
export class RNOAuthClientFactory 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,
|
|
||||||
}: RNOAuthClientOptions) {
|
|
||||||
const database = new RNOAuthDatabase()
|
|
||||||
|
|
||||||
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
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,214 +0,0 @@
|
|||||||
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<T> = {
|
|
||||||
value: string
|
|
||||||
expiresAt: null | Date
|
|
||||||
}
|
|
||||||
|
|
||||||
type EncodedKey = {
|
|
||||||
keyId: string
|
|
||||||
keyPair: CryptoKeyPair
|
|
||||||
}
|
|
||||||
|
|
||||||
function encodeKey(key: Key): EncodedKey {
|
|
||||||
if (!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 RNOAuthDatabase {
|
|
||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,269 +0,0 @@
|
|||||||
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 RNOAuthDatabase {
|
|
||||||
#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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
// @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')
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -19,7 +19,6 @@ import {Pager, PagerRef, RenderTabBarFnProps} from 'view/com/pager/Pager'
|
|||||||
import {CustomFeedEmptyState} from 'view/com/posts/CustomFeedEmptyState'
|
import {CustomFeedEmptyState} from 'view/com/posts/CustomFeedEmptyState'
|
||||||
import {FollowingEmptyState} from 'view/com/posts/FollowingEmptyState'
|
import {FollowingEmptyState} from 'view/com/posts/FollowingEmptyState'
|
||||||
import {FollowingEndOfFeed} from 'view/com/posts/FollowingEndOfFeed'
|
import {FollowingEndOfFeed} from 'view/com/posts/FollowingEndOfFeed'
|
||||||
import {RnCryptoKey} from '../../../modules/expo-bluesky-oauth-client'
|
|
||||||
import {HomeLoggedOutCTA} from '../com/auth/HomeLoggedOutCTA'
|
import {HomeLoggedOutCTA} from '../com/auth/HomeLoggedOutCTA'
|
||||||
import {HomeHeader} from '../com/home/HomeHeader'
|
import {HomeHeader} from '../com/home/HomeHeader'
|
||||||
|
|
||||||
@@ -52,18 +51,6 @@ function HomeScreenReady({
|
|||||||
preferences: UsePreferencesQueryResponse
|
preferences: UsePreferencesQueryResponse
|
||||||
pinnedFeedInfos: FeedSourceInfo[]
|
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 allFeeds = React.useMemo(() => {
|
||||||
const feeds: FeedDescriptor[] = []
|
const feeds: FeedDescriptor[] = []
|
||||||
feeds.push('home')
|
feeds.push('home')
|
||||||
|
|||||||
Reference in New Issue
Block a user