Skip to content

Commit af33ad0

Browse files
feat(passkeys): Implement passkey module with create and get methods, and integrate into Home screen
1 parent b9871f8 commit af33ad0

10 files changed

Lines changed: 439 additions & 6 deletions

File tree

example/android/app/src/main/java/com/auth0example/MainApplication.kt

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,7 @@ class MainApplication : Application(), ReactApplication {
1414
context = applicationContext,
1515
packageList =
1616
PackageList(this).packages.apply {
17-
// Packages that cannot be autolinked yet can be added manually here, for example:
18-
// add(MyReactNativePackage())
17+
add(PasskeyPackage())
1918
},
2019
)
2120
}
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
package com.auth0example
2+
3+
import android.app.Activity
4+
import android.os.Build
5+
import androidx.credentials.CreatePublicKeyCredentialRequest
6+
import androidx.credentials.CreatePublicKeyCredentialResponse
7+
import androidx.credentials.CredentialManager
8+
import androidx.credentials.GetCredentialRequest
9+
import androidx.credentials.GetPublicKeyCredentialOption
10+
import androidx.credentials.PublicKeyCredential
11+
import androidx.credentials.exceptions.CreateCredentialCancellationException
12+
import androidx.credentials.exceptions.GetCredentialCancellationException
13+
import com.facebook.react.bridge.Promise
14+
import com.facebook.react.bridge.ReactApplicationContext
15+
import com.facebook.react.bridge.ReactContextBaseJavaModule
16+
import com.facebook.react.bridge.ReactMethod
17+
import kotlinx.coroutines.CoroutineScope
18+
import kotlinx.coroutines.Dispatchers
19+
import kotlinx.coroutines.launch
20+
21+
class PasskeyModule(reactContext: ReactApplicationContext) :
22+
ReactContextBaseJavaModule(reactContext) {
23+
24+
override fun getName(): String = "PasskeyModule"
25+
26+
@ReactMethod
27+
fun createPasskey(requestJson: String, promise: Promise) {
28+
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) {
29+
promise.reject("PASSKEY_NOT_AVAILABLE", "Passkeys require Android API 28 or later")
30+
return
31+
}
32+
33+
val activity: Activity = reactApplicationContext.currentActivity
34+
?: run {
35+
promise.reject("PASSKEY_FAILED", "No activity available")
36+
return
37+
}
38+
39+
val credentialManager = CredentialManager.create(reactApplicationContext)
40+
val createRequest = CreatePublicKeyCredentialRequest(requestJson = requestJson)
41+
42+
CoroutineScope(Dispatchers.Main).launch {
43+
try {
44+
val result = credentialManager.createCredential(activity, createRequest)
45+
val response = result as CreatePublicKeyCredentialResponse
46+
promise.resolve(response.registrationResponseJson)
47+
} catch (e: CreateCredentialCancellationException) {
48+
promise.reject("USER_CANCELLED", "User cancelled passkey creation", e)
49+
} catch (e: Exception) {
50+
promise.reject("PASSKEY_FAILED", e.message, e)
51+
}
52+
}
53+
}
54+
55+
@ReactMethod
56+
fun getPasskey(requestJson: String, promise: Promise) {
57+
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) {
58+
promise.reject("PASSKEY_NOT_AVAILABLE", "Passkeys require Android API 28 or later")
59+
return
60+
}
61+
62+
val activity: Activity = reactApplicationContext.currentActivity
63+
?: run {
64+
promise.reject("PASSKEY_FAILED", "No activity available")
65+
return
66+
}
67+
68+
val credentialManager = CredentialManager.create(reactApplicationContext)
69+
val getRequest = GetCredentialRequest(
70+
listOf(GetPublicKeyCredentialOption(requestJson = requestJson))
71+
)
72+
73+
CoroutineScope(Dispatchers.Main).launch {
74+
try {
75+
val result = credentialManager.getCredential(activity, getRequest)
76+
val credential = result.credential as PublicKeyCredential
77+
promise.resolve(credential.authenticationResponseJson)
78+
} catch (e: GetCredentialCancellationException) {
79+
promise.reject("USER_CANCELLED", "User cancelled passkey assertion", e)
80+
} catch (e: Exception) {
81+
promise.reject("PASSKEY_FAILED", e.message, e)
82+
}
83+
}
84+
}
85+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
package com.auth0example
2+
3+
import com.facebook.react.ReactPackage
4+
import com.facebook.react.bridge.NativeModule
5+
import com.facebook.react.bridge.ReactApplicationContext
6+
import com.facebook.react.uimanager.ViewManager
7+
8+
class PasskeyPackage : ReactPackage {
9+
override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> {
10+
return listOf(PasskeyModule(reactContext))
11+
}
12+
13+
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> {
14+
return emptyList()
15+
}
16+
}

example/ios/Auth0Example.xcodeproj/project.pbxproj

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212
815544A03F48449898D8605F /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = C63DF729B29B9546313C3403 /* PrivacyInfo.xcprivacy */; };
1313
81AB9BB82411601600AC10FF /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */; };
1414
E99D02CA2DCD372E003D3E67 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = E99D02C92DCD372E003D3E67 /* AppDelegate.swift */; };
15+
E99D02D32DCD3730003D3E67 /* PasskeyModule.swift in Sources */ = {isa = PBXBuildFile; fileRef = E99D02D02DCD3730003D3E67 /* PasskeyModule.swift */; };
16+
E99D02D42DCD3730003D3E67 /* PasskeyModule.m in Sources */ = {isa = PBXBuildFile; fileRef = E99D02D12DCD3730003D3E67 /* PasskeyModule.m */; };
1517
/* End PBXBuildFile section */
1618

1719
/* Begin PBXContainerItemProxy section */
@@ -35,6 +37,9 @@
3537
81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = LaunchScreen.storyboard; path = Auth0Example/LaunchScreen.storyboard; sourceTree = "<group>"; };
3638
C63DF729B29B9546313C3403 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xml; name = PrivacyInfo.xcprivacy; path = Auth0Example/PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
3739
E99D02C92DCD372E003D3E67 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
40+
E99D02D02DCD3730003D3E67 /* PasskeyModule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = PasskeyModule.swift; path = Auth0Example/PasskeyModule.swift; sourceTree = "<group>"; };
41+
E99D02D12DCD3730003D3E67 /* PasskeyModule.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; name = PasskeyModule.m; path = Auth0Example/PasskeyModule.m; sourceTree = "<group>"; };
42+
E99D02D22DCD3730003D3E67 /* Auth0Example-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = "Auth0Example-Bridging-Header.h"; path = "Auth0Example/Auth0Example-Bridging-Header.h"; sourceTree = "<group>"; };
3843
ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; };
3944
/* End PBXFileReference section */
4045

@@ -65,6 +70,9 @@
6570
81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */,
6671
C63DF729B29B9546313C3403 /* PrivacyInfo.xcprivacy */,
6772
E99D02C92DCD372E003D3E67 /* AppDelegate.swift */,
73+
E99D02D02DCD3730003D3E67 /* PasskeyModule.swift */,
74+
E99D02D12DCD3730003D3E67 /* PasskeyModule.m */,
75+
E99D02D22DCD3730003D3E67 /* Auth0Example-Bridging-Header.h */,
6876
);
6977
name = Auth0Example;
7078
sourceTree = "<group>";
@@ -323,6 +331,8 @@
323331
buildActionMask = 2147483647;
324332
files = (
325333
E99D02CA2DCD372E003D3E67 /* AppDelegate.swift in Sources */,
334+
E99D02D32DCD3730003D3E67 /* PasskeyModule.swift in Sources */,
335+
E99D02D42DCD3730003D3E67 /* PasskeyModule.m in Sources */,
326336
);
327337
runOnlyForDeploymentPostprocessing = 0;
328338
};
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
#import <React/RCTBridgeModule.h>
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
#import <React/RCTBridgeModule.h>
2+
3+
@interface RCT_EXTERN_MODULE(PasskeyModule, NSObject)
4+
5+
RCT_EXTERN_METHOD(createPasskey:(NSString *)requestJson
6+
resolve:(RCTPromiseResolveBlock)resolve
7+
reject:(RCTPromiseRejectBlock)reject)
8+
9+
RCT_EXTERN_METHOD(getPasskey:(NSString *)requestJson
10+
resolve:(RCTPromiseResolveBlock)resolve
11+
reject:(RCTPromiseRejectBlock)reject)
12+
13+
+ (BOOL)requiresMainQueueSetup {
14+
return YES;
15+
}
16+
17+
@end
Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
import AuthenticationServices
2+
import Foundation
3+
4+
@available(iOS 16.6, *)
5+
@objc(PasskeyModule)
6+
class PasskeyModule: NSObject {
7+
8+
@objc static func requiresMainQueueSetup() -> Bool {
9+
return true
10+
}
11+
12+
@objc func createPasskey(_ requestJson: String, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
13+
guard #available(iOS 16.6, *) else {
14+
reject("PASSKEY_NOT_AVAILABLE", "Passkeys require iOS 16.6 or later", nil)
15+
return
16+
}
17+
18+
guard let data = requestJson.data(using: .utf8),
19+
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
20+
reject("PASSKEY_FAILED", "Invalid request JSON", nil)
21+
return
22+
}
23+
24+
guard let rp = json["rp"] as? [String: Any],
25+
let rpId = rp["id"] as? String,
26+
let challengeStr = json["challenge"] as? String,
27+
let challengeData = Data(base64URLEncoded: challengeStr),
28+
let user = json["user"] as? [String: Any],
29+
let userName = user["name"] as? String,
30+
let userIdStr = user["id"] as? String,
31+
let userId = Data(base64URLEncoded: userIdStr) else {
32+
reject("PASSKEY_FAILED", "Missing required fields: rp.id, challenge, user.id, user.name", nil)
33+
return
34+
}
35+
36+
let provider = ASAuthorizationPlatformPublicKeyCredentialProvider(relyingPartyIdentifier: rpId)
37+
let request = provider.createCredentialRegistrationRequest(challenge: challengeData, name: userName, userID: userId)
38+
39+
let delegate = AuthorizationDelegate { credential in
40+
guard let registration = credential as? ASAuthorizationPlatformPublicKeyCredentialRegistration else {
41+
reject("PASSKEY_FAILED", "Unexpected credential type", nil)
42+
return
43+
}
44+
let result: [String: Any] = [
45+
"id": registration.credentialID.base64URLEncodedString(),
46+
"rawId": registration.credentialID.base64URLEncodedString(),
47+
"type": "public-key",
48+
"response": [
49+
"clientDataJSON": registration.rawClientDataJSON.base64URLEncodedString(),
50+
"attestationObject": (registration.rawAttestationObject ?? Data()).base64URLEncodedString()
51+
],
52+
"authenticatorAttachment": "platform"
53+
]
54+
if let jsonData = try? JSONSerialization.data(withJSONObject: result),
55+
let jsonString = String(data: jsonData, encoding: .utf8) {
56+
resolve(jsonString)
57+
} else {
58+
reject("PASSKEY_FAILED", "Failed to serialize credential response", nil)
59+
}
60+
} onError: { error in
61+
if let authError = error as? ASAuthorizationError, authError.code == .canceled {
62+
reject("USER_CANCELLED", "User cancelled passkey creation", error)
63+
} else {
64+
reject("PASSKEY_FAILED", error.localizedDescription, error)
65+
}
66+
}
67+
68+
let controller = ASAuthorizationController(authorizationRequests: [request])
69+
controller.delegate = delegate
70+
controller.presentationContextProvider = delegate
71+
objc_setAssociatedObject(controller, "delegate", delegate, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
72+
controller.performRequests()
73+
}
74+
75+
@objc func getPasskey(_ requestJson: String, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
76+
guard #available(iOS 16.6, *) else {
77+
reject("PASSKEY_NOT_AVAILABLE", "Passkeys require iOS 16.6 or later", nil)
78+
return
79+
}
80+
81+
guard let data = requestJson.data(using: .utf8),
82+
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
83+
reject("PASSKEY_FAILED", "Invalid request JSON", nil)
84+
return
85+
}
86+
87+
guard let challengeStr = json["challenge"] as? String,
88+
let challengeData = Data(base64URLEncoded: challengeStr) else {
89+
reject("PASSKEY_FAILED", "Missing required 'challenge' field", nil)
90+
return
91+
}
92+
93+
let rpId = json["rpId"] as? String ?? ""
94+
let provider = ASAuthorizationPlatformPublicKeyCredentialProvider(relyingPartyIdentifier: rpId)
95+
let assertionRequest = provider.createCredentialAssertionRequest(challenge: challengeData)
96+
97+
if let allowCredentials = json["allowCredentials"] as? [[String: Any]] {
98+
assertionRequest.allowedCredentials = allowCredentials.compactMap { cred in
99+
guard let idStr = cred["id"] as? String,
100+
let idData = Data(base64URLEncoded: idStr) else { return nil }
101+
return ASAuthorizationPlatformPublicKeyCredentialDescriptor(credentialID: idData)
102+
}
103+
}
104+
105+
let delegate = AuthorizationDelegate { credential in
106+
guard let assertion = credential as? ASAuthorizationPlatformPublicKeyCredentialAssertion else {
107+
reject("PASSKEY_FAILED", "Unexpected credential type", nil)
108+
return
109+
}
110+
var response: [String: Any] = [
111+
"clientDataJSON": assertion.rawClientDataJSON.base64URLEncodedString(),
112+
"authenticatorData": assertion.rawAuthenticatorData.base64URLEncodedString(),
113+
"signature": assertion.signature.base64URLEncodedString()
114+
]
115+
if let userHandle = assertion.userID {
116+
response["userHandle"] = userHandle.base64URLEncodedString()
117+
}
118+
let result: [String: Any] = [
119+
"id": assertion.credentialID.base64URLEncodedString(),
120+
"rawId": assertion.credentialID.base64URLEncodedString(),
121+
"type": "public-key",
122+
"response": response,
123+
"authenticatorAttachment": "platform"
124+
]
125+
if let jsonData = try? JSONSerialization.data(withJSONObject: result),
126+
let jsonString = String(data: jsonData, encoding: .utf8) {
127+
resolve(jsonString)
128+
} else {
129+
reject("PASSKEY_FAILED", "Failed to serialize credential response", nil)
130+
}
131+
} onError: { error in
132+
if let authError = error as? ASAuthorizationError, authError.code == .canceled {
133+
reject("USER_CANCELLED", "User cancelled passkey assertion", error)
134+
} else {
135+
reject("PASSKEY_FAILED", error.localizedDescription, error)
136+
}
137+
}
138+
139+
let controller = ASAuthorizationController(authorizationRequests: [assertionRequest])
140+
controller.delegate = delegate
141+
controller.presentationContextProvider = delegate
142+
objc_setAssociatedObject(controller, "delegate", delegate, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
143+
controller.performRequests()
144+
}
145+
}
146+
147+
// MARK: - Authorization Delegate
148+
149+
@available(iOS 16.6, *)
150+
private class AuthorizationDelegate: NSObject, ASAuthorizationControllerDelegate, ASAuthorizationControllerPresentationContextProviding {
151+
private let onSuccess: (ASAuthorizationCredential) -> Void
152+
private let onError: (Error) -> Void
153+
154+
init(onSuccess: @escaping (ASAuthorizationCredential) -> Void, onError: @escaping (Error) -> Void) {
155+
self.onSuccess = onSuccess
156+
self.onError = onError
157+
super.init()
158+
}
159+
160+
func authorizationController(controller: ASAuthorizationController, didCompleteWithAuthorization authorization: ASAuthorization) {
161+
onSuccess(authorization.credential)
162+
}
163+
164+
func authorizationController(controller: ASAuthorizationController, didCompleteWithError error: Error) {
165+
onError(error)
166+
}
167+
168+
func presentationAnchor(for controller: ASAuthorizationController) -> ASPresentationAnchor {
169+
return UIApplication.shared.connectedScenes
170+
.compactMap { $0 as? UIWindowScene }
171+
.flatMap { $0.windows }
172+
.first { $0.isKeyWindow } ?? ASPresentationAnchor()
173+
}
174+
}
175+
176+
// MARK: - Data Base64URL Extensions
177+
178+
private extension Data {
179+
init?(base64URLEncoded string: String) {
180+
var base64 = string
181+
.replacingOccurrences(of: "-", with: "+")
182+
.replacingOccurrences(of: "_", with: "/")
183+
let remainder = base64.count % 4
184+
if remainder > 0 {
185+
base64.append(String(repeating: "=", count: 4 - remainder))
186+
}
187+
self.init(base64Encoded: base64)
188+
}
189+
190+
func base64URLEncodedString() -> String {
191+
return self.base64EncodedString()
192+
.replacingOccurrences(of: "+", with: "-")
193+
.replacingOccurrences(of: "/", with: "_")
194+
.replacingOccurrences(of: "=", with: "")
195+
}
196+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { NativeModules, Platform } from 'react-native';
2+
3+
const { PasskeyModule } = NativeModules;
4+
5+
export async function createPasskey(
6+
options: Record<string, any>
7+
): Promise<string> {
8+
if (Platform.OS === 'web') {
9+
throw new Error('Passkeys are not supported on web');
10+
}
11+
const requestJson = JSON.stringify(options);
12+
return PasskeyModule.createPasskey(requestJson);
13+
}
14+
15+
export async function getPasskey(
16+
options: Record<string, any>
17+
): Promise<string> {
18+
if (Platform.OS === 'web') {
19+
throw new Error('Passkeys are not supported on web');
20+
}
21+
const requestJson = JSON.stringify(options);
22+
return PasskeyModule.getPasskey(requestJson);
23+
}

0 commit comments

Comments
 (0)