Skip to content

Commit

Permalink
Web passkeys (#93)
Browse files Browse the repository at this point in the history
  • Loading branch information
itaihanski authored Nov 13, 2024
1 parent c924e4f commit 2949d10
Show file tree
Hide file tree
Showing 10 changed files with 209 additions and 25 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
# 0.9.1

- Support passkeys in Flutter Web

# 0.9.0

- New setup function for initializing the Descope SDK
Expand Down
Original file line number Diff line number Diff line change
@@ -1,38 +1,56 @@
// stub window & document
class StubWindow {
final localStorage = <String, String>{};
String? origin = "";
}

class StubDocument {
StubHtml? head;
StubHtml? body;
}

class StubHtml {
final children = <Element>[];
}

final window = StubWindow();
final document = StubDocument();

Iterable<Element> querySelectorAll(String _) => <Element>[];

// stub html elements
class NodeValidatorBuilder {
NodeValidatorBuilder.common();

allowElement(String _, {Iterable<String>? attributes}) => this;
}

class Element {
Element();

Element.html(String _, {NodeValidatorBuilder? validator});

remove() => {};

addEventListener(String _, EventListener? __) => {};

removeEventListener(String _, EventListener? __) => {};
String? className;
}
class DivElement extends Element{

class DivElement extends Element {
final children = <Element>[];
}

class ScriptElement extends Element {
String text = "";
}

// stub events
typedef EventListener = Function(Event event);

class Event {}

class CustomEvent {
final dynamic detail = <dynamic, dynamic>{};
}
Expand Down
3 changes: 3 additions & 0 deletions lib/src/internal/others/stubs/stub_js_util.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
Future<String> promiseToFuture(Object jsPromise) async {
return "";
}
4 changes: 4 additions & 0 deletions lib/src/internal/others/stubs/stub_package_js.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
class JS {
final String? name;
const JS([this.name]);
}
133 changes: 133 additions & 0 deletions lib/src/internal/others/web_passkeys.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import '/src/internal/others/stubs/stub_html.dart' if (dart.library.js) 'dart:html' hide Platform;
import '/src/internal/others/stubs/stub_js_util.dart' if (dart.library.js) 'dart:js_util';
import '/src/internal/others/stubs/stub_package_js.dart' if (dart.library.js) 'package:js/js.dart';

import '/src/internal/others/error.dart';
import '/src/types/error.dart';

class WebPasskeys {
String getOrigin() {
final origin = window.origin;
if (origin != null) {
return origin;
}
throw DescopeException.passkeyFailed.add(message:"Unable to get window origin");
}

Future<String> passkey(String options, bool create) async {
try {
_setupJs();
if (create) {
return await promiseToFuture(descopeWebAuthnCreate(options));
} else {
return await promiseToFuture(descopeWebAuthnGet(options));
}
} catch (e) {
throw DescopeException.passkeyFailed.add(message: e.toString());
}
}
}

void _setupJs() async {
ScriptElement scriptElement = ScriptElement();
scriptElement.text = _webauthnScript;
document.head?.children.add(scriptElement);
}

@JS()
external dynamic descopeWebAuthnCreate(String options);

@JS()
external dynamic descopeWebAuthnGet(String options);

const _webauthnScript = """
// webauthn create
async function descopeWebAuthnCreate(options) {
if (!descopeIsWebAuthnSupported()) throw Error('Passkeys are not supported');
const createOptions = descopeDecodeCreateOptions(options);
const createResponse = await window.navigator.credentials.create(createOptions);
return descopeEncodeCreateResponse(createResponse);
}
function descopeDecodeCreateOptions(value) {
const options = JSON.parse(value);
options.publicKey.challenge = descopeDecodeBase64Url(options.publicKey.challenge);
options.publicKey.user.id = descopeDecodeBase64Url(options.publicKey.user.id);
options.publicKey.excludeCredentials?.forEach((item) => {
item.id = descopeDecodeBase64Url(item.id);
});
return options;
}
function descopeEncodeCreateResponse(credential) {
return JSON.stringify({
id: credential.id,
rawId: descopeEncodeBase64Url(credential.rawId),
type: credential.type,
response: {
attestationObject: descopeEncodeBase64Url(credential.response.attestationObject),
clientDataJSON: descopeEncodeBase64Url(credential.response.clientDataJSON),
}
});
}
// webauthn get
async function descopeWebAuthnGet(options) {
if (!descopeIsWebAuthnSupported()) throw Error('Passkeys are not supported');
const getOptions = descopeDecodeGetOptions(options);
const getResponse = await navigator.credentials.get(getOptions);
return descopeEncodeGetResponse(getResponse);
}
function descopeDecodeGetOptions(value) {
const options = JSON.parse(value);
options.publicKey.challenge = descopeDecodeBase64Url(options.publicKey.challenge);
options.publicKey.allowCredentials?.forEach((item) => {
item.id = descopeDecodeBase64Url(item.id);
});
return options;
}
function descopeEncodeGetResponse(credential) {
return JSON.stringify({
id: credential.id,
rawId: descopeEncodeBase64Url(credential.rawId),
type: credential.type,
response: {
authenticatorData: descopeEncodeBase64Url(credential.response.authenticatorData),
clientDataJSON: descopeEncodeBase64Url(credential.response.clientDataJSON),
signature: descopeEncodeBase64Url(credential.response.signature),
userHandle: credential.response.userHandle
? descopeEncodeBase64Url(credential.response.userHandle)
: undefined,
}
});
}
// Conversion between ArrayBuffers and Base64Url strings
function descopeDecodeBase64Url(value) {
const base64 = value.replace(/_/g, '/').replace(/-/g, '+');
return Uint8Array.from(atob(base64), (c) => c.charCodeAt(0)).buffer;
}
function descopeEncodeBase64Url(value) {
const base64 = btoa(String.fromCharCode.apply(null, new Uint8Array(value)));
return base64.replace(/\\//g, '_').replace(/\\+/g, '-').replace(/=/g, '');
}
// Is supported
function descopeIsWebAuthnSupported() {
const supported = !!(
window.PublicKeyCredential &&
navigator.credentials &&
navigator.credentials.create &&
navigator.credentials.get
);
return supported;
}
""";
2 changes: 1 addition & 1 deletion lib/src/internal/routes/flow.dart
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import 'package:flutter/services.dart';
import '/src/internal/http/descope_client.dart';
import '/src/internal/http/responses.dart';
import '/src/internal/others/error.dart';
import '/src/internal/others/stub_html.dart' if (dart.library.js) 'dart:html' hide Platform;
import '/src/internal/others/stubs/stub_html.dart' if (dart.library.js) 'dart:html' hide Platform;
import '/src/internal/routes/shared.dart';
import '/src/sdk/routes.dart';
import '/src/types/error.dart';
Expand Down
60 changes: 41 additions & 19 deletions lib/src/internal/routes/passkey.dart
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@

import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';

import '/src/internal/http/descope_client.dart';
import '/src/internal/others/error.dart';
import '/src/internal/routes/shared.dart';
import '/src/internal/others/web_passkeys.dart';
import '/src/sdk/routes.dart';
import '/src/types/error.dart';
import '/src/types/others.dart';
Expand All @@ -13,69 +16,83 @@ class Passkey implements DescopePasskey {
static const _mChannel = MethodChannel('descope_flutter/methods');

final DescopeClient client;
final _webPasskeys = WebPasskeys();

Passkey(this.client);

@override
Future<AuthenticationResponse> signUp({required String loginId, SignUpDetails? details}) async {
ensureMobilePlatform(DescopeException.passkeyFailed);
_ensureSupportedPlatform();

final origin = await getNativeOrigin();
final origin = await getOrigin();
final startResponse = await client.passkeySignUpStart(loginId, details, origin);
final nativeResponse = await nativePasskey(startResponse.options, true);
final passkeyResponse = await nativeOrWebPasskey(startResponse.options, true);

final jwtResponse = await client.passkeySignUpFinish(startResponse.transactionId, nativeResponse);
final jwtResponse = await client.passkeySignUpFinish(startResponse.transactionId, passkeyResponse);
return jwtResponse.toAuthenticationResponse();
}

@override
Future<AuthenticationResponse> signIn({required String loginId, SignInOptions? options}) async {
ensureMobilePlatform(DescopeException.passkeyFailed);
_ensureSupportedPlatform();

final origin = await getNativeOrigin();
final origin = await getOrigin();
final startResponse = await client.passkeySignInStart(loginId, origin, options);
final nativeResponse = await nativePasskey(startResponse.options, false);
final passkeyResponse = await nativeOrWebPasskey(startResponse.options, false);

final jwtResponse = await client.passkeySignInFinish(startResponse.transactionId, nativeResponse);
final jwtResponse = await client.passkeySignInFinish(startResponse.transactionId, passkeyResponse);
return jwtResponse.toAuthenticationResponse();
}

@override
Future<AuthenticationResponse> signUpOrIn({required String loginId, SignInOptions? options}) async {
ensureMobilePlatform(DescopeException.passkeyFailed);
_ensureSupportedPlatform();

final origin = await getNativeOrigin();
final origin = await getOrigin();
final startResponse = await client.passkeySignUpInStart(loginId, origin, options);
final nativeResponse = await nativePasskey(startResponse.options, startResponse.create);
final passkeyResponse = await nativeOrWebPasskey(startResponse.options, startResponse.create);

final jwtResponse = startResponse.create
? (await client.passkeySignUpFinish(startResponse.transactionId, nativeResponse))
: (await client.passkeySignInFinish(startResponse.transactionId, nativeResponse));
? (await client.passkeySignUpFinish(startResponse.transactionId, passkeyResponse))
: (await client.passkeySignInFinish(startResponse.transactionId, passkeyResponse));
return jwtResponse.toAuthenticationResponse();
}

@override
Future<void> add({required String loginId, required String refreshJwt}) async {
ensureMobilePlatform(DescopeException.passkeyFailed);
_ensureSupportedPlatform();

final origin = await getNativeOrigin();
final origin = await getOrigin();
final startResponse = await client.passkeyAddStart(loginId, origin, refreshJwt);
final nativeResponse = await nativePasskey(startResponse.options, true);
final nativeResponse = await nativeOrWebPasskey(startResponse.options, true);

return client.passkeyAddFinish(startResponse.transactionId, nativeResponse);
}

// Internal

Future<String> getNativeOrigin() async {
Future<String> getOrigin() async {
try {
final result = await _mChannel.invokeMethod('passkeyOrigin', {});
return result as String;
if (kIsWeb) {
// web origin
return _webPasskeys.getOrigin();
} else {
// native origin
final result = await _mChannel.invokeMethod('passkeyOrigin', {});
return result as String;
}
} on Exception {
throw DescopeException.passkeyFailed.add(message: 'Failed to determine passkey origin');
}
}

Future<String> nativeOrWebPasskey(String options, bool create) async {
if (kIsWeb) {
return await _webPasskeys.passkey(options, create);
}
return nativePasskey(options, create);
}

Future<String> nativePasskey(String options, bool create) async {
dynamic result;
try {
Expand All @@ -98,3 +115,8 @@ class Passkey implements DescopePasskey {
}

}

void _ensureSupportedPlatform() {
if (kIsWeb || Platform.isIOS || Platform.isAndroid) return;
throw DescopeException.passkeyFailed.add(message: 'Feature not supported on this platform');
}
2 changes: 1 addition & 1 deletion lib/src/sdk/sdk.dart
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ class DescopeSdk {
static const name = 'DescopeFlutter';

/// The Descope SDK version
static const version = '0.9.0';
static const version = '0.9.1';

/// The configuration of the [DescopeSdk] instance.
final DescopeConfig config;
Expand Down
2 changes: 1 addition & 1 deletion lib/src/session/storage.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:json_annotation/json_annotation.dart';

import '/src/internal/others/stub_html.dart' if (dart.library.js) 'dart:html' hide Platform;
import '/src/internal/others/stubs/stub_html.dart' if (dart.library.js) 'dart:html' hide Platform;
import '/src/types/user.dart';
import 'manager.dart';
import 'session.dart';
Expand Down
4 changes: 2 additions & 2 deletions pubspec.yaml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
name: descope
description: A Flutter package for working with the Descope API.
version: 0.9.0
version: 0.9.1
homepage: https://www.descope.com
repository: https://github.com/descope/descope-flutter
issue_tracker: https://github.com/descope/descope-flutter/issues
Expand All @@ -20,7 +20,7 @@ dependencies:
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^5.0.0
flutter_lints: ^4.0.0
build_runner: ^2.3.3
json_serializable: ^6.6.0

Expand Down

0 comments on commit 2949d10

Please sign in to comment.