fix android module name

import fixes

fix some imports

use `@atproto/jwk` over local zod schema

preview oauth client

rename package

alignment

more alignment

database implementation

cleanup some types

alignment

switch implementation for es256

half of `verifyJwt` for android

half of `verifyJwt` for iOS

rm log

`createJwt` android

`createJwt` ios

rm print

cleanup native impl

silence some warnings

reorg, finish web impl

follow the `CryptoImplementation`

move some more things around

normalize some names

rename func

add generate on android

add android dep

rm bool

add alg

better implementation

ios jwk generation

fix index.ts

add `getRandomValues()` on native

web `getRandomValues`

silence ts error for missing crypto on web

add `digest` for web

add `digest`

add module
This commit is contained in:
Hailey
2024-04-10 12:58:41 -07:00
parent 310d865440
commit 13607edd43
27 changed files with 1502 additions and 0 deletions
@@ -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>
@@ -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()
)
}
}
@@ -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)
}
}
}
@@ -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
View File
@@ -131,6 +131,7 @@
"expo-web-browser": "~12.8.2",
"fast-text-encoding": "^1.0.6",
"history": "^5.3.0",
"jose": "^5.2.4",
"js-sha256": "^0.9.0",
"jwt-decode": "^4.0.0",
"lande": "^1.0.10",
+13
View File
@@ -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')
+5
View File
@@ -15031,6 +15031,11 @@ jose@^5.0.1:
resolved "https://registry.yarnpkg.com/jose/-/jose-5.1.3.tgz#303959d85c51b5cb14725f930270b72be56abdca"
integrity sha512-GPExOkcMsCLBTi1YetY2LmkoY559fss0+0KVa6kOfb2YFe84nAM7Nm/XzuZozah4iHgmBGrCOHL5/cy670SBRw==
jose@^5.2.4:
version "5.2.4"
resolved "https://registry.yarnpkg.com/jose/-/jose-5.2.4.tgz#c0d296caeeed0b8444a8b8c3b68403d61aa4ed72"
integrity sha512-6ScbIk2WWCeXkmzF6bRPmEuaqy1m8SbsRFMa/FLrSCkGIhj8OLVG/IH+XHVmNMx/KUo8cVWEE6oKR4dJ+S0Rkg==
js-base64@^3.7.2:
version "3.7.5"
resolved "https://registry.yarnpkg.com/js-base64/-/js-base64-3.7.5.tgz#21e24cf6b886f76d6f5f165bfcd69cc55b9e3fca"