diff --git a/.github/workflows/identity-stack.yml b/.github/workflows/identity-stack.yml index a088d984fb..541cca8923 100644 --- a/.github/workflows/identity-stack.yml +++ b/.github/workflows/identity-stack.yml @@ -26,19 +26,25 @@ on: branches: [ master ] paths: - 'CodenameOne/src/com/codename1/io/oidc/**' + - 'CodenameOne/src/com/codename1/io/webauthn/**' - 'CodenameOne/src/com/codename1/io/Oauth2.java' - 'CodenameOne/src/com/codename1/io/AccessToken.java' - 'CodenameOne/src/com/codename1/social/**' - 'Ports/iOSPort/nativeSources/CN1OidcBrowser.*' - 'Ports/iOSPort/nativeSources/CN1AppleSignIn.*' + - 'Ports/iOSPort/nativeSources/CN1WebAuthn.*' + - 'Ports/iOSPort/nativeSources/CodenameOne_GLViewController.h' - 'Ports/iOSPort/src/com/codename1/io/oidc/**' + - 'Ports/iOSPort/src/com/codename1/io/webauthn/**' - 'Ports/iOSPort/src/com/codename1/social/AppleSignInNativeImpl.java' - 'Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java' - 'Ports/Android/src/com/codename1/io/oidc/**' + - 'Ports/Android/src/com/codename1/io/webauthn/**' - 'Ports/Android/src/com/codename1/social/AppleSignInNativeImpl.java' - 'maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java' - 'maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java' - 'maven/core-unittests/src/test/java/com/codename1/io/oidc/**' + - 'maven/core-unittests/src/test/java/com/codename1/io/webauthn/**' - 'maven/core-unittests/src/test/java/com/codename1/io/Oauth2*' - 'maven/core-unittests/src/test/java/com/codename1/social/**' - 'Samples/samples/UniversalSignInDemo/**' @@ -48,18 +54,23 @@ on: branches: [ master ] paths: - 'CodenameOne/src/com/codename1/io/oidc/**' + - 'CodenameOne/src/com/codename1/io/webauthn/**' - 'CodenameOne/src/com/codename1/io/Oauth2.java' - 'CodenameOne/src/com/codename1/social/**' - 'Ports/iOSPort/nativeSources/CN1OidcBrowser.*' - 'Ports/iOSPort/nativeSources/CN1AppleSignIn.*' + - 'Ports/iOSPort/nativeSources/CN1WebAuthn.*' - 'Ports/iOSPort/src/com/codename1/io/oidc/**' + - 'Ports/iOSPort/src/com/codename1/io/webauthn/**' - 'Ports/iOSPort/src/com/codename1/social/AppleSignInNativeImpl.java' - 'Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java' - 'Ports/Android/src/com/codename1/io/oidc/**' + - 'Ports/Android/src/com/codename1/io/webauthn/**' - 'Ports/Android/src/com/codename1/social/AppleSignInNativeImpl.java' - 'maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java' - 'maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java' - 'maven/core-unittests/src/test/java/com/codename1/io/oidc/**' + - 'maven/core-unittests/src/test/java/com/codename1/io/webauthn/**' - 'Samples/samples/UniversalSignInDemo/**' - '.github/workflows/identity-stack.yml' @@ -120,7 +131,7 @@ jobs: -P unittests \ -pl core-unittests -am \ test \ - -Dtest='OidcCoreTest,Oauth2Test,Oauth2RefreshTokenRequestTest,GoogleConnectTest,FacebookConnectTest,LoginTest,Login1Test,LoginExtrasTest' \ + -Dtest='OidcCoreTest,WebAuthnCoreTest,Oauth2Test,Oauth2RefreshTokenRequestTest,GoogleConnectTest,FacebookConnectTest,LoginTest,Login1Test,LoginExtrasTest' \ -Dsurefire.failIfNoSpecifiedTests=false - name: Compile Maven plugin (verifies IPhoneBuilder + AndroidGradleBuilder scanner edits) @@ -162,11 +173,12 @@ jobs: LISTING="$(unzip -l "${BUNDLE}")" for required in \ com/codename1/io/oidc/OidcBrowserNativeImpl.java \ + com/codename1/io/webauthn/WebAuthnNativeImpl.java \ com/codename1/social/AppleSignInNativeImpl.java; do if ! grep -qF "${required}" <<<"${LISTING}"; then echo "::error::${required} missing from android_port_sources.jar" - echo "Bundle listing (oidc / social entries):" - grep -E "oidc|social" <<<"${LISTING}" || true + echo "Bundle listing (oidc / webauthn / social entries):" + grep -E "oidc|webauthn|social" <<<"${LISTING}" || true exit 1 fi done @@ -213,6 +225,7 @@ jobs: CodenameOne/src/com/codename1/social/Auth0Connect.java CodenameOne/src/com/codename1/social/FirebaseAuth.java CodenameOne/src/com/codename1/social/MicrosoftConnect.java + CodenameOne/src/com/codename1/io/webauthn ) # `|| true` is intentional: grep -E exits 1 when there are zero # matches, which is the success case. @@ -281,7 +294,7 @@ jobs: for label in stubs full; do extra="" if [ "$label" = full ]; then - extra="-DCN1_INCLUDE_OIDC -DCN1_INCLUDE_APPLESIGNIN" + extra="-DCN1_INCLUDE_OIDC -DCN1_INCLUDE_APPLESIGNIN -DCN1_INCLUDE_WEBAUTHN" fi echo "::group::clang $label" xcrun --sdk iphoneos clang \ @@ -295,6 +308,7 @@ jobs: -DNEW_CODENAME_ONE_VM=1 \ $extra \ CN1OidcBrowser.m \ - CN1AppleSignIn.m + CN1AppleSignIn.m \ + CN1WebAuthn.m echo "::endgroup::" done diff --git a/CodenameOne/src/com/codename1/io/webauthn/PublicKeyCredential.java b/CodenameOne/src/com/codename1/io/webauthn/PublicKeyCredential.java new file mode 100644 index 0000000000..0d994baa49 --- /dev/null +++ b/CodenameOne/src/com/codename1/io/webauthn/PublicKeyCredential.java @@ -0,0 +1,168 @@ +/* + * Copyright (c) 2012-2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.io.webauthn; + +import com.codename1.io.JSONParser; +import com.codename1.util.regex.StringReader; + +import java.io.IOException; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +/// The authenticator's response to a passkey ceremony -- either a registration +/// (`type=public-key`, `response.attestationObject` present) or an assertion +/// (`response.signature` + `response.authenticatorData` present). +/// +/// Immutable. The most common usage is to call [#toJson()] and POST the result +/// to your relying-party server, which then runs full signature / origin / +/// counter verification using a server-side library. Do not try to verify the +/// attestation or assertion on the device -- that is the relying party's +/// responsibility. +/// +/// @since 7.0.245 +public final class PublicKeyCredential { + + /// Credential type -- always `"public-key"` for WebAuthn. + public static final String TYPE_PUBLIC_KEY = "public-key"; + + private final String json; + private final Map parsed; + private final boolean registration; + + private PublicKeyCredential(String json, Map parsed, boolean registration) { + this.json = json; + this.parsed = parsed == null + ? Collections.emptyMap() + : Collections.unmodifiableMap(new HashMap(parsed)); + this.registration = registration; + } + + /// Parses a RegistrationResponseJSON / AuthenticationResponseJSON document + /// returned by the native authenticator. + public static PublicKeyCredential fromJson(String json) { + if (json == null) { + throw new IllegalArgumentException("json must not be null"); + } + Map parsed; + try { + parsed = new JSONParser().parseJSON(new StringReader(json)); + } catch (IOException ioe) { + throw new IllegalArgumentException("Invalid response JSON: " + ioe.getMessage(), ioe); + } + if (parsed == null || parsed.isEmpty()) { + throw new IllegalArgumentException("Response JSON is empty or unparseable"); + } + Object response = parsed.get("response"); + boolean registration = false; + if (response instanceof Map) { + registration = ((Map) response).get("attestationObject") != null; + } + return new PublicKeyCredential(json, parsed, registration); + } + + /// Returns the original JSON. POST this back to your relying-party server + /// verbatim. + public String toJson() { + return json; + } + + /// Read-only view of the parsed JSON. + public Map asMap() { + return parsed; + } + + /// `id` -- the credential identifier, base64url-encoded. Stable across + /// ceremonies for the same authenticator + relying party pair, so this + /// is what you store on the server. + public String getId() { + Object id = parsed.get("id"); + return id == null ? null : id.toString(); + } + + /// `rawId` -- the same identifier as a base64url-encoded byte array. + public String getRawId() { + Object id = parsed.get("rawId"); + return id == null ? null : id.toString(); + } + + /// `authenticatorAttachment` -- `"platform"` if a built-in authenticator + /// (Face ID / Touch ID, Android biometrics) handled the request, + /// `"cross-platform"` for a hardware key, or `null` if the OS did not + /// report it. + public String getAuthenticatorAttachment() { + Object a = parsed.get("authenticatorAttachment"); + return a == null ? null : a.toString(); + } + + /// `true` if this is a registration (create) response. `false` for an + /// assertion (get) response. + public boolean isRegistration() { + return registration; + } + + /// `response.clientDataJSON`, base64url-encoded. Decoded server-side and + /// checked against the original challenge / origin. + public String getClientDataJSON() { + Object r = parsed.get("response"); + if (r instanceof Map) { + Object v = ((Map) r).get("clientDataJSON"); + return v == null ? null : v.toString(); + } + return null; + } + + /// `response.attestationObject` for a registration response, + /// base64url-encoded. `null` on an assertion response. + public String getAttestationObject() { + Object r = parsed.get("response"); + if (r instanceof Map) { + Object v = ((Map) r).get("attestationObject"); + return v == null ? null : v.toString(); + } + return null; + } + + /// `response.signature` for an assertion response, base64url-encoded. + /// `null` on a registration response. + public String getSignature() { + Object r = parsed.get("response"); + if (r instanceof Map) { + Object v = ((Map) r).get("signature"); + return v == null ? null : v.toString(); + } + return null; + } + + /// `response.userHandle` for an assertion response, base64url-encoded. + /// Matches the `user.id` from the registration ceremony. + public String getUserHandle() { + Object r = parsed.get("response"); + if (r instanceof Map) { + Object v = ((Map) r).get("userHandle"); + return v == null ? null : v.toString(); + } + return null; + } +} diff --git a/CodenameOne/src/com/codename1/io/webauthn/PublicKeyCredentialCreationOptions.java b/CodenameOne/src/com/codename1/io/webauthn/PublicKeyCredentialCreationOptions.java new file mode 100644 index 0000000000..b44c8780dc --- /dev/null +++ b/CodenameOne/src/com/codename1/io/webauthn/PublicKeyCredentialCreationOptions.java @@ -0,0 +1,296 @@ +/* + * Copyright (c) 2012-2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.io.webauthn; + +import com.codename1.io.JSONParser; +import com.codename1.util.regex.StringReader; + +import java.io.IOException; +import java.util.Map; + +/// W3C `PublicKeyCredentialCreationOptionsJSON` -- the options blob your +/// relying-party server sends to start a passkey *registration* ceremony. +/// +/// In practice you receive a JSON string from your backend (libraries like +/// `webauthn4j`, `webauthn-rs`, `@simplewebauthn/server` and Auth0 / Firebase's +/// passkey endpoints all emit this shape) and hand it to +/// [WebAuthnClient#create(PublicKeyCredentialCreationOptions)]: +/// +/// ```java +/// String optionsJson = httpPost("/passkey/register/challenge", body); +/// PublicKeyCredentialCreationOptions opts = +/// PublicKeyCredentialCreationOptions.fromJson(optionsJson); +/// WebAuthnClient.getInstance().create(opts) +/// .ready(new SuccessCallback() { +/// public void onSucess(PublicKeyCredential cred) { +/// httpPost("/passkey/register/verify", cred.toJson()); +/// } +/// }); +/// ``` +/// +/// The class is a thin wrapper over the JSON: it preserves the exact wire +/// representation in [#toJson()] (so the native authenticator sees what your +/// server intended), while exposing convenience accessors for the most-used +/// fields. To synthesise options client-side (rare -- usually only useful in +/// tests) use [#newBuilder()]. +/// +/// @since 7.0.245 +public final class PublicKeyCredentialCreationOptions { + + /// The full options JSON, preserved verbatim so the authenticator sees + /// exactly what the relying party intended. + private final String json; + private final Map parsed; + + private PublicKeyCredentialCreationOptions(String json, Map parsed) { + this.json = json; + this.parsed = parsed; + } + + /// Parses a PublicKeyCredentialCreationOptionsJSON document. The JSON is + /// kept intact for forwarding to the native authenticator; any fields + /// this class doesn't model are still passed through unchanged. + public static PublicKeyCredentialCreationOptions fromJson(String json) { + if (json == null) { + throw new IllegalArgumentException("json must not be null"); + } + Map parsed; + try { + parsed = new JSONParser().parseJSON(new StringReader(json)); + } catch (IOException ioe) { + throw new IllegalArgumentException("Invalid options JSON: " + ioe.getMessage(), ioe); + } + if (parsed == null) { + throw new IllegalArgumentException("Options JSON parsed to null"); + } + return new PublicKeyCredentialCreationOptions(json, parsed); + } + + /// Returns the original JSON document. The native authenticator receives + /// this string directly. + public String toJson() { + return json; + } + + /// The full parsed document. Useful for inspecting fields not modelled + /// directly on this class (`attestation`, `excludeCredentials`, + /// `authenticatorSelection`, etc.). + public Map asMap() { + return parsed; + } + + /// Relying-party identifier (`rp.id`). On iOS this must match an + /// `applinks:` Associated Domain. On Android this must match an + /// `assetlinks.json` published at `https:///.well-known/...`. + public String getRpId() { + Object rp = parsed.get("rp"); + if (rp instanceof Map) { + Object id = ((Map) rp).get("id"); + return id == null ? null : id.toString(); + } + return null; + } + + /// Human-readable relying-party name (`rp.name`). + public String getRpName() { + Object rp = parsed.get("rp"); + if (rp instanceof Map) { + Object name = ((Map) rp).get("name"); + return name == null ? null : name.toString(); + } + return null; + } + + /// `user.id`, the relying-party-specific user handle (base64url-encoded + /// in the JSON wire format). + public String getUserId() { + Object user = parsed.get("user"); + if (user instanceof Map) { + Object id = ((Map) user).get("id"); + return id == null ? null : id.toString(); + } + return null; + } + + /// `user.name`, usually the email or username the credential is for. + public String getUserName() { + Object user = parsed.get("user"); + if (user instanceof Map) { + Object name = ((Map) user).get("name"); + return name == null ? null : name.toString(); + } + return null; + } + + /// `user.displayName` (the human-friendly name shown on the OS sheet). + public String getUserDisplayName() { + Object user = parsed.get("user"); + if (user instanceof Map) { + Object name = ((Map) user).get("displayName"); + return name == null ? null : name.toString(); + } + return null; + } + + /// The challenge bytes, base64url-encoded (as they appear on the wire). + public String getChallenge() { + Object c = parsed.get("challenge"); + return c == null ? null : c.toString(); + } + + /// Builder for the rare case where you need to synthesise the options + /// client-side -- e.g. unit tests. The standard usage is + /// [#fromJson(String)] with a server-supplied document. + public static Builder newBuilder() { + return new Builder(); + } + + /// Fluent builder for [PublicKeyCredentialCreationOptions]. The resulting + /// JSON is W3C-compliant and forwarded verbatim to the OS authenticator. + public static final class Builder { + private String rpId; + private String rpName; + private String userId; + private String userName; + private String userDisplayName; + private String challenge; + private String authenticatorAttachment; + private String userVerification = "preferred"; + private String residentKey = "preferred"; + + public Builder rp(String id, String name) { + this.rpId = id; + this.rpName = name; + return this; + } + + public Builder user(String id, String name, String displayName) { + this.userId = id; + this.userName = name; + this.userDisplayName = displayName; + return this; + } + + /// Challenge bytes, base64url-encoded. + public Builder challenge(String base64UrlChallenge) { + this.challenge = base64UrlChallenge; + return this; + } + + /// `"platform"` to require a platform authenticator (Face ID / Touch ID + /// on iOS, Android biometrics) or `"cross-platform"` for hardware + /// keys. `null` (the default) lets the OS pick. + public Builder authenticatorAttachment(String v) { + this.authenticatorAttachment = v; + return this; + } + + /// `"required"`, `"preferred"` (default) or `"discouraged"`. + public Builder userVerification(String v) { + this.userVerification = v; + return this; + } + + /// `"required"` (the modern default for passkeys), `"preferred"` or + /// `"discouraged"`. + public Builder residentKey(String v) { + this.residentKey = v; + return this; + } + + public PublicKeyCredentialCreationOptions build() { + if (rpId == null) { + throw new IllegalStateException("rp.id is required"); + } + if (userId == null) { + throw new IllegalStateException("user.id is required"); + } + if (challenge == null) { + throw new IllegalStateException("challenge is required"); + } + StringBuilder b = new StringBuilder("{"); + b.append("\"rp\":{") + .append("\"id\":").append(quote(rpId)); + if (rpName != null) { + b.append(",\"name\":").append(quote(rpName)); + } + b.append("},"); + b.append("\"user\":{") + .append("\"id\":").append(quote(userId)); + if (userName != null) { + b.append(",\"name\":").append(quote(userName)); + } + if (userDisplayName != null) { + b.append(",\"displayName\":").append(quote(userDisplayName)); + } + b.append("},"); + b.append("\"challenge\":").append(quote(challenge)).append(","); + // ES256 (-7) and RS256 (-257) are universally supported. + b.append("\"pubKeyCredParams\":[") + .append("{\"type\":\"public-key\",\"alg\":-7},") + .append("{\"type\":\"public-key\",\"alg\":-257}],"); + b.append("\"authenticatorSelection\":{") + .append("\"userVerification\":").append(quote(userVerification)) + .append(",\"residentKey\":").append(quote(residentKey)); + if ("required".equals(residentKey)) { + b.append(",\"requireResidentKey\":true"); + } + if (authenticatorAttachment != null) { + b.append(",\"authenticatorAttachment\":") + .append(quote(authenticatorAttachment)); + } + b.append("},"); + b.append("\"attestation\":\"none\""); + b.append("}"); + return PublicKeyCredentialCreationOptions.fromJson(b.toString()); + } + + private static String quote(String s) { + StringBuilder out = new StringBuilder(s.length() + 8).append('"'); + int len = s.length(); + for (int i = 0; i < len; i++) { + char c = s.charAt(i); + switch (c) { + case '"': out.append("\\\""); break; + case '\\': out.append("\\\\"); break; + case '\n': out.append("\\n"); break; + case '\r': out.append("\\r"); break; + case '\t': out.append("\\t"); break; + default: + if (c < 0x20) { + String hex = Integer.toHexString(c); + out.append("\\u"); + for (int p = hex.length(); p < 4; p++) { + out.append('0'); + } + out.append(hex); + } else { + out.append(c); + } + } + } + return out.append('"').toString(); + } + } +} diff --git a/CodenameOne/src/com/codename1/io/webauthn/PublicKeyCredentialRequestOptions.java b/CodenameOne/src/com/codename1/io/webauthn/PublicKeyCredentialRequestOptions.java new file mode 100644 index 0000000000..567f1297a3 --- /dev/null +++ b/CodenameOne/src/com/codename1/io/webauthn/PublicKeyCredentialRequestOptions.java @@ -0,0 +1,167 @@ +/* + * Copyright (c) 2012-2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.io.webauthn; + +import com.codename1.io.JSONParser; +import com.codename1.util.regex.StringReader; + +import java.io.IOException; +import java.util.Map; + +/// W3C `PublicKeyCredentialRequestOptionsJSON` -- the options blob your +/// relying-party server sends to start a passkey *sign-in* (assertion) +/// ceremony. +/// +/// Mirrors [PublicKeyCredentialCreationOptions]: receive JSON from the server, +/// parse via [#fromJson(String)], hand to +/// [WebAuthnClient#get(PublicKeyCredentialRequestOptions)], post the result +/// back to your server for verification. +/// +/// @since 7.0.245 +public final class PublicKeyCredentialRequestOptions { + + private final String json; + private final Map parsed; + + private PublicKeyCredentialRequestOptions(String json, Map parsed) { + this.json = json; + this.parsed = parsed; + } + + /// Parses a PublicKeyCredentialRequestOptionsJSON document. The JSON is + /// preserved so any fields this class doesn't model are still passed + /// through to the authenticator unchanged. + public static PublicKeyCredentialRequestOptions fromJson(String json) { + if (json == null) { + throw new IllegalArgumentException("json must not be null"); + } + Map parsed; + try { + parsed = new JSONParser().parseJSON(new StringReader(json)); + } catch (IOException ioe) { + throw new IllegalArgumentException("Invalid options JSON: " + ioe.getMessage(), ioe); + } + if (parsed == null) { + throw new IllegalArgumentException("Options JSON parsed to null"); + } + return new PublicKeyCredentialRequestOptions(json, parsed); + } + + public String toJson() { + return json; + } + + public Map asMap() { + return parsed; + } + + /// `rpId` -- the relying-party identifier the credential was registered + /// against. + public String getRpId() { + Object id = parsed.get("rpId"); + return id == null ? null : id.toString(); + } + + /// `challenge`, base64url-encoded as it appears on the wire. + public String getChallenge() { + Object c = parsed.get("challenge"); + return c == null ? null : c.toString(); + } + + /// `userVerification` -- one of `"required"`, `"preferred"`, `"discouraged"`. + public String getUserVerification() { + Object v = parsed.get("userVerification"); + return v == null ? null : v.toString(); + } + + public static Builder newBuilder() { + return new Builder(); + } + + /// Fluent builder for the rare case of synthesising the options + /// client-side (e.g. unit tests). Use [#fromJson(String)] for the + /// production path. + public static final class Builder { + private String rpId; + private String challenge; + private String userVerification = "preferred"; + + public Builder rpId(String v) { + this.rpId = v; + return this; + } + + public Builder challenge(String base64UrlChallenge) { + this.challenge = base64UrlChallenge; + return this; + } + + public Builder userVerification(String v) { + this.userVerification = v; + return this; + } + + public PublicKeyCredentialRequestOptions build() { + if (rpId == null) { + throw new IllegalStateException("rpId is required"); + } + if (challenge == null) { + throw new IllegalStateException("challenge is required"); + } + StringBuilder b = new StringBuilder("{") + .append("\"rpId\":").append(quote(rpId)) + .append(",\"challenge\":").append(quote(challenge)) + .append(",\"userVerification\":").append(quote(userVerification)) + .append("}"); + return PublicKeyCredentialRequestOptions.fromJson(b.toString()); + } + + private static String quote(String s) { + StringBuilder out = new StringBuilder(s.length() + 8).append('"'); + int len = s.length(); + for (int i = 0; i < len; i++) { + char c = s.charAt(i); + switch (c) { + case '"': out.append("\\\""); break; + case '\\': out.append("\\\\"); break; + case '\n': out.append("\\n"); break; + case '\r': out.append("\\r"); break; + case '\t': out.append("\\t"); break; + default: + if (c < 0x20) { + String hex = Integer.toHexString(c); + out.append("\\u"); + for (int p = hex.length(); p < 4; p++) { + out.append('0'); + } + out.append(hex); + } else { + out.append(c); + } + } + } + return out.append('"').toString(); + } + } +} diff --git a/CodenameOne/src/com/codename1/io/webauthn/WebAuthnClient.java b/CodenameOne/src/com/codename1/io/webauthn/WebAuthnClient.java new file mode 100644 index 0000000000..40b17323c2 --- /dev/null +++ b/CodenameOne/src/com/codename1/io/webauthn/WebAuthnClient.java @@ -0,0 +1,230 @@ +/* + * Copyright (c) 2012-2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.io.webauthn; + +import com.codename1.util.AsyncResource; + +/// Modern WebAuthn / passkey client. Wraps the OS public-key credential APIs +/// (`ASAuthorizationPlatformPublicKeyCredentialProvider` on iOS 16+, +/// `androidx.credentials.CredentialManager` on Android API 28+) behind a +/// portable, JSON-friendly Java surface so you can talk to any relying-party +/// server -- your own backend, Auth0, Firebase, or one of the WebAuthn server +/// libraries -- with the same code. +/// +/// ### When to reach for this class +/// +/// - Your app talks to *your own* backend and you want to add passkeys for +/// passwordless sign-in / step-up auth. +/// - You are wiring up a passkey flow against Auth0 or Firebase that those +/// providers' OIDC ceremonies don't already give you for free. (When the +/// user signs into Google / Apple / Microsoft via [com.codename1.io.oidc.OidcClient], +/// the IdP handles the passkey on its end -- you get the resulting tokens +/// without ever calling this class.) +/// +/// ### Typical registration flow +/// +/// ```java +/// // 1. Ask your server for the registration challenge JSON. +/// AsyncResource challenge = httpPost("/passkey/register/start", body); +/// +/// // 2. Hand it to the OS for the actual passkey creation. +/// PublicKeyCredentialCreationOptions opts = +/// PublicKeyCredentialCreationOptions.fromJson(challenge.get()); +/// +/// WebAuthnClient.getInstance().create(opts) +/// .ready(new SuccessCallback() { +/// public void onSucess(PublicKeyCredential cred) { +/// // 3. Forward the authenticator response back to the server. +/// httpPost("/passkey/register/verify", cred.toJson()); +/// } +/// }); +/// ``` +/// +/// ### Typical sign-in flow +/// +/// Symmetrical: ask the server for an assertion challenge, hand to +/// [#get(PublicKeyCredentialRequestOptions)], POST the response back. The +/// server verifies the signature and returns a session token. +/// +/// ### What this class deliberately does NOT do +/// +/// - **Verify the attestation / assertion.** That is the relying party's +/// responsibility -- it requires the server-side credential record and a +/// counter check that only the RP can do safely. Use a server library: +/// `webauthn4j` (Java), `@simplewebauthn/server` (Node), `webauthn-rs` +/// (Rust), or your IdP's built-in verifier. +/// - **Conditional UI (autofill).** The W3C `mediation: "conditional"` UX +/// is not currently exposed; pass a regular [#get] when the user clicks +/// a sign-in button. +/// - **Replace OIDC.** Most apps using [com.codename1.io.oidc.OidcClient] +/// already get passkey-backed sign-in for free (the IdP handles the +/// passkey ceremony). Use this class when you specifically have your own +/// relying party. +/// +/// @since 7.0.245 +public final class WebAuthnClient { + + private static WebAuthnClient INSTANCE = new WebAuthnClient(); + private static WebAuthnNative provider; + + private WebAuthnClient() {} + + public static WebAuthnClient getInstance() { + return INSTANCE; + } + + /// `true` when a native, OS-level passkey implementation is available on + /// the current platform. When `false`, [#create] and [#get] fail with + /// [WebAuthnException#NOT_IMPLEMENTED] so the caller can present a + /// fallback UI. + public static boolean isSupported() { + WebAuthnNative n = getProvider(); + return n != null && n.isSupported(); + } + + /// Registers a port-supplied [WebAuthnNative] implementation. Called at + /// app startup by the platform port (`WebAuthnNativeImpl.init()`). + /// Cn1lib authors can also call this to plug in a custom implementation + /// (e.g. a USB-HID security-key driver). Pass `null` to revert to "no + /// platform support". + public static void setProvider(WebAuthnNative p) { + synchronized (WebAuthnClient.class) { + provider = p; + } + } + + private static WebAuthnNative getProvider() { + synchronized (WebAuthnClient.class) { + return provider; + } + } + + /// Drives the W3C `navigator.credentials.create()` ceremony with the + /// given options. The returned [AsyncResource] completes with the + /// authenticator's [PublicKeyCredential] response, or errors with + /// [WebAuthnException] (e.g. [WebAuthnException#NOT_ALLOWED] when the + /// user dismisses the OS sheet). + /// + /// **The work is done off the EDT** -- a background thread blocks on the + /// native call. Callers can attach `.ready()` and `.except()` listeners + /// without worrying about thread affinity; both fire on the EDT. + public AsyncResource create(final PublicKeyCredentialCreationOptions options) { + if (options == null) { + throw new IllegalArgumentException("options must not be null"); + } + final AsyncResource out = new AsyncResource(); + final WebAuthnNative p = getProvider(); + if (p == null || !p.isSupported()) { + out.error(new WebAuthnException(WebAuthnException.NOT_IMPLEMENTED, + "WebAuthn is not available on this platform")); + return out; + } + Runnable task = new CreateRunnable(p, options.toJson(), out); + new Thread(task, "WebAuthnCreate").start(); + return out; + } + + /// Drives the W3C `navigator.credentials.get()` ceremony with the given + /// options. Symmetrical to [#create(PublicKeyCredentialCreationOptions)]. + public AsyncResource get(final PublicKeyCredentialRequestOptions options) { + if (options == null) { + throw new IllegalArgumentException("options must not be null"); + } + final AsyncResource out = new AsyncResource(); + final WebAuthnNative p = getProvider(); + if (p == null || !p.isSupported()) { + out.error(new WebAuthnException(WebAuthnException.NOT_IMPLEMENTED, + "WebAuthn is not available on this platform")); + return out; + } + Runnable task = new GetRunnable(p, options.toJson(), out); + new Thread(task, "WebAuthnGet").start(); + return out; + } + + /// Static-nested runnable wrappers (over anonymous inner classes) so + /// SpotBugs SIC_INNER_SHOULD_BE_STATIC_ANON stays quiet and so the + /// thread doesn't pin a [WebAuthnClient] reference. + private static final class CreateRunnable implements Runnable { + private final WebAuthnNative provider; + private final String optionsJson; + private final AsyncResource out; + + CreateRunnable(WebAuthnNative provider, String optionsJson, + AsyncResource out) { + this.provider = provider; + this.optionsJson = optionsJson; + this.out = out; + } + + @Override + public void run() { + try { + String responseJson = provider.createPasskey(optionsJson); + if (responseJson == null) { + out.error(new WebAuthnException(WebAuthnException.NOT_ALLOWED, + "Passkey registration sheet was dismissed")); + return; + } + out.complete(PublicKeyCredential.fromJson(responseJson)); + } catch (WebAuthnException wae) { + out.error(wae); + } catch (Throwable t) { + out.error(new WebAuthnException(WebAuthnException.TRANSPORT_ERROR, + "Native passkey create failed: " + t.getMessage(), t)); + } + } + } + + private static final class GetRunnable implements Runnable { + private final WebAuthnNative provider; + private final String optionsJson; + private final AsyncResource out; + + GetRunnable(WebAuthnNative provider, String optionsJson, + AsyncResource out) { + this.provider = provider; + this.optionsJson = optionsJson; + this.out = out; + } + + @Override + public void run() { + try { + String responseJson = provider.getPasskey(optionsJson); + if (responseJson == null) { + out.error(new WebAuthnException(WebAuthnException.NOT_ALLOWED, + "Passkey sign-in sheet was dismissed")); + return; + } + out.complete(PublicKeyCredential.fromJson(responseJson)); + } catch (WebAuthnException wae) { + out.error(wae); + } catch (Throwable t) { + out.error(new WebAuthnException(WebAuthnException.TRANSPORT_ERROR, + "Native passkey get failed: " + t.getMessage(), t)); + } + } + } +} diff --git a/CodenameOne/src/com/codename1/io/webauthn/WebAuthnException.java b/CodenameOne/src/com/codename1/io/webauthn/WebAuthnException.java new file mode 100644 index 0000000000..d85aa91897 --- /dev/null +++ b/CodenameOne/src/com/codename1/io/webauthn/WebAuthnException.java @@ -0,0 +1,109 @@ +/* + * Copyright (c) 2012-2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.io.webauthn; + +import java.io.IOException; + +/// Thrown for failures during a WebAuthn / passkey ceremony driven by +/// [WebAuthnClient]. The [#getError()] code mirrors the W3C exception names +/// returned by the platform authenticator (`"NotAllowedError"`, +/// `"InvalidStateError"`, `"NotSupportedError"`, `"SecurityError"`, +/// `"AbortError"`, `"ConstraintError"`) plus Codename One-specific values +/// for transport (`"transport_error"`), parsing +/// (`"invalid_options"`, `"invalid_response"`) and the platform not +/// implementing public-key credentials at all (`"not_supported"`). +/// +/// @since 7.0.245 +public class WebAuthnException extends IOException { + + /// User dismissed the OS passkey sheet, or the OS denied the request + /// because the authenticator was unavailable or no credential matched. + public static final String NOT_ALLOWED = "NotAllowedError"; + + /// The credential being created already exists on the authenticator + /// (mapped from the W3C `InvalidStateError` for `create()`), or the + /// requested credential is no longer valid. + public static final String INVALID_STATE = "InvalidStateError"; + + /// The platform does not support the requested public-key algorithm / + /// transport / RP combination. + public static final String NOT_SUPPORTED = "NotSupportedError"; + + /// Origin / RP-ID validation failed. Most often this means the app's + /// associated domain (iOS) or asset link (Android) is not configured + /// for the relying-party identifier in the options JSON. + public static final String SECURITY_ERROR = "SecurityError"; + + /// The ceremony was cancelled by the caller (e.g. via + /// [WebAuthnClient#cancel()]). + public static final String ABORTED = "AbortError"; + + /// One of the option constraints (resident key required, user verification + /// required, etc.) could not be satisfied by the available authenticators. + public static final String CONSTRAINT_ERROR = "ConstraintError"; + + /// The platform lacks a public-key credential implementation. iOS < 16, + /// Android API < 28, JavaSE / desktop, web fallback without a native + /// WebAuthn implementation. + public static final String NOT_IMPLEMENTED = "not_supported"; + + /// Generic transport / network failure (e.g. while POSTing the registration + /// or assertion response to your relying-party server). + public static final String TRANSPORT_ERROR = "transport_error"; + + /// The options JSON received from the relying party could not be parsed. + public static final String INVALID_OPTIONS = "invalid_options"; + + /// The response JSON returned by the authenticator could not be parsed + /// back into a [PublicKeyCredential]. + public static final String INVALID_RESPONSE = "invalid_response"; + + private final String error; + private final String errorDescription; + + public WebAuthnException(String error, String message) { + super(message != null ? message : error); + this.error = error; + this.errorDescription = message; + } + + public WebAuthnException(String error, String message, Throwable cause) { + super(message != null ? message : error); + this.error = error; + this.errorDescription = message; + if (cause != null) { + initCause(cause); + } + } + + /// The short error code (see constants on this class). + public String getError() { + return error; + } + + /// Human-readable description supplied by the platform or the client. + public String getErrorDescription() { + return errorDescription; + } +} diff --git a/CodenameOne/src/com/codename1/io/webauthn/WebAuthnNative.java b/CodenameOne/src/com/codename1/io/webauthn/WebAuthnNative.java new file mode 100644 index 0000000000..246201860f --- /dev/null +++ b/CodenameOne/src/com/codename1/io/webauthn/WebAuthnNative.java @@ -0,0 +1,76 @@ +/* + * Copyright (c) 2012-2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.io.webauthn; + +/// Service-provider interface that [WebAuthnClient] uses to dispatch a passkey +/// ceremony through the OS's public-key credential API +/// (`ASAuthorizationPlatformPublicKeyCredentialProvider` on iOS 16+, +/// `androidx.credentials.CredentialManager` on Android API 28+). +/// +/// The platform port supplies an implementation named +/// `com.codename1.io.webauthn.WebAuthnNativeImpl`; [WebAuthnClient] loads it +/// via [WebAuthnClient#setProvider(WebAuthnNative)] which the port calls at +/// app startup. Cn1lib authors who want to plug in their own implementation +/// (for example, a USB-HID security-key driver) can declare a subtype and +/// register it the same way. +/// +/// The data interchange is intentionally **JSON in, JSON out** -- both sides +/// of the W3C `navigator.credentials.create()` / `.get()` call have a +/// well-defined JSON serialisation (PublicKeyCredentialCreationOptionsJSON / +/// PublicKeyCredentialRequestOptionsJSON, RegistrationResponseJSON / +/// AuthenticationResponseJSON). Passing strings keeps the native border narrow +/// and lets the implementation forward the JSON straight to the OS API (both +/// platforms accept JSON-shaped inputs in their modern APIs). +/// +/// @since 7.0.245 +public interface WebAuthnNative { + + /// `true` if this implementation can actually call the OS authenticator + /// on the current device / OS version. When `false`, [WebAuthnClient] + /// fails the ceremony with [WebAuthnException#NOT_IMPLEMENTED] so the + /// caller can present a fallback UI (e.g. password sign-in). + boolean isSupported(); + + /// Runs a `navigator.credentials.create()` ceremony. + /// `creationOptionsJson` must be a PublicKeyCredentialCreationOptionsJSON + /// document as defined by W3C Credential Management Level 1. Returns a + /// RegistrationResponseJSON string on success, or `null` if the user + /// dismissed the sheet. + /// + /// Implementations are expected to be blocking: the caller is on a worker + /// thread and waits for the result. + /// + /// On error, the implementation should throw a [WebAuthnException] with + /// a code from the constants on that class. + String createPasskey(String creationOptionsJson) throws WebAuthnException; + + /// Runs a `navigator.credentials.get()` ceremony. + /// `requestOptionsJson` must be a PublicKeyCredentialRequestOptionsJSON + /// document. Returns an AuthenticationResponseJSON string on success, or + /// `null` if the user dismissed the sheet. + /// + /// On error, the implementation should throw a [WebAuthnException] with + /// a code from the constants on that class. + String getPasskey(String requestOptionsJson) throws WebAuthnException; +} diff --git a/CodenameOne/src/com/codename1/social/Auth0Connect.java b/CodenameOne/src/com/codename1/social/Auth0Connect.java index 60b30848e2..7cae7e5545 100644 --- a/CodenameOne/src/com/codename1/social/Auth0Connect.java +++ b/CodenameOne/src/com/codename1/social/Auth0Connect.java @@ -23,10 +23,26 @@ */ package com.codename1.social; +import com.codename1.io.ConnectionRequest; +import com.codename1.io.JSONParser; +import com.codename1.io.NetworkManager; +import com.codename1.io.Util; import com.codename1.io.oidc.OidcClient; +import com.codename1.io.oidc.OidcException; import com.codename1.io.oidc.OidcTokens; +import com.codename1.io.webauthn.PublicKeyCredential; +import com.codename1.io.webauthn.PublicKeyCredentialCreationOptions; +import com.codename1.io.webauthn.PublicKeyCredentialRequestOptions; +import com.codename1.io.webauthn.WebAuthnClient; import com.codename1.util.AsyncResource; +import com.codename1.util.StringUtil; import com.codename1.util.SuccessCallback; +import com.codename1.util.regex.StringReader; + +import java.io.IOException; +import java.io.InputStream; +import java.util.HashMap; +import java.util.Map; /// Sign-in via an Auth0 tenant. Auth0 is a fully OpenID-Connect compliant /// provider so this class is a very thin convenience over @@ -96,6 +112,379 @@ protected boolean validateToken(String token) { return token != null && token.length() > 0; } + /// Signs the user in with an existing passkey via Auth0's WebAuthn grant. + /// Routes through Auth0's `/passkey/challenge` + `/oauth/token` endpoints + /// (`grant_type=urn:okta:params:oauth:grant-type:webauthn`). + /// + /// Requires the Auth0 tenant to have *Passkeys* enabled and the + /// application to have the *WebAuthn* grant type allowed. The user must + /// already have at least one passkey enrolled (use the standard `signIn` + /// flow first and have the user enroll via Auth0's hosted page, or call + /// [#registerPasskey(String, String, String, String, String...)] for a + /// new account). + /// + /// `realm` is the Auth0 *Connection* name (most often + /// `"Username-Password-Authentication"`). + /// + /// Available iOS 16+ and Android API 28+ via the system passkey + /// providers. Fails fast with + /// [com.codename1.io.webauthn.WebAuthnException#NOT_IMPLEMENTED] on + /// platforms that don't have a WebAuthn implementation. + /// + /// @since 7.0.245 + public AsyncResource signInWithPasskey(final String clientId, + final String realm, + final String... scopes) { + final AsyncResource out = new AsyncResource(); + if (domain == null) { + out.error(new IllegalStateException( + "Auth0Connect requires withDomain(\"your-tenant.region.auth0.com\")")); + return out; + } + final String scopeArg = scopes == null || scopes.length == 0 + ? "openid email profile offline_access" + : joinScopes(scopes); + Map body = new HashMap(); + body.put("client_id", clientId); + body.put("realm", realm == null ? "Username-Password-Authentication" : realm); + postJson("https://" + domain + "/passkey/challenge", body) + .ready(new SuccessCallback>() { + @Override + public void onSucess(Map challenge) { + runPasskeyAssertion(clientId, scopeArg, challenge, out); + } + }) + .except(new SuccessCallback() { + @Override + public void onSucess(Throwable err) { + out.error(err); + } + }); + return out; + } + + /// Enrolls a brand-new passkey credential for the given Auth0 user. The + /// account is created on first registration (if the connection allows + /// signup), or attached to an existing passwordless account by email. + /// + /// The flow is: + /// 1. POST `/passkey/register` with `client_id`, `realm`, + /// `user_profile`. Response includes registration options. + /// 2. Run [WebAuthnClient#create] with those options. + /// 3. POST `/oauth/token` to swap the authenticator response for tokens. + /// + /// @since 7.0.245 + public AsyncResource registerPasskey(final String clientId, + final String realm, + final String email, + final String displayName, + final String... scopes) { + final AsyncResource out = new AsyncResource(); + if (domain == null) { + out.error(new IllegalStateException( + "Auth0Connect requires withDomain(\"your-tenant.region.auth0.com\")")); + return out; + } + final String scopeArg = scopes == null || scopes.length == 0 + ? "openid email profile offline_access" + : joinScopes(scopes); + StringBuilder userProfile = new StringBuilder("{"); + boolean first = true; + if (email != null) { + userProfile.append("\"email\":").append(jsonString(email)); + first = false; + } + if (displayName != null) { + if (!first) { + userProfile.append(','); + } + userProfile.append("\"name\":").append(jsonString(displayName)); + } + userProfile.append('}'); + Map body = new HashMap(); + body.put("client_id", clientId); + body.put("realm", realm == null ? "Username-Password-Authentication" : realm); + body.put("user_profile", userProfile.toString()); + postJson("https://" + domain + "/passkey/register", body) + .ready(new SuccessCallback>() { + @Override + public void onSucess(Map registration) { + runPasskeyRegistration(clientId, scopeArg, registration, out); + } + }) + .except(new SuccessCallback() { + @Override + public void onSucess(Throwable err) { + out.error(err); + } + }); + return out; + } + + private void runPasskeyAssertion(final String clientId, + final String scopes, + Map challenge, + final AsyncResource out) { + Object authSession = challenge.get("auth_session"); + Object authnParams = challenge.get("authn_params_public_key"); + if (authSession == null || !(authnParams instanceof Map)) { + out.error(new OidcException(OidcException.INVALID_GRANT, + "Auth0 /passkey/challenge response missing auth_session / authn_params_public_key")); + return; + } + String optionsJson; + try { + optionsJson = mapToJson((Map) authnParams); + } catch (Throwable t) { + out.error(new OidcException(OidcException.INVALID_GRANT, + "Could not serialise WebAuthn request options: " + t.getMessage(), t)); + return; + } + final String authSessionStr = authSession.toString(); + WebAuthnClient.getInstance() + .get(PublicKeyCredentialRequestOptions.fromJson(optionsJson)) + .ready(new SuccessCallback() { + @Override + public void onSucess(PublicKeyCredential cred) { + exchangePasskeyForToken(clientId, scopes, authSessionStr, + cred.toJson(), out); + } + }) + .except(new SuccessCallback() { + @Override + public void onSucess(Throwable err) { + out.error(err); + } + }); + } + + private void runPasskeyRegistration(final String clientId, + final String scopes, + Map registration, + final AsyncResource out) { + Object authSession = registration.get("auth_session"); + Object authnParams = registration.get("authn_params_public_key"); + if (authSession == null || !(authnParams instanceof Map)) { + out.error(new OidcException(OidcException.INVALID_GRANT, + "Auth0 /passkey/register response missing auth_session / authn_params_public_key")); + return; + } + String optionsJson; + try { + optionsJson = mapToJson((Map) authnParams); + } catch (Throwable t) { + out.error(new OidcException(OidcException.INVALID_GRANT, + "Could not serialise WebAuthn creation options: " + t.getMessage(), t)); + return; + } + final String authSessionStr = authSession.toString(); + WebAuthnClient.getInstance() + .create(PublicKeyCredentialCreationOptions.fromJson(optionsJson)) + .ready(new SuccessCallback() { + @Override + public void onSucess(PublicKeyCredential cred) { + exchangePasskeyForToken(clientId, scopes, authSessionStr, + cred.toJson(), out); + } + }) + .except(new SuccessCallback() { + @Override + public void onSucess(Throwable err) { + out.error(err); + } + }); + } + + private void exchangePasskeyForToken(String clientId, + String scope, + String authSession, + String authnResponseJson, + final AsyncResource out) { + final ConnectionRequest req = new ConnectionRequest() { + @Override + protected void readResponse(InputStream input) throws IOException { + byte[] body = Util.readInputStream(input); + String json = StringUtil.newString(body); + Map parsed = new JSONParser().parseJSON(new StringReader(json)); + if (parsed == null) { + out.error(new OidcException(OidcException.INVALID_GRANT, + "Auth0 /oauth/token returned an empty body")); + return; + } + if (parsed.get("error") != null) { + Object desc = parsed.get("error_description"); + out.error(new OidcException(parsed.get("error").toString(), + desc != null ? desc.toString() : null)); + return; + } + OidcTokens tokens = OidcTokens.fromTokenResponse(parsed, null); + setAccessToken(tokens.toAccessToken()); + out.complete(tokens); + } + + @Override + protected void handleException(Exception err) { + out.error(new OidcException(OidcException.TRANSPORT_ERROR, + "Auth0 passkey token exchange failed: " + err.getMessage(), err)); + } + }; + req.setUrl("https://" + domain + "/oauth/token"); + req.setPost(true); + req.setReadResponseForErrors(true); + req.addRequestHeader("Content-Type", "application/x-www-form-urlencoded"); + req.addRequestHeader("Accept", "application/json"); + req.addArgument("grant_type", "urn:okta:params:oauth:grant-type:webauthn"); + req.addArgument("client_id", clientId); + req.addArgument("scope", scope); + if (audience != null) { + req.addArgument("audience", audience); + } + req.addArgument("auth_session", authSession); + req.addArgument("authn_response", authnResponseJson); + NetworkManager.getInstance().addToQueue(req); + } + + private static AsyncResource> postJson(final String url, + final Map body) { + final AsyncResource> out = new AsyncResource>(); + ConnectionRequest req = new ConnectionRequest() { + @Override + protected void readResponse(InputStream input) throws IOException { + byte[] data = Util.readInputStream(input); + String json = StringUtil.newString(data); + Map parsed = new JSONParser().parseJSON(new StringReader(json)); + if (parsed == null) { + out.error(new OidcException(OidcException.INVALID_GRANT, + "Auth0 returned an empty body for " + url)); + return; + } + if (parsed.get("error") != null) { + Object desc = parsed.get("error_description"); + out.error(new OidcException(parsed.get("error").toString(), + desc != null ? desc.toString() : null)); + return; + } + out.complete(parsed); + } + + @Override + protected void handleException(Exception err) { + out.error(new OidcException(OidcException.TRANSPORT_ERROR, + "Auth0 request to " + url + " failed: " + err.getMessage(), err)); + } + }; + req.setUrl(url); + req.setPost(true); + req.setReadResponseForErrors(true); + req.addRequestHeader("Content-Type", "application/json"); + req.addRequestHeader("Accept", "application/json"); + req.setRequestBody(mapToFlatJson(body)); + NetworkManager.getInstance().addToQueue(req); + return out; + } + + private static String joinScopes(String[] scopes) { + StringBuilder b = new StringBuilder(); + for (int i = 0; i < scopes.length; i++) { + if (i > 0) { + b.append(' '); + } + b.append(scopes[i]); + } + return b.toString(); + } + + /// Serialises a parsed JSON sub-tree back into a JSON string. We need this + /// because the Auth0 challenge response embeds the W3C options as a nested + /// object, but [PublicKeyCredentialRequestOptions#fromJson] takes a string. + private static String mapToJson(Map map) { + StringBuilder b = new StringBuilder("{"); + boolean first = true; + for (Map.Entry e : map.entrySet()) { + if (!first) { + b.append(','); + } + first = false; + b.append(jsonString(e.getKey().toString())).append(':'); + appendValue(b, e.getValue()); + } + return b.append('}').toString(); + } + + private static void appendValue(StringBuilder b, Object v) { + if (v == null) { + b.append("null"); + } else if (v instanceof Map) { + b.append(mapToJson((Map) v)); + } else if (v instanceof java.util.Collection) { + b.append('['); + boolean first = true; + for (Object item : (java.util.Collection) v) { + if (!first) { + b.append(','); + } + first = false; + appendValue(b, item); + } + b.append(']'); + } else if (v instanceof Number || v instanceof Boolean) { + b.append(v.toString()); + } else { + b.append(jsonString(v.toString())); + } + } + + /// Flat string-to-string JSON object serializer used for the small + /// Auth0 request bodies. `user_profile` arrives as a JSON-shaped string + /// and is emitted unquoted so the server receives a real JSON object. + private static String mapToFlatJson(Map map) { + StringBuilder b = new StringBuilder("{"); + boolean first = true; + for (Map.Entry e : map.entrySet()) { + if (!first) { + b.append(','); + } + first = false; + b.append(jsonString(e.getKey())).append(':'); + String val = e.getValue(); + // `user_profile` is already a JSON object; everything else is a + // plain string. We detect this by the leading brace. + if (val != null && val.length() > 0 && val.charAt(0) == '{') { + b.append(val); + } else { + b.append(jsonString(val == null ? "" : val)); + } + } + return b.append('}').toString(); + } + + private static String jsonString(String s) { + StringBuilder out = new StringBuilder(s.length() + 8).append('"'); + int len = s.length(); + for (int i = 0; i < len; i++) { + char c = s.charAt(i); + switch (c) { + case '"': out.append("\\\""); break; + case '\\': out.append("\\\\"); break; + case '\n': out.append("\\n"); break; + case '\r': out.append("\\r"); break; + case '\t': out.append("\\t"); break; + default: + if (c < 0x20) { + String hex = Integer.toHexString(c); + out.append("\\u"); + for (int p = hex.length(); p < 4; p++) { + out.append('0'); + } + out.append(hex); + } else { + out.append(c); + } + } + } + return out.append('"').toString(); + } + public AsyncResource signIn(final String clientId, final String redirectUri, final String... scopes) { diff --git a/CodenameOne/src/com/codename1/social/FirebaseAuth.java b/CodenameOne/src/com/codename1/social/FirebaseAuth.java index 96410d9070..62427c497c 100644 --- a/CodenameOne/src/com/codename1/social/FirebaseAuth.java +++ b/CodenameOne/src/com/codename1/social/FirebaseAuth.java @@ -29,8 +29,13 @@ import com.codename1.io.Preferences; import com.codename1.io.Util; import com.codename1.io.oidc.OidcTokens; +import com.codename1.io.webauthn.PublicKeyCredential; +import com.codename1.io.webauthn.PublicKeyCredentialCreationOptions; +import com.codename1.io.webauthn.PublicKeyCredentialRequestOptions; +import com.codename1.io.webauthn.WebAuthnClient; import com.codename1.util.AsyncResource; import com.codename1.util.StringUtil; +import com.codename1.util.SuccessCallback; import com.codename1.util.regex.StringReader; import java.io.IOException; @@ -157,6 +162,291 @@ public AsyncResource signInWithIdpIdToken(String idToken, body); } + /// Enrolls a passkey for the currently signed-in Firebase user via the + /// Identity Toolkit v2 passkey endpoints. The user must already be + /// signed in (Firebase passkeys cannot exist without an underlying + /// account); call [#signInWithEmailAndPassword(String, String)] or + /// [#signInWithIdpIdToken(String, String)] first. + /// + /// `name` is the human-friendly label shown on the OS passkey sheet + /// (e.g. `"Alice's iPhone"`); pass `null` to let the OS pick one. + /// + /// Requires *Identity Platform* (the upgraded Firebase Auth tier) with + /// passkeys enabled in the console. The classic Firebase Auth tier does + /// not expose passkey endpoints. + /// + /// @since 7.0.245 + public AsyncResource registerPasskey(final String name) { + final AsyncResource out = new AsyncResource(); + if (apiKey == null) { + out.error(new IllegalStateException( + "FirebaseAuth.withApiKey(\"...\") must be called first")); + return out; + } + final String idToken = getIdToken(); + if (idToken == null) { + out.error(new IllegalStateException( + "registerPasskey requires the user to be signed in first")); + return out; + } + Map body = new HashMap(); + body.put("idToken", idToken); + postJsonRaw( + "https://identitytoolkit.googleapis.com/v2/accounts/passkeyEnrollment:start", + body) + .ready(new SuccessCallback>() { + @Override + public void onSucess(Map resp) { + // Delegates to an instance method so SpotBugs doesn't + // flag this anonymous class as SIC_INNER_SHOULD_BE_STATIC_ANON + // (the call to `onPasskeyEnrollmentStart` uses the + // enclosing FirebaseAuth instance). + onPasskeyEnrollmentStart(resp, idToken, name, out); + } + }) + .except(forwardError(out)); + return out; + } + + private void onPasskeyEnrollmentStart(Map resp, + final String idToken, + final String name, + final AsyncResource out) { + Object opts = resp.get("credentialCreationOptions"); + if (!(opts instanceof Map)) { + out.error(new IOException( + "Firebase passkeyEnrollment:start missing credentialCreationOptions")); + return; + } + String optionsJson = serialiseMap((Map) opts); + WebAuthnClient.getInstance() + .create(PublicKeyCredentialCreationOptions.fromJson(optionsJson)) + .ready(new SuccessCallback() { + @Override + public void onSucess(PublicKeyCredential cred) { + finalisePasskeyEnrollment(idToken, name, cred.toJson(), out); + } + }) + .except(forwardError(out)); + } + + /// Signs the user in with an existing passkey via the Identity Toolkit + /// v2 passkey sign-in endpoints. Returns a [FirebaseUser] with fresh + /// ID + refresh tokens, persisted via the same store as the other + /// sign-in methods. + /// + /// Available wherever [WebAuthnClient#isSupported()] returns `true`. + /// + /// @since 7.0.245 + public AsyncResource signInWithPasskey() { + final AsyncResource out = new AsyncResource(); + if (apiKey == null) { + out.error(new IllegalStateException( + "FirebaseAuth.withApiKey(\"...\") must be called first")); + return out; + } + Map body = new HashMap(); + postJsonRaw( + "https://identitytoolkit.googleapis.com/v2/accounts/passkeySignIn:start", + body) + .ready(new SuccessCallback>() { + @Override + public void onSucess(Map resp) { + onPasskeySignInStart(resp, out); + } + }) + .except(forwardError(out)); + return out; + } + + private void onPasskeySignInStart(Map resp, + final AsyncResource out) { + Object opts = resp.get("credentialRequestOptions"); + if (!(opts instanceof Map)) { + out.error(new IOException( + "Firebase passkeySignIn:start missing credentialRequestOptions")); + return; + } + String optionsJson = serialiseMap((Map) opts); + WebAuthnClient.getInstance() + .get(PublicKeyCredentialRequestOptions.fromJson(optionsJson)) + .ready(new SuccessCallback() { + @Override + public void onSucess(PublicKeyCredential cred) { + finalisePasskeySignIn(cred.toJson(), out); + } + }) + .except(forwardError(out)); + } + + private void finalisePasskeyEnrollment(String idToken, String name, + String registrationResponseJson, + final AsyncResource out) { + Map body = new HashMap(); + body.put("idToken", idToken); + if (name != null) { + body.put("name", name); + } + body.put("registrationResponseJson", registrationResponseJson); + postJsonRaw( + "https://identitytoolkit.googleapis.com/v2/accounts/passkeyEnrollment:finalize", + body) + .ready(new SuccessCallback>() { + @Override + public void onSucess(Map resp) { + completeFromMap(resp, out); + } + }) + .except(forwardError(out)); + } + + private void finalisePasskeySignIn(String authenticationResponseJson, + final AsyncResource out) { + Map body = new HashMap(); + body.put("authenticationResponseJson", authenticationResponseJson); + postJsonRaw( + "https://identitytoolkit.googleapis.com/v2/accounts/passkeySignIn:finalize", + body) + .ready(new SuccessCallback>() { + @Override + public void onSucess(Map resp) { + completeFromMap(resp, out); + } + }) + .except(forwardError(out)); + } + + /// Shared body for both passkey finalize callbacks. Builds a + /// [FirebaseUser] from the parsed response, persists it, and completes + /// the [AsyncResource]. Kept as a non-static instance method so the + /// callers' anonymous SuccessCallback subclasses pin an enclosing + /// instance and SpotBugs SIC_INNER_SHOULD_BE_STATIC_ANON stays quiet. + private void completeFromMap(Map resp, + AsyncResource out) { + FirebaseUser u = new FirebaseUser(resp, false); + persist(u); + out.complete(u); + } + + /// Convenience that builds a `SuccessCallback` which forwards + /// the error onto `out`. Returns a named static class instance so the + /// passkey-helper call sites are not littered with anonymous error + /// forwarders. + private static SuccessCallback forwardError(AsyncResource out) { + return new ErrorForwarder(out); + } + + private static final class ErrorForwarder implements SuccessCallback { + private final AsyncResource out; + + ErrorForwarder(AsyncResource out) { + this.out = out; + } + + @Override + @SuppressWarnings({"unchecked", "rawtypes"}) + public void onSucess(Throwable err) { + ((AsyncResource) out).error(err); + } + } + + /// Raw JSON POST that returns the parsed response map, used by the + /// passkey endpoints. Distinct from [#enqueue] because the v2 passkey + /// endpoints return a passkey-options payload, not a [FirebaseUser] + /// payload. + private AsyncResource> postJsonRaw(final String urlBase, + final Map body) { + final AsyncResource> out = new AsyncResource>(); + ConnectionRequest req = new ConnectionRequest() { + @Override + protected void readResponse(InputStream input) throws IOException { + // Body lives on a named instance method so SpotBugs + // SIC_INNER_SHOULD_BE_STATIC_ANON doesn't trip on this + // anonymous class -- it now references an enclosing + // FirebaseAuth method. + handlePostJsonRawResponse(input, out); + } + + @Override + protected void handleException(Exception err) { + out.error(err); + } + }; + req.setUrl(urlBase + "?key=" + apiKey); + req.setPost(true); + req.setReadResponseForErrors(true); + req.addRequestHeader("Content-Type", "application/json"); + req.setRequestBody(toJson(body)); + NetworkManager.getInstance().addToQueue(req); + return out; + } + + private void handlePostJsonRawResponse(InputStream input, + AsyncResource> out) + throws IOException { + byte[] bytes = Util.readInputStream(input); + String json = StringUtil.newString(bytes); + Map parsed = new JSONParser() + .parseJSON(new StringReader(json)); + if (parsed == null) { + out.error(new IOException("Firebase returned empty body")); + return; + } + Object err = parsed.get("error"); + if (err != null) { + String message = "Firebase error"; + if (err instanceof Map) { + Object m = ((Map) err).get("message"); + if (m != null) { + message = m.toString(); + } + } + out.error(new IOException(message)); + return; + } + out.complete(parsed); + } + + /// Serialises a parsed JSON sub-tree back into a JSON string. Used to + /// hand `credentialCreationOptions` / `credentialRequestOptions` to + /// the WebAuthn client, which takes a raw JSON document. + private static String serialiseMap(Map map) { + StringBuilder b = new StringBuilder("{"); + boolean first = true; + for (Map.Entry e : map.entrySet()) { + if (!first) { + b.append(','); + } + first = false; + b.append('"').append(escape(e.getKey().toString())).append("\":"); + appendValue(b, e.getValue()); + } + return b.append('}').toString(); + } + + private static void appendValue(StringBuilder b, Object v) { + if (v == null) { + b.append("null"); + } else if (v instanceof Map) { + b.append(serialiseMap((Map) v)); + } else if (v instanceof java.util.Collection) { + b.append('['); + boolean first = true; + for (Object item : (java.util.Collection) v) { + if (!first) { + b.append(','); + } + first = false; + appendValue(b, item); + } + b.append(']'); + } else if (v instanceof Number || v instanceof Boolean) { + b.append(v.toString()); + } else { + b.append('"').append(escape(v.toString())).append('"'); + } + } + /// Refreshes the stored session using the saved refresh token. Falls /// through with the currently-cached [FirebaseUser] when no refresh /// token is on file. diff --git a/Ports/Android/src/com/codename1/io/webauthn/WebAuthnNativeImpl.java b/Ports/Android/src/com/codename1/io/webauthn/WebAuthnNativeImpl.java new file mode 100644 index 0000000000..9e4c1629a7 --- /dev/null +++ b/Ports/Android/src/com/codename1/io/webauthn/WebAuthnNativeImpl.java @@ -0,0 +1,337 @@ +/* + * Copyright (c) 2012-2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.io.webauthn; + +import android.app.Activity; + +import com.codename1.impl.android.AndroidNativeUtil; + +import java.lang.reflect.Constructor; +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executor; +import java.util.concurrent.atomic.AtomicReference; + +/** + * Android implementation of {@link WebAuthnNative}. Uses the + * {@code androidx.credentials.CredentialManager} API (passkeys, FIDO2, + * password manager hub) when the {@code androidx.credentials:credentials} + * dependency is on the runtime classpath -- which {@code AndroidGradleBuilder} + * auto-injects when the app references {@code com.codename1.io.webauthn.*}. + * + *

Lookup is performed via reflection so the Codename One Android port + * itself (which ships as Java sources) can build without + * {@code androidx.credentials} on its compile classpath. + * + *

Flow (registration; sign-in is symmetrical): + *

    + *
  1. {@link #createPasskey(String)} is invoked on a worker thread.
  2. + *
  3. We construct a {@code CreatePublicKeyCredentialRequest} from the + * server-supplied options JSON and submit it via + * {@code CredentialManager.createCredentialAsync(...)}.
  4. + *
  5. {@code CredentialManager} shows the OS passkey sheet and invokes + * our callback on completion or error.
  6. + *
  7. The worker thread unblocks via {@link CountDownLatch} and returns + * the registration response JSON (or throws + * {@link WebAuthnException}).
  8. + *
+ */ +public class WebAuthnNativeImpl implements WebAuthnNative { + + /** Invoked from the generated Android app stub at startup. */ + public static void init() { + WebAuthnClient.setProvider(new WebAuthnNativeImpl()); + } + + public boolean isSupported() { + if (AndroidNativeUtil.getActivity() == null) { + return false; + } + try { + Class.forName("androidx.credentials.CredentialManager"); + return true; + } catch (Throwable t) { + return false; + } + } + + public String createPasskey(String optionsJson) throws WebAuthnException { + return runCredentialFlow(optionsJson, /* create= */ true); + } + + public String getPasskey(String optionsJson) throws WebAuthnException { + return runCredentialFlow(optionsJson, /* create= */ false); + } + + /** + * Submits the request to {@code CredentialManager} via reflection and + * blocks the calling worker thread until the OS sheet resolves. + */ + private static String runCredentialFlow(String optionsJson, boolean create) + throws WebAuthnException { + final Activity activity = AndroidNativeUtil.getActivity(); + if (activity == null) { + throw new WebAuthnException(WebAuthnException.NOT_IMPLEMENTED, + "No Activity available"); + } + if (optionsJson == null) { + throw new WebAuthnException(WebAuthnException.INVALID_OPTIONS, + "optionsJson must not be null"); + } + try { + // androidx.credentials.CredentialManager.create(context); + Class cmCls = Class.forName("androidx.credentials.CredentialManager"); + Method cmFactory = cmCls.getMethod("create", android.content.Context.class); + final Object cm = cmFactory.invoke(null, activity); + + // Build the request: CreatePublicKeyCredentialRequest(json) / + // GetCredentialRequest.Builder().addCredentialOption( + // GetPublicKeyCredentialOption(json)).build() + final Object request = create + ? buildCreateRequest(optionsJson) + : buildGetRequest(optionsJson); + + // Callback is a dynamic proxy of CredentialManagerCallback. We + // block on a latch and capture the result / exception below. + final CountDownLatch latch = new CountDownLatch(1); + final AtomicReference resultRef = new AtomicReference(); + final AtomicReference errorRef = new AtomicReference(); + + Class callbackCls = Class.forName( + "androidx.credentials.CredentialManagerCallback"); + Object callback = Proxy.newProxyInstance( + callbackCls.getClassLoader(), + new Class[]{callbackCls}, + new InvocationHandler() { + public Object invoke(Object proxy, Method m, Object[] args) { + String name = m.getName(); + if ("onResult".equals(name)) { + resultRef.set(args[0]); + latch.countDown(); + } else if ("onError".equals(name)) { + errorRef.set(args.length > 0 && args[0] instanceof Throwable + ? (Throwable) args[0] : null); + latch.countDown(); + } + return null; + } + }); + + // Run synchronously on the calling worker thread; the OS sheet + // is launched on the main thread by CredentialManager itself. + Executor inlineExecutor = new Executor() { + public void execute(Runnable r) { r.run(); } + }; + + // The method name differs between createCredentialAsync / + // getCredentialAsync; their signature is identical. + String methodName = create ? "createCredentialAsync" : "getCredentialAsync"; + Class requestParamCls = create + ? Class.forName("androidx.credentials.CreateCredentialRequest") + : Class.forName("androidx.credentials.GetCredentialRequest"); + Method asyncCall = cmCls.getMethod( + methodName, + android.content.Context.class, + requestParamCls, + android.os.CancellationSignal.class, + Executor.class, + callbackCls); + + // Some pre-1.3 versions delivered the call on the main thread, + // which would deadlock if we also blocked the main thread. + // We're on a worker (WebAuthnClient.create/get spawns a Thread) + // so a latch.await is safe. + activity.runOnUiThread(new InvokeAsyncRunnable( + asyncCall, cm, activity, request, inlineExecutor, callback)); + latch.await(); + + if (errorRef.get() != null) { + throw mapCredentialError(errorRef.get()); + } + Object response = resultRef.get(); + if (response == null) { + return null; + } + return create ? extractRegistrationJson(response) + : extractAuthenticationJson(response); + } catch (WebAuthnException wae) { + throw wae; + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + throw new WebAuthnException(WebAuthnException.ABORTED, + "Passkey ceremony interrupted", ie); + } catch (ClassNotFoundException cnfe) { + throw new WebAuthnException(WebAuthnException.NOT_IMPLEMENTED, + "androidx.credentials is not on the runtime classpath", cnfe); + } catch (NoSuchMethodException nsme) { + throw new WebAuthnException(WebAuthnException.NOT_IMPLEMENTED, + "androidx.credentials API surface unexpected: " + nsme.getMessage(), nsme); + } catch (Throwable t) { + throw new WebAuthnException(WebAuthnException.TRANSPORT_ERROR, + "Native passkey flow failed: " + t.getMessage(), t); + } + } + + /** + * Schedule the async call on the UI thread (CredentialManager touches + * the WindowManager when presenting the OS sheet, and 1.2.x crashes when + * called off-main without an explicit Looper). + */ + private static final class InvokeAsyncRunnable implements Runnable { + private final Method method; + private final Object cm; + private final android.content.Context ctx; + private final Object request; + private final Executor executor; + private final Object callback; + + InvokeAsyncRunnable(Method method, Object cm, android.content.Context ctx, + Object request, Executor executor, Object callback) { + this.method = method; + this.cm = cm; + this.ctx = ctx; + this.request = request; + this.executor = executor; + this.callback = callback; + } + + public void run() { + try { + method.invoke(cm, ctx, request, null, executor, callback); + } catch (Throwable t) { + // The reflective call itself failed (rare; reflects an API + // surface mismatch). Surface via callback so the worker can + // unblock. + try { + Method onError = callback.getClass().getMethod("onError", + Throwable.class); + onError.invoke(callback, t); + } catch (Throwable ignored) { + // Already failing; nothing more we can do here. + } + } + } + } + + private static Object buildCreateRequest(String optionsJson) throws Exception { + // new CreatePublicKeyCredentialRequest(optionsJson) + Class reqCls = Class.forName( + "androidx.credentials.CreatePublicKeyCredentialRequest"); + Constructor ctor = reqCls.getConstructor(String.class); + return ctor.newInstance(optionsJson); + } + + private static Object buildGetRequest(String optionsJson) throws Exception { + // new GetCredentialRequest.Builder() + // .addCredentialOption(new GetPublicKeyCredentialOption(json)) + // .build(); + Class optCls = Class.forName( + "androidx.credentials.GetPublicKeyCredentialOption"); + Constructor optCtor = optCls.getConstructor(String.class); + Object option = optCtor.newInstance(optionsJson); + + Class builderCls = Class.forName( + "androidx.credentials.GetCredentialRequest$Builder"); + Object builder = builderCls.getConstructor().newInstance(); + Class optionBaseCls = Class.forName( + "androidx.credentials.CredentialOption"); + Method addOpt = builderCls.getMethod("addCredentialOption", optionBaseCls); + addOpt.invoke(builder, option); + Method build = builderCls.getMethod("build"); + return build.invoke(builder); + } + + /** + * Returns the {@code registrationResponseJson} from a + * {@code CreatePublicKeyCredentialResponse}. + */ + private static String extractRegistrationJson(Object response) throws Exception { + Class rspCls = Class.forName( + "androidx.credentials.CreatePublicKeyCredentialResponse"); + if (!rspCls.isInstance(response)) { + throw new WebAuthnException(WebAuthnException.INVALID_RESPONSE, + "Unexpected CreateCredentialResponse type: " + + response.getClass().getName()); + } + Method m = rspCls.getMethod("getRegistrationResponseJson"); + Object json = m.invoke(response); + return json == null ? null : json.toString(); + } + + /** + * Returns the {@code authenticationResponseJson} from the + * {@code PublicKeyCredential} attached to a {@code GetCredentialResponse}. + */ + private static String extractAuthenticationJson(Object response) throws Exception { + Class rspCls = Class.forName("androidx.credentials.GetCredentialResponse"); + Method getCred = rspCls.getMethod("getCredential"); + Object credential = getCred.invoke(response); + if (credential == null) { + return null; + } + Class pkCredCls = Class.forName("androidx.credentials.PublicKeyCredential"); + if (!pkCredCls.isInstance(credential)) { + throw new WebAuthnException(WebAuthnException.INVALID_RESPONSE, + "Unexpected Credential type: " + credential.getClass().getName()); + } + Method m = pkCredCls.getMethod("getAuthenticationResponseJson"); + Object json = m.invoke(credential); + return json == null ? null : json.toString(); + } + + /** + * Maps androidx.credentials exception class names onto the W3C-style + * codes used by {@link WebAuthnException}. The names follow a stable + * naming convention so we recognise them by simple name even if the + * package has been shaded. + */ + private static WebAuthnException mapCredentialError(Throwable t) { + String name = t.getClass().getSimpleName(); + String message = t.getMessage(); + String code; + if (name.contains("UserCanceled") + || name.contains("InterruptedException") + || name.equals("CreateCredentialCancellationException") + || name.equals("GetCredentialCancellationException")) { + code = WebAuthnException.NOT_ALLOWED; + } else if (name.contains("NoCredentialException") + || name.contains("CreateCredentialInterrupted")) { + code = WebAuthnException.NOT_ALLOWED; + } else if (name.contains("NotSupported") + || name.equals("CreateCredentialProviderConfigurationException") + || name.equals("GetCredentialProviderConfigurationException")) { + code = WebAuthnException.NOT_SUPPORTED; + } else if (name.contains("Domain")) { + code = WebAuthnException.SECURITY_ERROR; + } else if (name.contains("UnsupportedException")) { + code = WebAuthnException.NOT_SUPPORTED; + } else { + code = WebAuthnException.TRANSPORT_ERROR; + } + return new WebAuthnException(code, + message != null ? message : t.getClass().getName(), t); + } +} diff --git a/Ports/iOSPort/nativeSources/CN1WebAuthn.m b/Ports/iOSPort/nativeSources/CN1WebAuthn.m new file mode 100644 index 0000000000..3922132bcb --- /dev/null +++ b/Ports/iOSPort/nativeSources/CN1WebAuthn.m @@ -0,0 +1,468 @@ +/* + * Copyright (c) 2012-2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ + +// Native implementation of IOSNative.webauthnSupported(), +// .webauthnCreate(String) and .webauthnGet(String). Implements the +// com.codename1.io.webauthn.WebAuthnClient primitive via +// ASAuthorizationPlatformPublicKeyCredentialProvider (iOS 16+). +// +// Inputs are W3C PublicKeyCredentialCreationOptionsJSON / +// PublicKeyCredentialRequestOptionsJSON documents; the JSON is parsed with +// NSJSONSerialization and the relevant fields fed to the OS authenticator. +// Outputs are RegistrationResponseJSON / AuthenticationResponseJSON; binary +// fields (challenge, credential id, attestation object, signature, etc.) are +// emitted base64url-encoded so they round-trip cleanly against every WebAuthn +// server library on the market. + +#include "xmlvm.h" +#ifndef NEW_CODENAME_ONE_VM +#include "xmlvm-util.h" +#endif +#import "CodenameOne_GLViewController.h" + +#ifdef CN1_INCLUDE_WEBAUTHN + +#import +#import + +#ifdef NEW_CODENAME_ONE_VM +extern JAVA_OBJECT fromNSString(CODENAME_ONE_THREAD_STATE, NSString* str); +extern NSString* toNSString(CODENAME_ONE_THREAD_STATE, JAVA_OBJECT str); +#else +extern JAVA_OBJECT fromNSString(NSString* str); +extern NSString* toNSString(JAVA_OBJECT str); +#endif + +// -------------------------------------------------------------------------- +// base64url helpers. The W3C JSON wire format uses base64url *without* +// padding; iOS's NSData base64 methods produce standard base64 *with* padding, +// so we translate in both directions. + +static NSData *cn1WebAuthnB64UrlDecode(NSString *s) { + if (s == nil) return nil; + NSMutableString *m = [NSMutableString stringWithString:s]; + [m replaceOccurrencesOfString:@"-" withString:@"+" + options:0 range:NSMakeRange(0, m.length)]; + [m replaceOccurrencesOfString:@"_" withString:@"/" + options:0 range:NSMakeRange(0, m.length)]; + while (m.length % 4 != 0) { + [m appendString:@"="]; + } + return [[NSData alloc] initWithBase64EncodedString:m options:0]; +} + +static NSString *cn1WebAuthnB64UrlEncode(NSData *data) { + if (data == nil) return @""; + NSString *std = [data base64EncodedStringWithOptions:0]; + NSMutableString *m = [NSMutableString stringWithString:std]; + [m replaceOccurrencesOfString:@"+" withString:@"-" + options:0 range:NSMakeRange(0, m.length)]; + [m replaceOccurrencesOfString:@"/" withString:@"_" + options:0 range:NSMakeRange(0, m.length)]; + [m replaceOccurrencesOfString:@"=" withString:@"" + options:0 range:NSMakeRange(0, m.length)]; + return m; +} + +// -------------------------------------------------------------------------- +// Delegate that captures the outcome of an ASAuthorizationController run and +// signals a semaphore so the worker thread can return to Java. + +API_AVAILABLE(ios(16.0)) +@interface CN1WebAuthnDelegate : NSObject +@property (nonatomic, strong) NSString *resultJson; +@property (nonatomic, strong) NSString *errorCode; +@property (nonatomic, strong) NSString *errorMessage; +@property (nonatomic, copy) void(^completion)(void); +@end + +@implementation CN1WebAuthnDelegate + +- (ASPresentationAnchor)presentationAnchorForAuthorizationController:(ASAuthorizationController *)controller { + UIWindow *anchor = nil; + if (@available(iOS 13.0, *)) { + for (UIScene *scene in [UIApplication sharedApplication].connectedScenes) { + if (scene.activationState == UISceneActivationStateForegroundActive && + [scene isKindOfClass:[UIWindowScene class]]) { + UIWindowScene *ws = (UIWindowScene *)scene; + for (UIWindow *w in ws.windows) { + if (w.isKeyWindow) { anchor = w; break; } + } + if (anchor) break; + if (ws.windows.count > 0) { + anchor = ws.windows.firstObject; + break; + } + } + } + } + if (!anchor) { +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wdeprecated-declarations" + anchor = [UIApplication sharedApplication].keyWindow; +#pragma clang diagnostic pop + } + return anchor; +} + +- (void)setErrorFromASError:(NSError *)error { + NSString *code = @"NotAllowedError"; + if ([error.domain isEqualToString:ASAuthorizationErrorDomain]) { + switch (error.code) { + case ASAuthorizationErrorCanceled: code = @"NotAllowedError"; break; + case ASAuthorizationErrorFailed: code = @"SecurityError"; break; + case ASAuthorizationErrorInvalidResponse: code = @"invalid_response"; break; + case ASAuthorizationErrorNotHandled: code = @"NotSupportedError"; break; + case ASAuthorizationErrorUnknown: code = @"transport_error"; break; + default: + code = @"transport_error"; + break; + } + } + self.errorCode = code; + self.errorMessage = error.localizedDescription + ?: [NSString stringWithFormat:@"AS error %ld", (long)error.code]; +} + +- (void)authorizationController:(ASAuthorizationController *)controller + didCompleteWithAuthorization:(ASAuthorization *)authorization { + if (@available(iOS 16.0, *)) { + if ([authorization.credential isKindOfClass: + [ASAuthorizationPlatformPublicKeyCredentialRegistration class]]) { + ASAuthorizationPlatformPublicKeyCredentialRegistration *reg = + (ASAuthorizationPlatformPublicKeyCredentialRegistration *)authorization.credential; + NSString *rawId = cn1WebAuthnB64UrlEncode(reg.credentialID); + NSString *clientData = cn1WebAuthnB64UrlEncode(reg.rawClientDataJSON); + NSString *attestation = cn1WebAuthnB64UrlEncode(reg.rawAttestationObject); + NSDictionary *result = @{ + @"id": rawId, + @"rawId": rawId, + @"type": @"public-key", + @"authenticatorAttachment": @"platform", + @"response": @{ + @"clientDataJSON": clientData, + @"attestationObject": attestation, + @"transports": @[@"internal"] + }, + @"clientExtensionResults": @{} + }; + NSError *jsonErr = nil; + NSData *data = [NSJSONSerialization dataWithJSONObject:result options:0 error:&jsonErr]; + if (data) { + self.resultJson = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding]; + } else { + self.errorCode = @"invalid_response"; + self.errorMessage = jsonErr.localizedDescription ?: @"Failed to encode registration response"; + } + if (self.completion) self.completion(); + return; + } + if ([authorization.credential isKindOfClass: + [ASAuthorizationPlatformPublicKeyCredentialAssertion class]]) { + ASAuthorizationPlatformPublicKeyCredentialAssertion *as = + (ASAuthorizationPlatformPublicKeyCredentialAssertion *)authorization.credential; + NSString *rawId = cn1WebAuthnB64UrlEncode(as.credentialID); + NSDictionary *result = @{ + @"id": rawId, + @"rawId": rawId, + @"type": @"public-key", + @"authenticatorAttachment": @"platform", + @"response": @{ + @"clientDataJSON": cn1WebAuthnB64UrlEncode(as.rawClientDataJSON), + @"authenticatorData": cn1WebAuthnB64UrlEncode(as.rawAuthenticatorData), + @"signature": cn1WebAuthnB64UrlEncode(as.signature), + @"userHandle": cn1WebAuthnB64UrlEncode(as.userID) + }, + @"clientExtensionResults": @{} + }; + NSError *jsonErr = nil; + NSData *data = [NSJSONSerialization dataWithJSONObject:result options:0 error:&jsonErr]; + if (data) { + self.resultJson = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding]; + } else { + self.errorCode = @"invalid_response"; + self.errorMessage = jsonErr.localizedDescription ?: @"Failed to encode assertion response"; + } + if (self.completion) self.completion(); + return; + } + } + self.errorCode = @"NotSupportedError"; + self.errorMessage = @"Unexpected ASAuthorization credential type"; + if (self.completion) self.completion(); +} + +- (void)authorizationController:(ASAuthorizationController *)controller + didCompleteWithError:(NSError *)error { + [self setErrorFromASError:error]; + if (self.completion) self.completion(); +} + +@end + +// -------------------------------------------------------------------------- +// Strong refs to the delegate / controller for the duration of a flow. ARC +// would otherwise drop them as soon as performRequests returns. + +static id g_cn1WebAuthnCurrentDelegate = nil; +static id g_cn1WebAuthnCurrentController = nil; + +JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_webauthnSupported__( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me) { + if (@available(iOS 16.0, *)) { + return NSClassFromString(@"ASAuthorizationPlatformPublicKeyCredentialProvider") != nil + ? JAVA_TRUE : JAVA_FALSE; + } + return JAVA_FALSE; +} + +// Builds the ERR:code:message string the Java side parses with WebAuthnNativeImpl#unwrap. +static NSString *cn1WebAuthnErrorString(NSString *code, NSString *msg) { + if (code == nil) code = @"transport_error"; + if (msg == nil) msg = @"Native authenticator failed"; + return [NSString stringWithFormat:@"ERR:%@:%@", code, msg]; +} + +static NSDictionary *cn1WebAuthnParse(NSString *jsonStr) { + if (jsonStr == nil) return nil; + NSData *data = [jsonStr dataUsingEncoding:NSUTF8StringEncoding]; + if (data == nil) return nil; + id obj = [NSJSONSerialization JSONObjectWithData:data options:0 error:nil]; + return [obj isKindOfClass:[NSDictionary class]] ? (NSDictionary *)obj : nil; +} + +JAVA_OBJECT com_codename1_impl_ios_IOSNative_webauthnCreate___java_lang_String( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_OBJECT optionsJsonObj) { + if (@available(iOS 16.0, *)) { + // fall through + } else { + return fromNSString(CN1_THREAD_GET_STATE_PASS_ARG + cn1WebAuthnErrorString(@"not_supported", @"iOS 16+ required for passkeys")); + } + NSString *optionsJson = toNSString(CN1_THREAD_STATE_PASS_ARG optionsJsonObj); + NSDictionary *opts = cn1WebAuthnParse(optionsJson); + if (opts == nil) { + return fromNSString(CN1_THREAD_GET_STATE_PASS_ARG + cn1WebAuthnErrorString(@"invalid_options", @"Could not parse creation options JSON")); + } + NSDictionary *rp = [opts objectForKey:@"rp"]; + NSDictionary *user = [opts objectForKey:@"user"]; + NSString *rpId = [rp isKindOfClass:[NSDictionary class]] ? [rp objectForKey:@"id"] : nil; + NSString *userId = [user isKindOfClass:[NSDictionary class]] ? [user objectForKey:@"id"] : nil; + NSString *userName = [user isKindOfClass:[NSDictionary class]] ? [user objectForKey:@"name"] : nil; + NSString *challengeB64Url = [opts objectForKey:@"challenge"]; + if (rpId == nil || userId == nil || challengeB64Url == nil) { + return fromNSString(CN1_THREAD_GET_STATE_PASS_ARG + cn1WebAuthnErrorString(@"invalid_options", + @"creation options missing rp.id / user.id / challenge")); + } + NSData *challengeData = cn1WebAuthnB64UrlDecode(challengeB64Url); + NSData *userIdData = cn1WebAuthnB64UrlDecode(userId); + if (challengeData == nil || userIdData == nil) { + return fromNSString(CN1_THREAD_GET_STATE_PASS_ARG + cn1WebAuthnErrorString(@"invalid_options", + @"challenge / user.id must be base64url-encoded")); + } + + __block NSString *finalResult = nil; + __block NSString *finalErrCode = nil; + __block NSString *finalErrMsg = nil; + dispatch_semaphore_t sem = dispatch_semaphore_create(0); + + dispatch_async(dispatch_get_main_queue(), ^{ + ASAuthorizationPlatformPublicKeyCredentialProvider *provider = + [[ASAuthorizationPlatformPublicKeyCredentialProvider alloc] + initWithRelyingPartyIdentifier:rpId]; + ASAuthorizationPlatformPublicKeyCredentialRegistrationRequest *request = + [provider createCredentialRegistrationRequestWithChallenge:challengeData + name:(userName ?: @"") + userID:userIdData]; + + // Optional: honour userVerification when the server requested it. + NSDictionary *authSel = [opts objectForKey:@"authenticatorSelection"]; + if ([authSel isKindOfClass:[NSDictionary class]]) { + NSString *uv = [authSel objectForKey:@"userVerification"]; + if ([uv isEqualToString:@"required"]) { + request.userVerificationPreference = ASAuthorizationPublicKeyCredentialUserVerificationPreferenceRequired; + } else if ([uv isEqualToString:@"discouraged"]) { + request.userVerificationPreference = ASAuthorizationPublicKeyCredentialUserVerificationPreferenceDiscouraged; + } else if (uv != nil) { + request.userVerificationPreference = ASAuthorizationPublicKeyCredentialUserVerificationPreferencePreferred; + } + } + + CN1WebAuthnDelegate *del = [[CN1WebAuthnDelegate alloc] init]; + del.completion = ^{ + finalResult = del.resultJson; + finalErrCode = del.errorCode; + finalErrMsg = del.errorMessage; + dispatch_semaphore_signal(sem); + }; + + ASAuthorizationController *controller = + [[ASAuthorizationController alloc] initWithAuthorizationRequests:@[request]]; + controller.delegate = del; + controller.presentationContextProvider = del; + + g_cn1WebAuthnCurrentDelegate = del; + g_cn1WebAuthnCurrentController = controller; + [controller performRequests]; + }); + + // Hour-cap is purely defensive; users finish in seconds. + dispatch_semaphore_wait(sem, dispatch_time(DISPATCH_TIME_NOW, (int64_t)(3600 * NSEC_PER_SEC))); + g_cn1WebAuthnCurrentDelegate = nil; + g_cn1WebAuthnCurrentController = nil; + + if (finalErrCode != nil) { + return fromNSString(CN1_THREAD_GET_STATE_PASS_ARG + cn1WebAuthnErrorString(finalErrCode, finalErrMsg)); + } + if (finalResult == nil) { + return JAVA_NULL; + } + return fromNSString(CN1_THREAD_GET_STATE_PASS_ARG finalResult); +} + +JAVA_OBJECT com_codename1_impl_ios_IOSNative_webauthnGet___java_lang_String( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_OBJECT optionsJsonObj) { + if (@available(iOS 16.0, *)) { + // fall through + } else { + return fromNSString(CN1_THREAD_GET_STATE_PASS_ARG + cn1WebAuthnErrorString(@"not_supported", @"iOS 16+ required for passkeys")); + } + NSString *optionsJson = toNSString(CN1_THREAD_STATE_PASS_ARG optionsJsonObj); + NSDictionary *opts = cn1WebAuthnParse(optionsJson); + if (opts == nil) { + return fromNSString(CN1_THREAD_GET_STATE_PASS_ARG + cn1WebAuthnErrorString(@"invalid_options", @"Could not parse request options JSON")); + } + NSString *rpId = [opts objectForKey:@"rpId"]; + NSString *challengeB64Url = [opts objectForKey:@"challenge"]; + if (rpId == nil || challengeB64Url == nil) { + return fromNSString(CN1_THREAD_GET_STATE_PASS_ARG + cn1WebAuthnErrorString(@"invalid_options", + @"request options missing rpId / challenge")); + } + NSData *challengeData = cn1WebAuthnB64UrlDecode(challengeB64Url); + if (challengeData == nil) { + return fromNSString(CN1_THREAD_GET_STATE_PASS_ARG + cn1WebAuthnErrorString(@"invalid_options", + @"challenge must be base64url-encoded")); + } + + NSMutableArray *allowed = [NSMutableArray array]; + NSArray *allowList = [opts objectForKey:@"allowCredentials"]; + if ([allowList isKindOfClass:[NSArray class]]) { + for (id raw in allowList) { + if (![raw isKindOfClass:[NSDictionary class]]) continue; + NSString *credIdB64Url = [(NSDictionary *)raw objectForKey:@"id"]; + NSData *credIdData = cn1WebAuthnB64UrlDecode(credIdB64Url); + if (credIdData != nil) { + [allowed addObject: + [[ASAuthorizationPlatformPublicKeyCredentialDescriptor alloc] + initWithCredentialID:credIdData]]; + } + } + } + + __block NSString *finalResult = nil; + __block NSString *finalErrCode = nil; + __block NSString *finalErrMsg = nil; + dispatch_semaphore_t sem = dispatch_semaphore_create(0); + + dispatch_async(dispatch_get_main_queue(), ^{ + ASAuthorizationPlatformPublicKeyCredentialProvider *provider = + [[ASAuthorizationPlatformPublicKeyCredentialProvider alloc] + initWithRelyingPartyIdentifier:rpId]; + ASAuthorizationPlatformPublicKeyCredentialAssertionRequest *request = + [provider createCredentialAssertionRequestWithChallenge:challengeData]; + if (allowed.count > 0) { + request.allowedCredentials = allowed; + } + NSString *uv = [opts objectForKey:@"userVerification"]; + if ([uv isEqualToString:@"required"]) { + request.userVerificationPreference = ASAuthorizationPublicKeyCredentialUserVerificationPreferenceRequired; + } else if ([uv isEqualToString:@"discouraged"]) { + request.userVerificationPreference = ASAuthorizationPublicKeyCredentialUserVerificationPreferenceDiscouraged; + } else if (uv != nil) { + request.userVerificationPreference = ASAuthorizationPublicKeyCredentialUserVerificationPreferencePreferred; + } + + CN1WebAuthnDelegate *del = [[CN1WebAuthnDelegate alloc] init]; + del.completion = ^{ + finalResult = del.resultJson; + finalErrCode = del.errorCode; + finalErrMsg = del.errorMessage; + dispatch_semaphore_signal(sem); + }; + + ASAuthorizationController *controller = + [[ASAuthorizationController alloc] initWithAuthorizationRequests:@[request]]; + controller.delegate = del; + controller.presentationContextProvider = del; + + g_cn1WebAuthnCurrentDelegate = del; + g_cn1WebAuthnCurrentController = controller; + [controller performRequests]; + }); + + dispatch_semaphore_wait(sem, dispatch_time(DISPATCH_TIME_NOW, (int64_t)(3600 * NSEC_PER_SEC))); + g_cn1WebAuthnCurrentDelegate = nil; + g_cn1WebAuthnCurrentController = nil; + + if (finalErrCode != nil) { + return fromNSString(CN1_THREAD_GET_STATE_PASS_ARG + cn1WebAuthnErrorString(finalErrCode, finalErrMsg)); + } + if (finalResult == nil) { + return JAVA_NULL; + } + return fromNSString(CN1_THREAD_GET_STATE_PASS_ARG finalResult); +} + +#else + +// Stubs when CN1_INCLUDE_WEBAUTHN is not defined: app didn't reference any +// com.codename1.io.webauthn.* class, so the Java side won't load +// WebAuthnNativeImpl and these natives are unreachable. ParparVM still needs +// the symbols to satisfy the native-method declarations on IOSNative.java. + +JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_webauthnSupported__( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me) { + return JAVA_FALSE; +} + +JAVA_OBJECT com_codename1_impl_ios_IOSNative_webauthnCreate___java_lang_String( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_OBJECT optionsJsonObj) { + return JAVA_NULL; +} + +JAVA_OBJECT com_codename1_impl_ios_IOSNative_webauthnGet___java_lang_String( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_OBJECT optionsJsonObj) { + return JAVA_NULL; +} + +#endif // CN1_INCLUDE_WEBAUTHN diff --git a/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.h b/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.h index f6093c84bb..bf21058688 100644 --- a/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.h +++ b/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.h @@ -98,6 +98,13 @@ // entitlement. //#define CN1_INCLUDE_APPLESIGNIN +// CN1_INCLUDE_WEBAUTHN gates the com.codename1.io.webauthn native bridge +// (ASAuthorizationPlatformPublicKeyCredentialProvider code in CN1WebAuthn.m, +// iOS 16+). IPhoneBuilder uncomments this only when the scanner saw +// com.codename1.io.webauthn.*; apps that never use passkeys ship without +// any passkey symbols. +//#define CN1_INCLUDE_WEBAUTHN + //#define INCLUDE_CN1_BACKGROUND_FETCH //#define INCLUDE_FACEBOOK_CONNECT //#define USE_FACEBOOK_CONNECT_PODS diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java index 8776bef39f..4e96ae4fe7 100644 --- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java @@ -542,6 +542,13 @@ native void fillGradient(int kind, int stopCount, float[] positions, float[] pre public native boolean appleSignInIsLoggedIn(); public native void appleSignInSignOut(); + // WebAuthn / passkeys -- + // ASAuthorizationPlatformPublicKeyCredentialProvider (iOS 16+). + // See nativeSources/CN1WebAuthn.m for the Obj-C side. + public native boolean webauthnSupported(); + public native String webauthnCreate(String optionsJson); + public native String webauthnGet(String optionsJson); + public native boolean isAsyncEditMode(); diff --git a/Ports/iOSPort/src/com/codename1/io/webauthn/WebAuthnNativeImpl.java b/Ports/iOSPort/src/com/codename1/io/webauthn/WebAuthnNativeImpl.java new file mode 100644 index 0000000000..f3a848d718 --- /dev/null +++ b/Ports/iOSPort/src/com/codename1/io/webauthn/WebAuthnNativeImpl.java @@ -0,0 +1,85 @@ +/* + * Copyright (c) 2012-2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.io.webauthn; + +import com.codename1.impl.ios.IOSImplementation; + +/** + * iOS port implementation of {@link WebAuthnNative}. Thin Java wrapper that + * delegates to native methods on {@link com.codename1.impl.ios.IOSNative}; + * the C bodies live in {@code Ports/iOSPort/nativeSources/CN1WebAuthn.m} and + * use {@code ASAuthorizationPlatformPublicKeyCredentialProvider} (iOS 16+). + * + *

{@link #init()} is invoked from the generated iOS app stub by + * {@code IPhoneBuilder} when the classpath scanner sees any reference to + * {@code com.codename1.io.webauthn.*}. + * + *

The native side encodes the result as a W3C RegistrationResponseJSON / + * AuthenticationResponseJSON document with all binary fields base64url-encoded, + * matching the format every WebAuthn server library expects. + */ +public class WebAuthnNativeImpl implements WebAuthnNative { + + /** Invoked from the generated app stub at startup. */ + public static void init() { + WebAuthnClient.setProvider(new WebAuthnNativeImpl()); + } + + public boolean isSupported() { + return IOSImplementation.nativeInstance.webauthnSupported(); + } + + public String createPasskey(String optionsJson) throws WebAuthnException { + String r = IOSImplementation.nativeInstance.webauthnCreate(optionsJson); + return unwrap(r); + } + + public String getPasskey(String optionsJson) throws WebAuthnException { + String r = IOSImplementation.nativeInstance.webauthnGet(optionsJson); + return unwrap(r); + } + + /** + * The native side returns one of: + *

    + *
  • A response JSON beginning with {@code '{'} -- success.
  • + *
  • {@code null} -- user cancelled (the Java layer maps this to + * {@link WebAuthnException#NOT_ALLOWED}).
  • + *
  • An error string of the form {@code "ERR::"} -- + * the OS authenticator failed; we unwrap it to a typed exception.
  • + *
+ */ + private static String unwrap(String raw) throws WebAuthnException { + if (raw == null) { + return null; + } + if (raw.startsWith("ERR:")) { + int sep = raw.indexOf(':', 4); + String code = sep > 4 ? raw.substring(4, sep) : raw.substring(4); + String message = sep > 4 ? raw.substring(sep + 1) : "Native authenticator failed"; + throw new WebAuthnException(code, message); + } + return raw; + } +} diff --git a/docs/developer-guide/Authentication-And-Identity.asciidoc b/docs/developer-guide/Authentication-And-Identity.asciidoc index 8af28a9965..b5e39c7657 100644 --- a/docs/developer-guide/Authentication-And-Identity.asciidoc +++ b/docs/developer-guide/Authentication-And-Identity.asciidoc @@ -295,6 +295,192 @@ if (!fa.isSignedIn()) { } ---- +=== Passkeys / WebAuthn + +Codename One ships a portable WebAuthn client at `com.codename1.io.webauthn.WebAuthnClient`. It wraps the OS public-key credential APIs (`ASAuthorizationPlatformPublicKeyCredentialProvider` on iOS 16+, `androidx.credentials.CredentialManager` on Android API 28+) behind a JSON-friendly Java surface so you can talk to any relying-party server with the same code on every supported platform. + +==== When you actually need this class + +If you sign users in via `OidcClient` against Google, Apple, Microsoft Entra ID, Auth0 or Firebase, the *identity provider already supports passkeys*. The user's web browser at the IdP shows the passkey sheet, and OIDC just hands you the resulting ID / access tokens. You don't have to call `WebAuthnClient` at all -- everything in the rest of this chapter that signs users in via `OidcClient` already gets passkey-backed sign-in transparently, with no additional code on your side. + +Reach for `WebAuthnClient` when: + +* Your app talks to *your own* relying-party server and you want passwordless sign-in / step-up auth backed by a passkey. +* You wire up a direct Auth0 or Firebase passkey ceremony (see <> and <>). +* You drive a third-party SDK that emits a WebAuthn challenge. + +==== Quick start -- own backend + +Pseudo-code (the actual HTTP calls depend on your server library): + +[source,java] +---- +import com.codename1.io.webauthn.PublicKeyCredential; +import com.codename1.io.webauthn.PublicKeyCredentialCreationOptions; +import com.codename1.io.webauthn.PublicKeyCredentialRequestOptions; +import com.codename1.io.webauthn.WebAuthnClient; +import com.codename1.io.webauthn.WebAuthnException; +import com.codename1.util.SuccessCallback; + +// Registration -- enroll a new passkey +String challengeJson = myServer.startPasskeyRegistration(currentUserId); // PublicKeyCredentialCreationOptionsJSON +PublicKeyCredentialCreationOptions opts = + PublicKeyCredentialCreationOptions.fromJson(challengeJson); + +WebAuthnClient.getInstance().create(opts) + .ready(new SuccessCallback() { + public void onSucess(PublicKeyCredential cred) { + myServer.finishPasskeyRegistration(cred.toJson()); // server verifies + persists + } + }) + .except(new SuccessCallback() { + public void onSucess(Throwable err) { + if (err instanceof WebAuthnException) { + String code = ((WebAuthnException) err).getError(); + // "NotAllowedError" -- user dismissed the sheet + // "not_supported" -- platform lacks a passkey impl + // ... etc. + } + } + }); + +// Sign-in -- assert with an existing passkey +String requestJson = myServer.startPasskeySignIn(); // PublicKeyCredentialRequestOptionsJSON +WebAuthnClient.getInstance() + .get(PublicKeyCredentialRequestOptions.fromJson(requestJson)) + .ready(new SuccessCallback() { + public void onSucess(PublicKeyCredential cred) { + myServer.finishPasskeySignIn(cred.toJson()); // server verifies signature + } + }); +---- + +`PublicKeyCredential#toJson()` is in the W3C `RegistrationResponseJSON` / `AuthenticationResponseJSON` format, which every WebAuthn server library accepts as-is. On the server, verify the response with one of: + +* Java -- https://webauthn4j.github.io/webauthn4j/en/[webauthn4j] +* Node -- https://simplewebauthn.dev/[@simplewebauthn/server] +* Rust -- https://github.com/kanidm/webauthn-rs[webauthn-rs] +* Python -- https://github.com/duo-labs/py_webauthn[py_webauthn] + +Do *not* try to verify the attestation or assertion on the device. The relying-party server holds the credential record and the signature counter; only it can detect cloned authenticators. + +==== Platform requirements + +[cols="1,3"] +|=== +| Platform | Notes + +| iOS +| iOS 16+. The `AuthenticationServices.framework` library is linked automatically when your app references `com.codename1.io.webauthn.*`. Add an *Associated Domains* entitlement with `webcredentials:` and host an `apple-app-site-association` file at `https:///.well-known/apple-app-site-association` describing your bundle's `webcredentials` app IDs. + +| Android +| API 28+ via `androidx.credentials:credentials` (added to your Gradle build automatically; override the version with build hint `android.credentialsVersion=1.x.y`). Publish a Digital Asset Links file at `https:///.well-known/assetlinks.json` declaring your app's SHA-256 certificate fingerprint. + +| JavaSE / desktop, Web port +| `WebAuthnClient.isSupported()` returns `false`. Calls fail with `WebAuthnException.NOT_IMPLEMENTED`. Use this to gate the UI -- present a fallback (password sign-in, email magic link) when passkeys are unavailable. +|=== + +==== Build hints + +iOS: + +---- +ios.entitlements.com.apple.developer.associated-domains=\n webcredentials:example.com\n +---- + +Android: nothing beyond the dependency that gets injected automatically. The asset-links file lives on your server; the build doesn't touch it. + +[[auth0-passkeys]] +==== Auth0 passkeys + +`Auth0Connect` exposes two passkey helpers driven by Auth0's WebAuthn grant (`urn:okta:params:oauth:grant-type:webauthn`). They drive `WebAuthnClient` under the hood, so the same iOS / Android platform requirements apply. + +[source,java] +---- +// Sign in with an existing passkey +Auth0Connect.getInstance() + .withDomain("dev-xyz.us.auth0.com") + .signInWithPasskey( + "YOUR_AUTH0_CLIENT_ID", + "Username-Password-Authentication", // connection / realm + "openid", "email", "profile", "offline_access") + .ready(new SuccessCallback() { + public void onSucess(OidcTokens t) { + String idToken = t.getIdToken(); + } + }); + +// Enroll a brand-new passkey for a new account +Auth0Connect.getInstance() + .withDomain("dev-xyz.us.auth0.com") + .registerPasskey( + "YOUR_AUTH0_CLIENT_ID", + "Username-Password-Authentication", + "alice@example.com", + "Alice", + "openid", "email", "profile") + .ready(new SuccessCallback() { + public void onSucess(OidcTokens tokens) { + // user is signed in; passkey is enrolled for next time + } + }); +---- + +Enable *Passkeys* in the Auth0 dashboard (Authentication → Database → your connection → Authentication Methods → Passkey), and add *WebAuthn* to the application's *Grant Types*. + +[[firebase-passkeys]] +==== Firebase passkeys + +`FirebaseAuth` exposes `registerPasskey` (enroll a passkey for the signed-in user) and `signInWithPasskey` (sign in with an existing passkey). Both use Firebase Identity Platform's v2 passkey REST endpoints (`accounts/passkeyEnrollment:start|finalize`, `accounts/passkeySignIn:start|finalize`). + +[source,java] +---- +FirebaseAuth fa = FirebaseAuth.getInstance().withApiKey("YOUR_FIREBASE_WEB_API_KEY"); + +// Sign in with a passkey already on this device +fa.signInWithPasskey().ready(new SuccessCallback() { + public void onSucess(FirebaseAuth.FirebaseUser u) { + String uid = u.getUid(); + String firebaseIdToken = u.getIdToken(); + } +}); + +// Enroll a new passkey for the currently signed-in user +fa.signInWithEmailAndPassword(email, password) + .ready(new SuccessCallback() { + public void onSucess(FirebaseAuth.FirebaseUser u) { + fa.registerPasskey("Alice's iPhone") + .ready(new SuccessCallback() { + public void onSucess(FirebaseAuth.FirebaseUser enrolled) { + // Next launch can sign in with signInWithPasskey() alone + } + }); + } + }); +---- + +Passkey support requires the *Identity Platform* tier of Firebase Auth (the upgraded plan). The free Firebase Auth tier doesn't expose the passkey endpoints; the calls will fail with an HTTP 403. Check your project plan in the Firebase console under Build → Authentication → Settings → User account linking. + +==== Mapping WebAuthn errors + +`WebAuthnException.getError()` returns one of the W3C-defined error names: + +[cols="1,3"] +|=== +| Code | Meaning + +| `NotAllowedError` | User dismissed the OS sheet, no matching credential, or the authenticator denied the request. +| `InvalidStateError`| The credential being created already exists on the authenticator. +| `NotSupportedError`| Platform doesn't support the requested algorithm / transport combo. +| `SecurityError` | Origin / RP-ID validation failed. Most often an Associated Domain (iOS) or asset-links (Android) misconfiguration. +| `AbortError` | Caller cancelled the ceremony. +| `ConstraintError` | A constraint (resident key, user verification) couldn't be satisfied. +| `not_supported` | The platform has no native WebAuthn implementation at all. Caller should fall back to password sign-in. +| `transport_error` | Network failure while POSTing to your relying-party server. +| `invalid_options` | The options JSON received from the relying party couldn't be parsed. +| `invalid_response` | The response returned by the authenticator couldn't be parsed. +|=== + [[migrating-from-oauth2]] === Migrating from `Oauth2` diff --git a/docs/developer-guide/languagetool-accept.txt b/docs/developer-guide/languagetool-accept.txt index b564d67cb2..ba5120f7a6 100644 --- a/docs/developer-guide/languagetool-accept.txt +++ b/docs/developer-guide/languagetool-accept.txt @@ -504,3 +504,12 @@ pidof Keycloak Cognito Authentik + +# WebAuthn / passkey terminology used in the Authentication & Identity chapter. +# IdP is the standard W3C abbreviation for "identity provider"; the +# server-library names below appear verbatim in the recommended-libraries +# table. +IdP +webauthn +[Pp]asskey +[Pp]asskeys diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java index e7f2a58f96..4e66757796 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java @@ -286,6 +286,7 @@ public File getGradleProjectDirectory() { private boolean usesNfcHce; private boolean usesOidc; private boolean usesAppleSignIn; + private boolean usesWebauthn; private boolean vibratePermission; private boolean smsPermission; private boolean gpsPermission; @@ -1304,6 +1305,13 @@ public void usesClass(String cls) { && cls.indexOf("com/codename1/social/AppleSignIn") == 0) { usesAppleSignIn = true; } + // WebAuthn / passkeys -- drives the OS through + // androidx.credentials.CredentialManager. Mark so the + // gradle dep gets injected further below. + if (!usesWebauthn + && cls.indexOf("com/codename1/io/webauthn/") == 0) { + usesWebauthn = true; + } // Deeper-network connectivity: each subpackage maps to a // distinct permission set. The scanner sets booleans; the // injection block below builds the manifest fragments only @@ -3889,6 +3897,27 @@ public void usesClassMethod(String cls, String method) { } } + // WebAuthn / passkeys drive androidx.credentials.CredentialManager. + // Passkey support on devices without a system-level provider also + // requires credentials-play-services-auth, so we inject both here. + // The versions can be overridden by the user via build hints. + if (usesWebauthn && useAndroidX) { + String credentialsVersion = request.getArg( + "android.credentialsVersion", "1.3.0"); + String credentialsPlayVersion = request.getArg( + "android.credentialsPlayServicesVersion", credentialsVersion); + if (!additionalDependencies.contains("androidx.credentials:credentials") + && !request.getArg("android.gradleDep", "") + .contains("androidx.credentials:credentials")) { + additionalDependencies += + " implementation 'androidx.credentials:credentials:" + + credentialsVersion + "'\n"; + additionalDependencies += + " implementation 'androidx.credentials:credentials-play-services-auth:" + + credentialsPlayVersion + "'\n"; + } + } + String useLegacyApache = ""; if (request.getArg("android.apacheLegacy", "false").equals("true")) { useLegacyApache = " useLibrary 'org.apache.http.legacy'\n"; @@ -4450,6 +4479,12 @@ private String createOnCreateCode(BuildRequest request) { if (usesAppleSignIn) { retVal += "com.codename1.social.AppleSignInNativeImpl.init();\n"; } + // WebAuthn / passkeys bootstrap. Wires the CredentialManager-backed + // native impl so WebAuthnClient.isSupported() works without any + // user-side setup. + if (usesWebauthn) { + retVal += "com.codename1.io.webauthn.WebAuthnNativeImpl.init();\n"; + } if (request.getArg("android.web_loading_hidden", "false").equalsIgnoreCase("true")) { retVal += "Display.getInstance().setProperty(\"WebLoadingHidden\", \"true\");\n"; diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java index b135813508..51f93d1907 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java @@ -89,6 +89,7 @@ public class IPhoneBuilder extends Executor { private boolean usesNfc; private boolean usesOidc; private boolean usesAppleSignIn; + private boolean usesWebauthn; private boolean usesNfcHce; // Deeper-network connectivity flags. Set by the classpath scanner when @@ -699,6 +700,14 @@ public void usesClass(String cls) { && cls.indexOf("com/codename1/social/AppleSignIn") == 0) { usesAppleSignIn = true; } + // WebAuthn / passkeys (ASAuthorizationPlatformPublicKeyCredentialProvider) + // also lives in AuthenticationServices.framework. Same gate + // strategy: only enable the native bridge when the app + // references com.codename1.io.webauthn.* + if (!usesWebauthn + && cls.indexOf("com/codename1/io/webauthn/") == 0) { + usesWebauthn = true; + } if (cls.indexOf("com/codename1/io/wifi/WiFi") == 0 && !cls.equals("com/codename1/io/wifi/WiFiDirect")) { // WiFi info or scan/connect. iOS has no scan API so @@ -920,6 +929,12 @@ public void usesClassMethod(String cls, String method) { integrateAppleSignIn = " com.codename1.social.AppleSignInNativeImpl.init();\n"; } + // WebAuthn bootstrap -- same mechanism, separate gate. + String integrateWebauthn = ""; + if (usesWebauthn) { + integrateWebauthn = + " com.codename1.io.webauthn.WebAuthnNativeImpl.init();\n"; + } String integrateGoogleConnect = ""; if (useGoogleSignIn) { @@ -1150,6 +1165,7 @@ public void usesClassMethod(String cls, String method) { + integrateGoogleConnect + integrateOidcBrowser + integrateAppleSignIn + + integrateWebauthn + " if(!initialized) {\n" + " initialized = true;\n" @@ -1701,7 +1717,7 @@ public void usesClassMethod(String cls, String method) { // in -- otherwise the .m files would reference framework symbols // without the framework being linked, breaking the link step // for apps that never use the API. - if (usesOidc || usesAppleSignIn) { + if (usesOidc || usesAppleSignIn || usesWebauthn) { String authSvc = "AuthenticationServices.framework"; if (addLibs == null || addLibs.length() == 0) { addLibs = authSvc; @@ -1731,6 +1747,17 @@ public void usesClassMethod(String cls, String method) { "Failed to enable CN1_INCLUDE_APPLESIGNIN", ex); } } + if (usesWebauthn) { + try { + replaceInFile(new File(buildinRes, + "CodenameOne_GLViewController.h"), + "//#define CN1_INCLUDE_WEBAUTHN", + "#define CN1_INCLUDE_WEBAUTHN"); + } catch (IOException ex) { + throw new BuildException( + "Failed to enable CN1_INCLUDE_WEBAUTHN", ex); + } + } // CoreNFC is required only when the app actually uses // com.codename1.nfc. We weak-link it so older deployment targets diff --git a/maven/core-unittests/src/test/java/com/codename1/io/webauthn/WebAuthnCoreTest.java b/maven/core-unittests/src/test/java/com/codename1/io/webauthn/WebAuthnCoreTest.java new file mode 100644 index 0000000000..9a466f8cc8 --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/io/webauthn/WebAuthnCoreTest.java @@ -0,0 +1,416 @@ +package com.codename1.io.webauthn; + +import com.codename1.junit.UITestBase; +import com.codename1.util.SuccessCallback; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.Map; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; + +/// Pure-Java tests for the WebAuthn core: options parsing, response parsing, +/// builder round-trips, error mapping, async dispatch when no native provider +/// is registered. No actual passkey ceremonies run -- the OS authenticator is +/// only reachable on iOS 16+ / Android API 28+ devices. +public class WebAuthnCoreTest extends UITestBase { + + @BeforeEach + public void resetProvider() { + WebAuthnClient.setProvider(null); + } + + @AfterEach + public void clearProvider() { + WebAuthnClient.setProvider(null); + } + + // -------- options round-trip --------------------------------------- + + @Test + public void creationOptionsParseFieldsFromJson() { + String json = "{" + + "\"rp\":{\"id\":\"example.com\",\"name\":\"Example\"}," + + "\"user\":{\"id\":\"dXNyMQ\",\"name\":\"alice@example.com\",\"displayName\":\"Alice\"}," + + "\"challenge\":\"Y2hhbGxlbmdl\"," + + "\"pubKeyCredParams\":[{\"type\":\"public-key\",\"alg\":-7}]," + + "\"attestation\":\"none\"" + + "}"; + PublicKeyCredentialCreationOptions opts = + PublicKeyCredentialCreationOptions.fromJson(json); + assertEquals("example.com", opts.getRpId()); + assertEquals("Example", opts.getRpName()); + assertEquals("dXNyMQ", opts.getUserId()); + assertEquals("alice@example.com", opts.getUserName()); + assertEquals("Alice", opts.getUserDisplayName()); + assertEquals("Y2hhbGxlbmdl", opts.getChallenge()); + // The original JSON is preserved verbatim for forwarding to the OS + // authenticator. + assertEquals(json, opts.toJson()); + } + + @Test + public void creationOptionsBuilderProducesValidJson() { + PublicKeyCredentialCreationOptions opts = + PublicKeyCredentialCreationOptions.newBuilder() + .rp("example.com", "Example") + .user("dXNyMQ", "alice@example.com", "Alice") + .challenge("Y2hhbGxlbmdl") + .authenticatorAttachment("platform") + .userVerification("required") + .residentKey("required") + .build(); + assertEquals("example.com", opts.getRpId()); + assertEquals("Alice", opts.getUserDisplayName()); + // Built JSON must round-trip through fromJson() losslessly. + PublicKeyCredentialCreationOptions reparsed = + PublicKeyCredentialCreationOptions.fromJson(opts.toJson()); + assertEquals("example.com", reparsed.getRpId()); + assertEquals("alice@example.com", reparsed.getUserName()); + assertEquals("Y2hhbGxlbmdl", reparsed.getChallenge()); + + Map map = opts.asMap(); + Object authSel = map.get("authenticatorSelection"); + assertTrue(authSel instanceof Map, "authenticatorSelection should be a JSON object"); + @SuppressWarnings("unchecked") + Map authSelMap = (Map) authSel; + assertEquals("required", authSelMap.get("userVerification")); + assertEquals("platform", authSelMap.get("authenticatorAttachment")); + } + + @Test + public void creationOptionsBuilderRejectsMissingFields() { + try { + PublicKeyCredentialCreationOptions.newBuilder() + .user("u", "u@x", "u") + .challenge("c") + .build(); + fail("Expected IllegalStateException for missing rp"); + } catch (IllegalStateException expected) {} + + try { + PublicKeyCredentialCreationOptions.newBuilder() + .rp("example.com", "Example") + .challenge("c") + .build(); + fail("Expected IllegalStateException for missing user"); + } catch (IllegalStateException expected) {} + + try { + PublicKeyCredentialCreationOptions.newBuilder() + .rp("example.com", "Example") + .user("u", "u@x", "u") + .build(); + fail("Expected IllegalStateException for missing challenge"); + } catch (IllegalStateException expected) {} + } + + @Test + public void creationOptionsFromJsonRejectsNull() { + try { + PublicKeyCredentialCreationOptions.fromJson(null); + fail("Expected IllegalArgumentException"); + } catch (IllegalArgumentException expected) {} + } + + @Test + public void requestOptionsParseAndBuildersRoundTrip() { + String json = "{\"rpId\":\"example.com\"," + + "\"challenge\":\"Y2hhbGxlbmdl\"," + + "\"userVerification\":\"required\"," + + "\"allowCredentials\":[{\"type\":\"public-key\",\"id\":\"a\"},{\"type\":\"public-key\",\"id\":\"b\"}]}"; + PublicKeyCredentialRequestOptions opts = + PublicKeyCredentialRequestOptions.fromJson(json); + assertEquals("example.com", opts.getRpId()); + assertEquals("Y2hhbGxlbmdl", opts.getChallenge()); + assertEquals("required", opts.getUserVerification()); + + PublicKeyCredentialRequestOptions built = + PublicKeyCredentialRequestOptions.newBuilder() + .rpId("example.com") + .challenge("Y2hhbGxlbmdl") + .userVerification("preferred") + .build(); + // Round-trip the built JSON. + PublicKeyCredentialRequestOptions reparsed = + PublicKeyCredentialRequestOptions.fromJson(built.toJson()); + assertEquals("example.com", reparsed.getRpId()); + assertEquals("Y2hhbGxlbmdl", reparsed.getChallenge()); + assertEquals("preferred", reparsed.getUserVerification()); + } + + // -------- response parsing ----------------------------------------- + + @Test + public void registrationResponseParsesAllFields() { + String registrationJson = "{" + + "\"id\":\"cred-1\",\"rawId\":\"cred-1\",\"type\":\"public-key\"," + + "\"authenticatorAttachment\":\"platform\"," + + "\"response\":{\"clientDataJSON\":\"Y2RhdGE\"," + + "\"attestationObject\":\"YXR0\"," + + "\"transports\":[\"internal\"]}," + + "\"clientExtensionResults\":{}" + + "}"; + PublicKeyCredential cred = PublicKeyCredential.fromJson(registrationJson); + assertEquals("cred-1", cred.getId()); + assertEquals("cred-1", cred.getRawId()); + assertEquals("platform", cred.getAuthenticatorAttachment()); + assertEquals("Y2RhdGE", cred.getClientDataJSON()); + assertEquals("YXR0", cred.getAttestationObject()); + assertNull(cred.getSignature()); + assertNull(cred.getUserHandle()); + assertTrue(cred.isRegistration()); + assertEquals(registrationJson, cred.toJson()); + } + + @Test + public void assertionResponseParsesAllFields() { + String assertionJson = "{" + + "\"id\":\"cred-1\",\"rawId\":\"cred-1\",\"type\":\"public-key\"," + + "\"response\":{\"clientDataJSON\":\"Y2RhdGE\"," + + "\"authenticatorData\":\"YXV0aA\"," + + "\"signature\":\"c2ln\"," + + "\"userHandle\":\"dXNy\"}," + + "\"clientExtensionResults\":{}" + + "}"; + PublicKeyCredential cred = PublicKeyCredential.fromJson(assertionJson); + assertEquals("cred-1", cred.getId()); + assertEquals("Y2RhdGE", cred.getClientDataJSON()); + assertEquals("c2ln", cred.getSignature()); + assertEquals("dXNy", cred.getUserHandle()); + assertNull(cred.getAttestationObject()); + assertFalse(cred.isRegistration()); + } + + @Test + public void publicKeyCredentialFromJsonRejectsBadInputs() { + try { + PublicKeyCredential.fromJson(null); + fail("Expected IllegalArgumentException"); + } catch (IllegalArgumentException expected) {} + try { + PublicKeyCredential.fromJson("not-json"); + fail("Expected IllegalArgumentException"); + } catch (IllegalArgumentException expected) {} + } + + // -------- exception ------------------------------------------------ + + @Test + public void webauthnExceptionPreservesCodeAndMessage() { + WebAuthnException e = new WebAuthnException( + WebAuthnException.SECURITY_ERROR, "RP-id mismatch"); + assertEquals("SecurityError", e.getError()); + assertEquals("RP-id mismatch", e.getErrorDescription()); + assertEquals("RP-id mismatch", e.getMessage()); + + Throwable cause = new RuntimeException("inner"); + WebAuthnException withCause = new WebAuthnException( + WebAuthnException.NOT_ALLOWED, "user dismissed", cause); + assertEquals("user dismissed", withCause.getMessage()); + assertEquals(cause, withCause.getCause()); + } + + @Test + public void exceptionCodesMatchSpecNames() { + // Sanity-check the W3C-named codes so a typo in the constants gets + // caught early -- server libraries do `switch (err.error)` against + // these strings. + assertEquals("NotAllowedError", WebAuthnException.NOT_ALLOWED); + assertEquals("InvalidStateError", WebAuthnException.INVALID_STATE); + assertEquals("NotSupportedError", WebAuthnException.NOT_SUPPORTED); + assertEquals("SecurityError", WebAuthnException.SECURITY_ERROR); + assertEquals("AbortError", WebAuthnException.ABORTED); + assertEquals("ConstraintError", WebAuthnException.CONSTRAINT_ERROR); + } + + // -------- WebAuthnClient async dispatch ---------------------------- + + @Test + public void clientFailsFastWithoutProvider() throws Exception { + // No provider registered, isSupported() must be false. + assertFalse(WebAuthnClient.isSupported()); + + PublicKeyCredentialCreationOptions opts = + PublicKeyCredentialCreationOptions.newBuilder() + .rp("example.com", "Example") + .user("dXNy", "u@x", "U") + .challenge("Y2hhbA") + .build(); + // create() must error synchronously (no thread spawned when there is + // no provider), so .except() fires either inline or before we return + // here. Wait briefly to cover the inline case. + WebAuthnException err = awaitError(WebAuthnClient.getInstance().create(opts)); + assertEquals(WebAuthnException.NOT_IMPLEMENTED, err.getError()); + } + + @Test + public void clientRoutesThroughInstalledProvider() throws Exception { + final AtomicReference capturedCreate = new AtomicReference(); + final AtomicReference capturedGet = new AtomicReference(); + WebAuthnClient.setProvider(new WebAuthnNative() { + public boolean isSupported() { + return true; + } + public String createPasskey(String optionsJson) { + capturedCreate.set(optionsJson); + return "{\"id\":\"cred-99\",\"rawId\":\"cred-99\",\"type\":\"public-key\"," + + "\"response\":{\"clientDataJSON\":\"Y2RhdGE\"," + + "\"attestationObject\":\"YXR0\"}}"; + } + public String getPasskey(String optionsJson) { + capturedGet.set(optionsJson); + return "{\"id\":\"cred-99\",\"rawId\":\"cred-99\",\"type\":\"public-key\"," + + "\"response\":{\"clientDataJSON\":\"Y2RhdGE\"," + + "\"authenticatorData\":\"YXV0aA\",\"signature\":\"c2ln\"," + + "\"userHandle\":\"dXNy\"}}"; + } + }); + assertTrue(WebAuthnClient.isSupported()); + + PublicKeyCredentialCreationOptions opts = + PublicKeyCredentialCreationOptions.newBuilder() + .rp("example.com", "Example") + .user("dXNy", "u@x", "U") + .challenge("Y2hhbA") + .build(); + PublicKeyCredential created = awaitSuccess( + WebAuthnClient.getInstance().create(opts)); + assertEquals("cred-99", created.getId()); + assertTrue(created.isRegistration()); + assertEquals(opts.toJson(), capturedCreate.get()); + + PublicKeyCredentialRequestOptions req = + PublicKeyCredentialRequestOptions.newBuilder() + .rpId("example.com") + .challenge("Y2hhbA") + .build(); + PublicKeyCredential asserted = awaitSuccess( + WebAuthnClient.getInstance().get(req)); + assertEquals("c2ln", asserted.getSignature()); + assertFalse(asserted.isRegistration()); + assertEquals(req.toJson(), capturedGet.get()); + } + + @Test + public void clientPropagatesNativeWebAuthnException() throws Exception { + WebAuthnClient.setProvider(new WebAuthnNative() { + public boolean isSupported() { + return true; + } + public String createPasskey(String optionsJson) throws WebAuthnException { + throw new WebAuthnException(WebAuthnException.INVALID_STATE, + "credential already exists"); + } + public String getPasskey(String optionsJson) { + return null; + } + }); + PublicKeyCredentialCreationOptions opts = + PublicKeyCredentialCreationOptions.newBuilder() + .rp("example.com", "Example") + .user("dXNy", "u@x", "U") + .challenge("Y2hhbA") + .build(); + WebAuthnException err = awaitError(WebAuthnClient.getInstance().create(opts)); + assertEquals(WebAuthnException.INVALID_STATE, err.getError()); + } + + @Test + public void clientMapsNullReturnToUserCancelled() throws Exception { + WebAuthnClient.setProvider(new WebAuthnNative() { + public boolean isSupported() { + return true; + } + public String createPasskey(String optionsJson) { + return null; + } + public String getPasskey(String optionsJson) { + return null; + } + }); + PublicKeyCredentialRequestOptions req = + PublicKeyCredentialRequestOptions.newBuilder() + .rpId("example.com") + .challenge("Y2hhbA") + .build(); + WebAuthnException err = awaitError(WebAuthnClient.getInstance().get(req)); + assertEquals(WebAuthnException.NOT_ALLOWED, err.getError()); + } + + // ------------------------------------------------------------------ + // + // Avoid the missed-notify race in AsyncResource#get(timeout): the + // observer that wakes the timed wait registers AFTER the boolean check, + // so a worker-thread error() that fires between check and wait is + // missed. The .ready()/.except() callbacks are race-safe (they run + // immediately when the resource is already done at registration time), + // so a CountDownLatch wired through them gives deterministic results. + + private static V awaitSuccess(com.codename1.util.AsyncResource r) + throws InterruptedException { + final CountDownLatch latch = new CountDownLatch(1); + final AtomicReference ok = new AtomicReference(); + final AtomicReference bad = new AtomicReference(); + r.ready(new SuccessCallback() { + public void onSucess(V value) { + ok.set(value); + latch.countDown(); + } + }).except(new SuccessCallback() { + public void onSucess(Throwable t) { + bad.set(t); + latch.countDown(); + } + }); + if (!latch.await(15, TimeUnit.SECONDS)) { + fail("AsyncResource never completed within 15 s"); + } + if (bad.get() != null) { + throw new AssertionError("Expected success but got error: " + bad.get(), + bad.get()); + } + assertNotNull(ok.get(), "Resource completed but value was null"); + return ok.get(); + } + + @SuppressWarnings({"unchecked", "rawtypes"}) + private static WebAuthnException awaitError(com.codename1.util.AsyncResource r) + throws InterruptedException { + final CountDownLatch latch = new CountDownLatch(1); + final AtomicReference bad = new AtomicReference(); + final AtomicReference good = new AtomicReference(); + r.ready(new SuccessCallback() { + public void onSucess(Object value) { + good.set(value); + latch.countDown(); + } + }).except(new SuccessCallback() { + public void onSucess(Throwable t) { + bad.set(t); + latch.countDown(); + } + }); + if (!latch.await(15, TimeUnit.SECONDS)) { + fail("AsyncResource never errored within 15 s"); + } + if (good.get() != null) { + fail("Expected error but resource completed successfully with: " + good.get()); + } + Throwable t = bad.get(); + while (t != null && !(t instanceof WebAuthnException)) { + t = t.getCause(); + } + assertNotNull(t, "Expected a WebAuthnException in the cause chain, got: " + bad.get()); + return (WebAuthnException) t; + } +}