@atproto/oauth-client-expo 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +11 -0
- package/LICENSE.txt +7 -0
- package/README.md +140 -0
- package/android/.editorconfig +2 -0
- package/android/build.gradle +47 -0
- package/android/src/main/AndroidManifest.xml +2 -0
- package/android/src/main/java/expo/modules/atprotooauthclient/Crypto.kt +69 -0
- package/android/src/main/java/expo/modules/atprotooauthclient/ExpoAtprotoOAuthClientModule.kt +41 -0
- package/android/src/main/java/expo/modules/atprotooauthclient/Jose.kt +116 -0
- package/android/src/main/java/expo/modules/atprotooauthclient/Records.kt +61 -0
- package/dist/ExpoAtprotoOAuthClientModule.d.ts +22 -0
- package/dist/ExpoAtprotoOAuthClientModule.d.ts.map +1 -0
- package/dist/ExpoAtprotoOAuthClientModule.js +3 -0
- package/dist/ExpoAtprotoOAuthClientModule.js.map +1 -0
- package/dist/ExpoAtprotoOAuthClientModule.types.d.ts +2 -0
- package/dist/ExpoAtprotoOAuthClientModule.types.d.ts.map +1 -0
- package/dist/ExpoAtprotoOAuthClientModule.types.js +2 -0
- package/dist/ExpoAtprotoOAuthClientModule.types.js.map +1 -0
- package/dist/expo-oauth-client-interface.d.ts +6 -0
- package/dist/expo-oauth-client-interface.d.ts.map +1 -0
- package/dist/expo-oauth-client-interface.js +2 -0
- package/dist/expo-oauth-client-interface.js.map +1 -0
- package/dist/expo-oauth-client-options.d.ts +9 -0
- package/dist/expo-oauth-client-options.d.ts.map +1 -0
- package/dist/expo-oauth-client-options.js +2 -0
- package/dist/expo-oauth-client-options.js.map +1 -0
- package/dist/expo-oauth-client.native.d.ts +13 -0
- package/dist/expo-oauth-client.native.d.ts.map +1 -0
- package/dist/expo-oauth-client.native.js +130 -0
- package/dist/expo-oauth-client.native.js.map +1 -0
- package/dist/expo-oauth-client.web.d.ts +9 -0
- package/dist/expo-oauth-client.web.d.ts.map +1 -0
- package/dist/expo-oauth-client.web.js +24 -0
- package/dist/expo-oauth-client.web.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -0
- package/dist/polyfill.d.ts +5 -0
- package/dist/polyfill.d.ts.map +1 -0
- package/dist/polyfill.js +5 -0
- package/dist/polyfill.js.map +1 -0
- package/dist/utils/expo-key.d.ts +11 -0
- package/dist/utils/expo-key.d.ts.map +1 -0
- package/dist/utils/expo-key.js +29 -0
- package/dist/utils/expo-key.js.map +1 -0
- package/dist/utils/mmkv-simple-store-ttl.d.ts +24 -0
- package/dist/utils/mmkv-simple-store-ttl.d.ts.map +1 -0
- package/dist/utils/mmkv-simple-store-ttl.js +62 -0
- package/dist/utils/mmkv-simple-store-ttl.js.map +1 -0
- package/dist/utils/mmkv-simple-store.d.ts +18 -0
- package/dist/utils/mmkv-simple-store.d.ts.map +1 -0
- package/dist/utils/mmkv-simple-store.js +31 -0
- package/dist/utils/mmkv-simple-store.js.map +1 -0
- package/dist/utils/stores.d.ts +24 -0
- package/dist/utils/stores.d.ts.map +1 -0
- package/dist/utils/stores.js +99 -0
- package/dist/utils/stores.js.map +1 -0
- package/expo-module.config.json +9 -0
- package/ios/Crypto.swift +83 -0
- package/ios/ExpoAtprotoOAuthClient.podspec +31 -0
- package/ios/ExpoAtprotoOAuthClientModule.swift +45 -0
- package/ios/Jose.swift +137 -0
- package/ios/Records.swift +58 -0
- package/package.json +52 -0
- package/src/ExpoAtprotoOAuthClientModule.ts +33 -0
- package/src/ExpoAtprotoOAuthClientModule.types.ts +2 -0
- package/src/expo-oauth-client-interface.ts +10 -0
- package/src/expo-oauth-client-options.ts +27 -0
- package/src/expo-oauth-client.d.ts +6 -0
- package/src/expo-oauth-client.native.ts +111 -0
- package/src/expo-oauth-client.web.ts +42 -0
- package/src/index.ts +4 -0
- package/src/polyfill.ts +4 -0
- package/src/utils/expo-key.ts +50 -0
- package/src/utils/mmkv-simple-store-ttl.ts +90 -0
- package/src/utils/mmkv-simple-store.ts +48 -0
- package/src/utils/stores.ts +115 -0
- package/tsconfig.build.json +8 -0
- package/tsconfig.build.tsbuildinfo +1 -0
- package/tsconfig.json +4 -0
package/ios/Crypto.swift
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import CryptoKit
|
|
3
|
+
import JOSESwift
|
|
4
|
+
|
|
5
|
+
class CryptoUtil: NSObject {
|
|
6
|
+
static func digest(data: Data) -> Data {
|
|
7
|
+
let hash = SHA256.hash(data: data)
|
|
8
|
+
return Data(hash)
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
public static func getRandomValues(byteLength: Int) -> Data {
|
|
12
|
+
let bytes = (0..<byteLength).map { _ in UInt8.random(in: UInt8.min...UInt8.max) }
|
|
13
|
+
return Data(bytes)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
static func generateJwk() -> EncodedJWK {
|
|
17
|
+
let kid = UUID().uuidString
|
|
18
|
+
|
|
19
|
+
let privKey = P256.Signing.PrivateKey()
|
|
20
|
+
let pubKey = privKey.publicKey
|
|
21
|
+
|
|
22
|
+
let x = pubKey.x963Representation[1..<33].base64URLEncodedString()
|
|
23
|
+
let y = pubKey.x963Representation[33...].base64URLEncodedString()
|
|
24
|
+
let d = privKey.rawRepresentation.base64URLEncodedString()
|
|
25
|
+
|
|
26
|
+
let jwk = EncodedJWK()
|
|
27
|
+
jwk.kty = "EC"
|
|
28
|
+
jwk.crv = "P-256"
|
|
29
|
+
jwk.kid = kid
|
|
30
|
+
jwk.x = x
|
|
31
|
+
jwk.y = y
|
|
32
|
+
jwk.d = d
|
|
33
|
+
jwk.alg = "ES256"
|
|
34
|
+
|
|
35
|
+
return jwk
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
static func decodeJwk(x: String, y: String, d: String) throws -> SecKey {
|
|
39
|
+
func base64UrlDecode(_ string: String) -> Data? {
|
|
40
|
+
var base64 = string
|
|
41
|
+
.replacingOccurrences(of: "-", with: "+")
|
|
42
|
+
.replacingOccurrences(of: "_", with: "/")
|
|
43
|
+
|
|
44
|
+
let remainder = base64.count % 4
|
|
45
|
+
if remainder > 0 {
|
|
46
|
+
base64 += String(repeating: "=", count: 4 - remainder)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return Data(base64Encoded: base64)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
guard let xData = base64UrlDecode(x),
|
|
53
|
+
let yData = base64UrlDecode(y),
|
|
54
|
+
let dData = base64UrlDecode(d) else {
|
|
55
|
+
throw ExpoAtprotoOAuthClientError.invalidJwk
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
var keyData = Data()
|
|
59
|
+
keyData.append(0x04)
|
|
60
|
+
keyData.append(xData)
|
|
61
|
+
keyData.append(yData)
|
|
62
|
+
keyData.append(dData)
|
|
63
|
+
|
|
64
|
+
let attributes: [String: Any] = [
|
|
65
|
+
kSecAttrKeyType as String: kSecAttrKeyTypeEC,
|
|
66
|
+
kSecAttrKeyClass as String: kSecAttrKeyClassPrivate,
|
|
67
|
+
kSecAttrKeySizeInBits as String: 256
|
|
68
|
+
]
|
|
69
|
+
|
|
70
|
+
var error: Unmanaged<CFError>?
|
|
71
|
+
|
|
72
|
+
let key = SecKeyCreateWithData(keyData as CFData, attributes as CFDictionary, &error)
|
|
73
|
+
if error != nil {
|
|
74
|
+
throw error!.takeUnretainedValue()
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
guard let key = key else {
|
|
78
|
+
throw ExpoAtprotoOAuthClientError.invalidJwk
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return key
|
|
82
|
+
}
|
|
83
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
require 'json'
|
|
2
|
+
|
|
3
|
+
package = JSON.parse(File.read(File.join(__dir__, '..', 'package.json')))
|
|
4
|
+
|
|
5
|
+
Pod::Spec.new do |s|
|
|
6
|
+
s.name = 'ExpoAtprotoOAuthClient'
|
|
7
|
+
s.version = package['version']
|
|
8
|
+
s.summary = package['description']
|
|
9
|
+
s.description = package['description']
|
|
10
|
+
s.license = package['license']
|
|
11
|
+
s.author = package['author']
|
|
12
|
+
s.authors = package['authors']
|
|
13
|
+
s.homepage = package['homepage']
|
|
14
|
+
s.platforms = {
|
|
15
|
+
:ios => '15.1',
|
|
16
|
+
:tvos => '15.1'
|
|
17
|
+
}
|
|
18
|
+
s.swift_version = '5.4'
|
|
19
|
+
s.source = { git: 'https://github.com/bluesky-social/atproto' }
|
|
20
|
+
s.static_framework = true
|
|
21
|
+
|
|
22
|
+
s.dependency 'ExpoModulesCore'
|
|
23
|
+
s.dependency 'JOSESwift', '~> 2.3'
|
|
24
|
+
|
|
25
|
+
# Swift/Objective-C compatibility
|
|
26
|
+
s.pod_target_xcconfig = {
|
|
27
|
+
'DEFINES_MODULE' => 'YES',
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
s.source_files = "**/*.{h,m,mm,swift,hpp,cpp}"
|
|
31
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import ExpoModulesCore
|
|
2
|
+
|
|
3
|
+
enum ExpoAtprotoOAuthClientError: Error {
|
|
4
|
+
case unsupportedAlgorithm(String)
|
|
5
|
+
case invalidJwk
|
|
6
|
+
case invalidHeader(String)
|
|
7
|
+
case invalidPayload(String)
|
|
8
|
+
case nullSigner
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
public class ExpoAtprotoOAuthClientModule: Module {
|
|
12
|
+
public func definition() -> ModuleDefinition {
|
|
13
|
+
Name("ExpoAtprotoOAuthClient")
|
|
14
|
+
|
|
15
|
+
AsyncFunction("digest") { (data: Data, algo: String) throws -> Data in
|
|
16
|
+
if algo != "sha256" {
|
|
17
|
+
throw ExpoAtprotoOAuthClientError.unsupportedAlgorithm(algo)
|
|
18
|
+
}
|
|
19
|
+
return CryptoUtil.digest(data: data)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
AsyncFunction("getRandomValues") { (byteLength: Int) -> Data in
|
|
23
|
+
return CryptoUtil.getRandomValues(byteLength: byteLength)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
AsyncFunction("generatePrivateJwk") { (algo: String) throws -> EncodedJWK in
|
|
27
|
+
if algo != "ES256" {
|
|
28
|
+
throw ExpoAtprotoOAuthClientError.unsupportedAlgorithm(algo)
|
|
29
|
+
}
|
|
30
|
+
return CryptoUtil.generateJwk()
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
AsyncFunction("createJwt") { (header: String, payload: String, jwk: EncodedJWK) throws -> String in
|
|
34
|
+
let jwk = try CryptoUtil.decodeJwk(x: jwk.x, y: jwk.y, d: jwk.d)
|
|
35
|
+
let jwt = try JoseUtil.createJwt(header: header, payload: payload, jwk: jwk)
|
|
36
|
+
return jwt
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
AsyncFunction("verifyJwt") { (token: String, jwk: EncodedJWK, options: VerifyOptions) throws -> VerifyResult in
|
|
40
|
+
let jwk = try CryptoUtil.decodeJwk(x: jwk.x, y: jwk.y, d: jwk.d)
|
|
41
|
+
let res = try JoseUtil.verifyJwt(token: token, jwk: jwk, options: options)
|
|
42
|
+
return res
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
package/ios/Jose.swift
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import JOSESwift
|
|
2
|
+
|
|
3
|
+
class JoseUtil: NSObject {
|
|
4
|
+
private static func headerStringToHeader(_ headerString: String) -> JWSHeader? {
|
|
5
|
+
guard let headerData = headerString.data(using: .utf8) else {
|
|
6
|
+
return nil
|
|
7
|
+
}
|
|
8
|
+
return JWSHeader(headerData)
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
private static func payloadStringToPayload(_ payloadString: String) -> Payload? {
|
|
12
|
+
guard let payloadData = payloadString.data(using: .utf8) else {
|
|
13
|
+
return nil
|
|
14
|
+
}
|
|
15
|
+
return Payload(payloadData)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
static func createJwt(header: String, payload: String, jwk: SecKey) throws -> String {
|
|
19
|
+
guard let header = headerStringToHeader(header) else {
|
|
20
|
+
throw ExpoAtprotoOAuthClientError.invalidHeader("could not parse header string")
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
guard let payload = payloadStringToPayload(payload) else {
|
|
24
|
+
throw ExpoAtprotoOAuthClientError.invalidPayload("could not parse payload string")
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
let signer = Signer(signingAlgorithm: .ES256, key: jwk)
|
|
28
|
+
|
|
29
|
+
guard let signer = signer else {
|
|
30
|
+
throw ExpoAtprotoOAuthClientError.nullSigner
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
let jws = try JWS(header: header, payload: payload, signer: signer)
|
|
34
|
+
|
|
35
|
+
return jws.compactSerializedString
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
static func verifyJwt(token: String, jwk: SecKey, options: VerifyOptions) throws -> VerifyResult {
|
|
39
|
+
guard let jws = try? JWS(compactSerialization: token),
|
|
40
|
+
let verifier = Verifier(verifyingAlgorithm: .ES256, key: jwk),
|
|
41
|
+
let validation = try? jws.validate(using: verifier)
|
|
42
|
+
else {
|
|
43
|
+
throw ExpoAtprotoOAuthClientError.invalidJwk
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
let header = validation.header
|
|
47
|
+
let payload = String(data: validation.payload.data(), encoding: .utf8)
|
|
48
|
+
guard let payload = payload else {
|
|
49
|
+
throw ExpoAtprotoOAuthClientError.invalidPayload("unable to parse payload")
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
var protectedHeader: [String: Any] = [:]
|
|
53
|
+
protectedHeader["alg"] = "ES256"
|
|
54
|
+
if header.jku != nil {
|
|
55
|
+
protectedHeader["jku"] = header.jku?.absoluteString
|
|
56
|
+
}
|
|
57
|
+
if header.kid != nil {
|
|
58
|
+
protectedHeader["kid"] = header.kid
|
|
59
|
+
}
|
|
60
|
+
if header.typ != nil {
|
|
61
|
+
protectedHeader["typ"] = header.typ
|
|
62
|
+
}
|
|
63
|
+
if header.cty != nil {
|
|
64
|
+
protectedHeader["cty"] = header.cty
|
|
65
|
+
}
|
|
66
|
+
if header.crit != nil {
|
|
67
|
+
protectedHeader["crit"] = header.crit
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if let typ = options.typ {
|
|
71
|
+
if header.typ != typ {
|
|
72
|
+
throw ExpoAtprotoOAuthClientError.invalidPayload("typ mismatch")
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
let claims = try JSONSerialization.jsonObject(with: validation.payload.data(), options: []) as? [String: Any]
|
|
77
|
+
|
|
78
|
+
if let requiredClaims = options.requiredClaims {
|
|
79
|
+
try requiredClaims.forEach { c in
|
|
80
|
+
if claims?[c] == nil {
|
|
81
|
+
throw ExpoAtprotoOAuthClientError.invalidPayload("required claim \(c) missing")
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if let audience = options.audience {
|
|
87
|
+
if claims?["aud"] as? String != audience {
|
|
88
|
+
throw ExpoAtprotoOAuthClientError.invalidPayload("audience mismatch")
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if let subject = options.subject {
|
|
93
|
+
if claims?["sub"] as? String != subject {
|
|
94
|
+
throw ExpoAtprotoOAuthClientError.invalidPayload("subject mismatch")
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if let checkTolerance = options.clockTolerance {
|
|
99
|
+
let now = Date()
|
|
100
|
+
let expiryDate: Date
|
|
101
|
+
if let expiryString = claims?["exp"] as? String {
|
|
102
|
+
let formatter = ISO8601DateFormatter()
|
|
103
|
+
expiryDate = formatter.date(from: expiryString)!
|
|
104
|
+
} else {
|
|
105
|
+
throw ExpoAtprotoOAuthClientError.invalidPayload("expiry missing")
|
|
106
|
+
}
|
|
107
|
+
if expiryDate < now - checkTolerance {
|
|
108
|
+
throw ExpoAtprotoOAuthClientError.invalidPayload("token expired")
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if let maxTokenAge = options.maxTokenAge {
|
|
113
|
+
let now = Date()
|
|
114
|
+
if let expiryString = claims?["exp"] as? String {
|
|
115
|
+
let formatter = ISO8601DateFormatter()
|
|
116
|
+
let expiryDate = formatter.date(from: expiryString)!
|
|
117
|
+
if expiryDate < now - maxTokenAge {
|
|
118
|
+
throw ExpoAtprotoOAuthClientError.invalidPayload("token expired")
|
|
119
|
+
}
|
|
120
|
+
} else {
|
|
121
|
+
throw ExpoAtprotoOAuthClientError.invalidPayload("expiry missing")
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if let issuer = options.issuer {
|
|
126
|
+
if claims?["iss"] as? String != issuer {
|
|
127
|
+
throw ExpoAtprotoOAuthClientError.invalidPayload("issuer mismatch")
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
let res = VerifyResult()
|
|
132
|
+
res.payload = payload
|
|
133
|
+
res.protectedHeader = protectedHeader
|
|
134
|
+
|
|
135
|
+
return res
|
|
136
|
+
}
|
|
137
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import ExpoModulesCore
|
|
2
|
+
|
|
3
|
+
struct EncodedJWK: Record {
|
|
4
|
+
@Field
|
|
5
|
+
var kty: String
|
|
6
|
+
|
|
7
|
+
@Field
|
|
8
|
+
var crv: String
|
|
9
|
+
|
|
10
|
+
@Field
|
|
11
|
+
var kid: String
|
|
12
|
+
|
|
13
|
+
@Field
|
|
14
|
+
var x: String
|
|
15
|
+
|
|
16
|
+
@Field
|
|
17
|
+
var y: String
|
|
18
|
+
|
|
19
|
+
@Field
|
|
20
|
+
var d: String
|
|
21
|
+
|
|
22
|
+
@Field
|
|
23
|
+
var alg: String
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
struct VerifyOptions: Record {
|
|
27
|
+
@Field
|
|
28
|
+
var audience: String?
|
|
29
|
+
|
|
30
|
+
@Field
|
|
31
|
+
var clockTolerance: Double?
|
|
32
|
+
|
|
33
|
+
@Field
|
|
34
|
+
var issuer: String?
|
|
35
|
+
|
|
36
|
+
@Field
|
|
37
|
+
var maxTokenAge: Double?
|
|
38
|
+
|
|
39
|
+
@Field
|
|
40
|
+
var subject: String?
|
|
41
|
+
|
|
42
|
+
@Field
|
|
43
|
+
var typ: String?
|
|
44
|
+
|
|
45
|
+
@Field
|
|
46
|
+
var currentDate: Date?
|
|
47
|
+
|
|
48
|
+
@Field
|
|
49
|
+
var requiredClaims: [String]?
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
struct VerifyResult: Record {
|
|
53
|
+
@Field
|
|
54
|
+
var payload: String
|
|
55
|
+
|
|
56
|
+
@Field
|
|
57
|
+
var protectedHeader: [String: Any]
|
|
58
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@atproto/oauth-client-expo",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"description": "ATPROTO OAuth client for Expo applications",
|
|
6
|
+
"authors": [
|
|
7
|
+
"Hailey <me@haileyok.com> (https://github.com/haileyok)",
|
|
8
|
+
"Matthieu Sieben <me@matthieusieben.com> (https://github.com/matthieusieben)"
|
|
9
|
+
],
|
|
10
|
+
"keywords": [
|
|
11
|
+
"atproto",
|
|
12
|
+
"oauth",
|
|
13
|
+
"client",
|
|
14
|
+
"react-native",
|
|
15
|
+
"expo"
|
|
16
|
+
],
|
|
17
|
+
"bugs": {
|
|
18
|
+
"url": "https://github.com/bluesky-social/atproto/issues"
|
|
19
|
+
},
|
|
20
|
+
"homepage": "https://github.com/bluesky-social/atproto/tree/main/packages/oauth/oauth-client-expo#readme",
|
|
21
|
+
"repository": {
|
|
22
|
+
"type": "git",
|
|
23
|
+
"url": "https://github.com/bluesky-social/atproto",
|
|
24
|
+
"directory": "packages/oauth/oauth-client-expo"
|
|
25
|
+
},
|
|
26
|
+
"main": "dist/index.js",
|
|
27
|
+
"types": "dist/index.d.ts",
|
|
28
|
+
"exports": {
|
|
29
|
+
".": {
|
|
30
|
+
"types": "./dist/index.d.ts",
|
|
31
|
+
"default": "./dist/index.js"
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"abortcontroller-polyfill": "^1.7.8",
|
|
36
|
+
"core-js": "^3",
|
|
37
|
+
"event-target-polyfill": "^0.0.4",
|
|
38
|
+
"expo-web-browser": "^15.0.8",
|
|
39
|
+
"react-native-mmkv": "^3.3.3",
|
|
40
|
+
"react-native-url-polyfill": "^3.0.0",
|
|
41
|
+
"@atproto-labs/simple-store": "0.3.0",
|
|
42
|
+
"@atproto/oauth-client": "0.5.7",
|
|
43
|
+
"@atproto/oauth-client-browser": "0.3.33"
|
|
44
|
+
},
|
|
45
|
+
"peerDependencies": {
|
|
46
|
+
"expo": "*",
|
|
47
|
+
"react-native": "*"
|
|
48
|
+
},
|
|
49
|
+
"scripts": {
|
|
50
|
+
"build": "tsc --build tsconfig.build.json"
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { NativeModule, requireNativeModule } from 'expo'
|
|
2
|
+
import { SignedJwt, VerifyOptions, VerifyResult } from '@atproto/oauth-client'
|
|
3
|
+
import { ExpoAtprotoOAuthClientModuleEvents } from './ExpoAtprotoOAuthClientModule.types'
|
|
4
|
+
|
|
5
|
+
export type NativeJwk = {
|
|
6
|
+
kty: 'EC'
|
|
7
|
+
crv: 'P-256'
|
|
8
|
+
kid: string
|
|
9
|
+
x: string
|
|
10
|
+
y: string
|
|
11
|
+
d: string
|
|
12
|
+
alg: 'ES256'
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
declare class ExpoAtprotoOAuthClientModule extends NativeModule<ExpoAtprotoOAuthClientModuleEvents> {
|
|
16
|
+
digest(data: Uint8Array, algo: string): Promise<Uint8Array>
|
|
17
|
+
|
|
18
|
+
getRandomValues(byteLength: number): Promise<Uint8Array>
|
|
19
|
+
|
|
20
|
+
generatePrivateJwk(algorithm: string): Promise<NativeJwk>
|
|
21
|
+
|
|
22
|
+
createJwt(header: string, payload: string, jwk: NativeJwk): Promise<SignedJwt>
|
|
23
|
+
|
|
24
|
+
verifyJwt<C extends string = never>(
|
|
25
|
+
token: SignedJwt,
|
|
26
|
+
jwk: NativeJwk,
|
|
27
|
+
options: VerifyOptions<C>,
|
|
28
|
+
): Promise<VerifyResult<C>>
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export default requireNativeModule<ExpoAtprotoOAuthClientModule>(
|
|
32
|
+
'ExpoAtprotoOAuthClient',
|
|
33
|
+
)
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AuthorizeOptions,
|
|
3
|
+
OAuthClient,
|
|
4
|
+
OAuthSession,
|
|
5
|
+
} from '@atproto/oauth-client'
|
|
6
|
+
|
|
7
|
+
export interface ExpoOAuthClientInterface extends OAuthClient, Disposable {
|
|
8
|
+
signIn(input: string, options?: AuthorizeOptions): Promise<OAuthSession>
|
|
9
|
+
handleCallback(): Promise<null | OAuthSession>
|
|
10
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
OAuthClientMetadataInput,
|
|
3
|
+
OAuthClientOptions,
|
|
4
|
+
OAuthResponseMode,
|
|
5
|
+
} from '@atproto/oauth-client'
|
|
6
|
+
|
|
7
|
+
export type Simplify<T> = { [K in keyof T]: T[K] } & NonNullable<unknown>
|
|
8
|
+
|
|
9
|
+
export type ExpoOAuthClientOptions = Simplify<
|
|
10
|
+
{
|
|
11
|
+
clientMetadata: Readonly<OAuthClientMetadataInput>
|
|
12
|
+
responseMode?: Exclude<OAuthResponseMode, 'form_post'>
|
|
13
|
+
} & Omit<
|
|
14
|
+
OAuthClientOptions,
|
|
15
|
+
| 'clientMetadata'
|
|
16
|
+
| 'responseMode'
|
|
17
|
+
| 'keyset'
|
|
18
|
+
| 'runtimeImplementation'
|
|
19
|
+
| 'sessionStore'
|
|
20
|
+
| 'stateStore'
|
|
21
|
+
| 'didCache'
|
|
22
|
+
| 'handleCache'
|
|
23
|
+
| 'dpopNonceCache'
|
|
24
|
+
| 'authorizationServerMetadataCache'
|
|
25
|
+
| 'protectedResourceMetadataCache'
|
|
26
|
+
>
|
|
27
|
+
>
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { ExpoOAuthClientInterface } from './expo-oauth-client-interface'
|
|
2
|
+
import { ExpoOAuthClientOptions } from './expo-oauth-client-options'
|
|
3
|
+
|
|
4
|
+
export declare class ExpoOAuthClient implements ExpoOAuthClientInterface {
|
|
5
|
+
constructor(options: ExpoOAuthClientOptions)
|
|
6
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import './polyfill'
|
|
2
|
+
|
|
3
|
+
import { openAuthSessionAsync } from 'expo-web-browser'
|
|
4
|
+
import {
|
|
5
|
+
AuthorizeOptions,
|
|
6
|
+
OAuthClient,
|
|
7
|
+
OAuthSession,
|
|
8
|
+
} from '@atproto/oauth-client'
|
|
9
|
+
import { default as NativeModule } from './ExpoAtprotoOAuthClientModule'
|
|
10
|
+
import { ExpoOAuthClientInterface } from './expo-oauth-client-interface'
|
|
11
|
+
import { ExpoOAuthClientOptions } from './expo-oauth-client-options'
|
|
12
|
+
import { ExpoKey } from './utils/expo-key'
|
|
13
|
+
import {
|
|
14
|
+
AuthorizationServerMetadataCache,
|
|
15
|
+
DidCache,
|
|
16
|
+
DpopNonceCache,
|
|
17
|
+
HandleCache,
|
|
18
|
+
ProtectedResourceMetadataCache,
|
|
19
|
+
SessionStore,
|
|
20
|
+
StateStore,
|
|
21
|
+
} from './utils/stores'
|
|
22
|
+
|
|
23
|
+
export const CUSTOM_URI_SCHEME_REGEX = /^(?:[^.]+(?:\.[^.]+)+):\/(?:[^/].*)?$/
|
|
24
|
+
const isCustomUriScheme = (uri: string) => CUSTOM_URI_SCHEME_REGEX.test(uri)
|
|
25
|
+
|
|
26
|
+
export class ExpoOAuthClient
|
|
27
|
+
extends OAuthClient
|
|
28
|
+
implements ExpoOAuthClientInterface
|
|
29
|
+
{
|
|
30
|
+
readonly #disposables: DisposableStack
|
|
31
|
+
|
|
32
|
+
constructor(options: ExpoOAuthClientOptions) {
|
|
33
|
+
using stack = new DisposableStack()
|
|
34
|
+
|
|
35
|
+
super({
|
|
36
|
+
...options,
|
|
37
|
+
responseMode: options.responseMode ?? 'query',
|
|
38
|
+
keyset: undefined,
|
|
39
|
+
runtimeImplementation: {
|
|
40
|
+
createKey: async (algs) => ExpoKey.generate(algs),
|
|
41
|
+
digest: async (bytes, { name }) => NativeModule.digest(bytes, name),
|
|
42
|
+
getRandomValues: async (length) => NativeModule.getRandomValues(length),
|
|
43
|
+
},
|
|
44
|
+
sessionStore: stack.use(new SessionStore()),
|
|
45
|
+
stateStore: stack.use(new StateStore()),
|
|
46
|
+
didCache: stack.use(new DidCache()),
|
|
47
|
+
handleCache: stack.use(new HandleCache()),
|
|
48
|
+
dpopNonceCache: stack.use(new DpopNonceCache()),
|
|
49
|
+
authorizationServerMetadataCache: stack.use(
|
|
50
|
+
new AuthorizationServerMetadataCache(),
|
|
51
|
+
),
|
|
52
|
+
protectedResourceMetadataCache: stack.use(
|
|
53
|
+
new ProtectedResourceMetadataCache(),
|
|
54
|
+
),
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
stack.defer(() => super[Symbol.dispose]?.())
|
|
58
|
+
|
|
59
|
+
this.#disposables = stack.move()
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async handleCallback(): Promise<null | OAuthSession> {
|
|
63
|
+
return null
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async signIn(
|
|
67
|
+
input: string,
|
|
68
|
+
options?: AuthorizeOptions,
|
|
69
|
+
): Promise<OAuthSession> {
|
|
70
|
+
const redirectUri =
|
|
71
|
+
options?.redirect_uri ??
|
|
72
|
+
this.clientMetadata.redirect_uris.find(isCustomUriScheme)
|
|
73
|
+
|
|
74
|
+
if (!redirectUri) {
|
|
75
|
+
throw new TypeError(
|
|
76
|
+
'A redirect URI with a custom scheme is required for Expo OAuth.',
|
|
77
|
+
)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const url = await this.authorize(input, {
|
|
81
|
+
...options,
|
|
82
|
+
redirect_uri: redirectUri,
|
|
83
|
+
display: options?.display ?? 'touch',
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
console.debug('openAuthSessionAsync', { url, redirectUri })
|
|
87
|
+
|
|
88
|
+
const result = await openAuthSessionAsync(url.toString(), redirectUri)
|
|
89
|
+
|
|
90
|
+
console.debug('AUTH SESSION RESULT', result)
|
|
91
|
+
|
|
92
|
+
if (result.type === 'success') {
|
|
93
|
+
const callbackUrl = new URL(result.url)
|
|
94
|
+
const params =
|
|
95
|
+
this.responseMode === 'fragment'
|
|
96
|
+
? new URLSearchParams(callbackUrl.hash.slice(1))
|
|
97
|
+
: callbackUrl.searchParams
|
|
98
|
+
|
|
99
|
+
const { session } = await this.callback(params, {
|
|
100
|
+
redirect_uri: redirectUri,
|
|
101
|
+
})
|
|
102
|
+
return session
|
|
103
|
+
} else {
|
|
104
|
+
throw new Error(`Authentication cancelled: ${result.type}`)
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
[Symbol.dispose]() {
|
|
109
|
+
this.#disposables.dispose()
|
|
110
|
+
}
|
|
111
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AuthorizeOptions,
|
|
3
|
+
BrowserOAuthClient,
|
|
4
|
+
OAuthSession,
|
|
5
|
+
} from '@atproto/oauth-client-browser'
|
|
6
|
+
import { ExpoOAuthClientInterface } from './expo-oauth-client-interface'
|
|
7
|
+
import { ExpoOAuthClientOptions } from './expo-oauth-client-options'
|
|
8
|
+
|
|
9
|
+
export class ExpoOAuthClient
|
|
10
|
+
extends BrowserOAuthClient
|
|
11
|
+
implements ExpoOAuthClientInterface
|
|
12
|
+
{
|
|
13
|
+
constructor({
|
|
14
|
+
clientMetadata,
|
|
15
|
+
responseMode = 'fragment',
|
|
16
|
+
...options
|
|
17
|
+
}: ExpoOAuthClientOptions) {
|
|
18
|
+
super({ ...options, clientMetadata, responseMode })
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
override async signIn(
|
|
22
|
+
input: string,
|
|
23
|
+
options?: AuthorizeOptions,
|
|
24
|
+
): Promise<OAuthSession> {
|
|
25
|
+
// Force popup mode
|
|
26
|
+
return this.signInPopup(input, {
|
|
27
|
+
...options,
|
|
28
|
+
display: options?.display ?? 'touch',
|
|
29
|
+
})
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async handleCallback(): Promise<null | OAuthSession> {
|
|
33
|
+
const params = this.readCallbackParams()
|
|
34
|
+
if (!params) return null
|
|
35
|
+
|
|
36
|
+
const url = this.findRedirectUrl()
|
|
37
|
+
if (!url) return null
|
|
38
|
+
|
|
39
|
+
const { session } = await this.initCallback(params, url)
|
|
40
|
+
return session
|
|
41
|
+
}
|
|
42
|
+
}
|
package/src/index.ts
ADDED
package/src/polyfill.ts
ADDED