From 1b6c4c1fd7f00282b4ac335aec54d0df4d2d9de3 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 23 May 2026 17:58:24 +0300 Subject: [PATCH 01/20] Add OidcClient identity stack + native iOS/Android bindings Replaces the in-app-WebView Oauth2 flow (now rejected by Google, Apple, Microsoft and Facebook) with a modern OpenID Connect client driven from the system browser: * New com.codename1.io.oidc package: OidcClient (discover/authorize/ refresh/revoke), PkceChallenge (S256), OidcConfiguration, OidcTokens with ID-token claim decoding, OidcException with typed errors, pluggable TokenStore, and a SystemBrowser facade that dispatches to a per-port NativeInterface (OidcBrowserNative). * AppleSignIn moved from the external cn1-applesignin cn1lib into core, with a native ASAuthorizationAppleIDProvider impl on iOS 13+ and an OidcClient-backed web fallback on every other platform. * GoogleConnect.signIn / FacebookConnect.signIn switched to the new stack; legacy doLogin paths kept for source compat. * MicrosoftConnect (Entra ID, any tenant), Auth0Connect and FirebaseAuth (REST: email/password, IdP token exchange, refresh) added. * Oauth2 is @Deprecated with a migration recipe pointing at OidcClient. Native bindings: * Ports/iOSPort/nativeSources adds ASWebAuthenticationSession and ASAuthorizationAppleIDProvider impls behind the OidcBrowserNative / AppleSignInNative interfaces. The Maven plugin's IPhoneBuilder auto-links AuthenticationServices.framework and auto-injects the com.apple.developer.applesignin entitlement when the scanner sees the classes in use. * Ports/Android/src adds an androidx.browser.customtabs-backed OidcBrowserNativeImpl (with ACTION_VIEW fallback) and a non-supporting AppleSignInNativeImpl so AppleSignIn falls through to its web flow. AndroidGradleBuilder auto-injects androidx.browser:browser:1.8.0 when the OIDC classes are referenced; override via android.customTabsVersion. Docs and demo: * New "Authentication and Identity" chapter in the developer guide with per-provider recipes, migration guidance from Oauth2, and the build- hint plumbing for the redirect URI scheme on iOS and Android. * Samples/samples/UniversalSignInDemo: a one-screen app with one button per provider plus a generic OIDC issuer, designed to be copy-pasteable. Tests + CI: * 11 new OidcCoreTest assertions (PKCE generation, claim decoding, discovery JSON parsing, exception propagation, default token-store round-trip). The 16 existing Oauth2/Login/*Connect tests still pass. * New .github/workflows/identity-stack.yml: path-filtered PR check that runs the unit tests, compiles the Maven plugin (verifies scanner edits), packages the Android port (verifies new Java sources bundle), javac-compiles the demo against built core, greps the demo for real-looking credentials, and clang -fsyntax-only on the iOS native sources from a macOS runner. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/identity-stack.yml | 235 +++++++ CodenameOne/src/com/codename1/io/Oauth2.java | 21 +- .../codename1/io/oidc/OidcBrowserNative.java | 62 ++ .../src/com/codename1/io/oidc/OidcClient.java | 655 ++++++++++++++++++ .../codename1/io/oidc/OidcConfiguration.java | 175 +++++ .../com/codename1/io/oidc/OidcException.java | 91 +++ .../src/com/codename1/io/oidc/OidcTokens.java | 239 +++++++ .../com/codename1/io/oidc/PkceChallenge.java | 104 +++ .../com/codename1/io/oidc/SystemBrowser.java | 208 ++++++ .../src/com/codename1/io/oidc/TokenStore.java | 209 ++++++ .../src/com/codename1/social/AppleSignIn.java | 414 +++++++++++ .../codename1/social/AppleSignInCallback.java | 43 ++ .../codename1/social/AppleSignInNative.java | 66 ++ .../codename1/social/AppleSignInResult.java | 78 +++ .../com/codename1/social/Auth0Connect.java | 142 ++++ .../com/codename1/social/FacebookConnect.java | 62 ++ .../com/codename1/social/FirebaseAuth.java | 369 ++++++++++ .../com/codename1/social/GoogleConnect.java | 93 ++- .../codename1/social/MicrosoftConnect.java | 136 ++++ .../io/oidc/OidcBrowserNativeImpl.java | 196 ++++++ .../social/AppleSignInNativeImpl.java | 62 ++ ..._codename1_io_oidc_OidcBrowserNativeImpl.h | 32 + ..._codename1_io_oidc_OidcBrowserNativeImpl.m | 165 +++++ ...m_codename1_social_AppleSignInNativeImpl.h | 34 + ...m_codename1_social_AppleSignInNativeImpl.m | 231 ++++++ Samples/samples/UniversalSignInDemo/README.md | 49 ++ .../UniversalSignInDemo.java | 315 +++++++++ .../Authentication-And-Identity.asciidoc | 360 ++++++++++ .../Miscellaneous-Features.asciidoc | 6 + docs/developer-guide/developer-guide.asciidoc | 2 + .../builders/AndroidGradleBuilder.java | 21 + .../com/codename1/builders/IPhoneBuilder.java | 43 ++ .../com/codename1/io/oidc/OidcCoreTest.java | 187 +++++ 33 files changed, 5094 insertions(+), 11 deletions(-) create mode 100644 .github/workflows/identity-stack.yml create mode 100644 CodenameOne/src/com/codename1/io/oidc/OidcBrowserNative.java create mode 100644 CodenameOne/src/com/codename1/io/oidc/OidcClient.java create mode 100644 CodenameOne/src/com/codename1/io/oidc/OidcConfiguration.java create mode 100644 CodenameOne/src/com/codename1/io/oidc/OidcException.java create mode 100644 CodenameOne/src/com/codename1/io/oidc/OidcTokens.java create mode 100644 CodenameOne/src/com/codename1/io/oidc/PkceChallenge.java create mode 100644 CodenameOne/src/com/codename1/io/oidc/SystemBrowser.java create mode 100644 CodenameOne/src/com/codename1/io/oidc/TokenStore.java create mode 100644 CodenameOne/src/com/codename1/social/AppleSignIn.java create mode 100644 CodenameOne/src/com/codename1/social/AppleSignInCallback.java create mode 100644 CodenameOne/src/com/codename1/social/AppleSignInNative.java create mode 100644 CodenameOne/src/com/codename1/social/AppleSignInResult.java create mode 100644 CodenameOne/src/com/codename1/social/Auth0Connect.java create mode 100644 CodenameOne/src/com/codename1/social/FirebaseAuth.java create mode 100644 CodenameOne/src/com/codename1/social/MicrosoftConnect.java create mode 100644 Ports/Android/src/com/codename1/io/oidc/OidcBrowserNativeImpl.java create mode 100644 Ports/Android/src/com/codename1/social/AppleSignInNativeImpl.java create mode 100644 Ports/iOSPort/nativeSources/com_codename1_io_oidc_OidcBrowserNativeImpl.h create mode 100644 Ports/iOSPort/nativeSources/com_codename1_io_oidc_OidcBrowserNativeImpl.m create mode 100644 Ports/iOSPort/nativeSources/com_codename1_social_AppleSignInNativeImpl.h create mode 100644 Ports/iOSPort/nativeSources/com_codename1_social_AppleSignInNativeImpl.m create mode 100644 Samples/samples/UniversalSignInDemo/README.md create mode 100644 Samples/samples/UniversalSignInDemo/UniversalSignInDemo.java create mode 100644 docs/developer-guide/Authentication-And-Identity.asciidoc create mode 100644 maven/core-unittests/src/test/java/com/codename1/io/oidc/OidcCoreTest.java diff --git a/.github/workflows/identity-stack.yml b/.github/workflows/identity-stack.yml new file mode 100644 index 0000000000..f3461a6d53 --- /dev/null +++ b/.github/workflows/identity-stack.yml @@ -0,0 +1,235 @@ +name: Identity stack + +# Focused, fast PR check for the OidcClient / SystemBrowser / AppleSignIn / +# *Connect identity stack. Triggers only when files in the identity surface +# change, so it pages reviewers within a couple of minutes instead of waiting +# for the full PR matrix. +# +# Coverage: +# - linux-tests : compiles core, runs OidcCoreTest + the existing +# Oauth2/Login/*Connect unit tests, builds the Maven +# plugin (so IPhoneBuilder + AndroidGradleBuilder scanner +# changes can't bit-rot), packages the Android port +# (verifies new Java native impls bundle correctly), and +# javac-compiles the UniversalSignInDemo sample against +# the freshly built core jar to catch API drift. +# - sample-secrets : trivial grep scan that fails if real-looking credentials +# appear in the demo sample. +# - macos-clang : runs `clang -fsyntax-only` on both new iOS native +# sources under the host Xcode SDK -- catches Obj-C +# typos and API misuse before the change ever reaches +# the build cloud. + +on: + workflow_dispatch: {} + pull_request: + branches: [ master ] + paths: + - 'CodenameOne/src/com/codename1/io/oidc/**' + - 'CodenameOne/src/com/codename1/io/Oauth2.java' + - 'CodenameOne/src/com/codename1/io/AccessToken.java' + - 'CodenameOne/src/com/codename1/social/**' + - 'Ports/iOSPort/nativeSources/com_codename1_io_oidc_*' + - 'Ports/iOSPort/nativeSources/com_codename1_social_AppleSignIn*' + - 'Ports/Android/src/com/codename1/io/oidc/**' + - '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/Oauth2*' + - 'maven/core-unittests/src/test/java/com/codename1/social/**' + - 'Samples/samples/UniversalSignInDemo/**' + - 'docs/developer-guide/Authentication-And-Identity.asciidoc' + - '.github/workflows/identity-stack.yml' + push: + branches: [ master ] + paths: + - 'CodenameOne/src/com/codename1/io/oidc/**' + - 'CodenameOne/src/com/codename1/io/Oauth2.java' + - 'CodenameOne/src/com/codename1/social/**' + - 'Ports/iOSPort/nativeSources/com_codename1_io_oidc_*' + - 'Ports/iOSPort/nativeSources/com_codename1_social_AppleSignIn*' + - 'Ports/Android/src/com/codename1/io/oidc/**' + - '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/**' + - 'Samples/samples/UniversalSignInDemo/**' + - '.github/workflows/identity-stack.yml' + +permissions: + contents: read + +concurrency: + group: identity-stack-${{ github.workflow }}-${{ github.ref_name }} + cancel-in-progress: true + +jobs: + linux-tests: + name: Core tests, plugin & Android compile, sample javac + runs-on: ubuntu-latest + # Same container the main PR workflow uses; it ships JDK 8/17/21 and + # cn1-binaries pre-staged at /opt/cn1-binaries. + container: ghcr.io/codenameone/codenameone/pr-ci-container:latest + timeout-minutes: 20 + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@v4 + + - name: Select JDK 8 + run: | + echo "JAVA_HOME=${JAVA_HOME_8}" >> "$GITHUB_ENV" + echo "${JAVA_HOME_8}/bin" >> "$GITHUB_PATH" + + - name: Cache Maven repository + uses: actions/cache@v4 + with: + path: ~/.m2/repository + key: ${{ runner.os }}-m2-identity-${{ hashFiles('**/pom.xml') }} + restore-keys: | + ${{ runner.os }}-m2-identity- + ${{ runner.os }}-m2- + + - name: Stage cn1-binaries (required by maven-plugin tests) + run: | + set -euo pipefail + rm -rf maven/target/cn1-binaries + mkdir -p maven/target + cp -r /opt/cn1-binaries maven/target/cn1-binaries + + - name: Run identity-stack unit tests + working-directory: maven + run: | + set -euo pipefail + # Targeted run: OidcCoreTest is the new suite; the *Connect / Login / + # Oauth2 tests cover backward-compat for the changes in Login.java + # and friends. + mvn -B -Dmaven.javadoc.skip=true \ + -DunitTests=true \ + -Plocal-dev-javase \ + -P unittests \ + -pl core-unittests -am \ + test \ + -Dtest='OidcCoreTest,Oauth2Test,Oauth2RefreshTokenRequestTest,GoogleConnectTest,FacebookConnectTest,LoginTest,Login1Test,LoginExtrasTest' \ + -Dsurefire.failIfNoSpecifiedTests=false + + - name: Compile Maven plugin (verifies IPhoneBuilder + AndroidGradleBuilder scanner edits) + working-directory: maven + run: | + set -euo pipefail + # `-pl codenameone-maven-plugin compile` (no -am) requires the + # plugin's deps to be in the local repo; the unit-test step above + # already installed them via -am. + mvn -B -Dmaven.javadoc.skip=true \ + -pl codenameone-maven-plugin \ + -Plocal-dev-javase \ + compile + + - name: Package Android port (verifies new Java sources bundle correctly) + run: | + set -euo pipefail + (cd maven && mvn -B -Dmaven.javadoc.skip=true \ + -pl android -am \ + -Plocal-dev-javase \ + -DskipTests \ + package) + BUNDLE="maven/android/target/classes/com/codename1/android/android_port_sources.jar" + if [ ! -f "${BUNDLE}" ]; then + echo "::error::android_port_sources.jar not produced at ${BUNDLE}" + exit 1 + fi + for required in \ + com/codename1/io/oidc/OidcBrowserNativeImpl.java \ + com/codename1/social/AppleSignInNativeImpl.java; do + if ! unzip -l "${BUNDLE}" | grep -q "${required}"; then + echo "::error::${required} missing from android_port_sources.jar" + exit 1 + fi + done + + - name: Compile UniversalSignInDemo against built core + run: | + set -euo pipefail + CORE_CLASSES="maven/core/target/classes" + if [ ! -d "${CORE_CLASSES}" ]; then + echo "::error::core not built yet at ${CORE_CLASSES}" + exit 1 + fi + mkdir -p target/sample-check + # JDK 8 is fine for the sample -- it deliberately avoids Java 8+ + # syntax to match the broader Codename One source level. + "${JAVA_HOME}/bin/javac" \ + -source 1.8 -target 1.8 \ + -Xlint:-options \ + -cp "${CORE_CLASSES}" \ + -d target/sample-check \ + Samples/samples/UniversalSignInDemo/UniversalSignInDemo.java + test -f target/sample-check/com/codename1/samples/UniversalSignInDemo.class + + sample-secrets: + name: Scan demo sample for accidental credentials + runs-on: ubuntu-latest + timeout-minutes: 3 + steps: + - uses: actions/checkout@v4 + - name: Reject real-looking secrets in identity sources & demo + run: | + set -euo pipefail + # Patterns: JWTs, Firebase web API keys (AIza...), Google client IDs + # (numeric-prefixed apps.googleusercontent.com), hex blobs >=32 chars, + # private-key headers, GitHub PATs, AWS keys. + PATTERN='eyJ[A-Za-z0-9_-]{20,}|[0-9]{6,}\.apps\.googleusercontent\.com|AIza[0-9A-Za-z_-]{30,}|sk-[A-Za-z0-9]{20,}|[0-9a-f]{32,}|BEGIN (RSA|EC|PRIVATE) KEY|ghp_[A-Za-z0-9]{20,}|AKIA[0-9A-Z]{16}' + TARGETS=( + Samples/samples/UniversalSignInDemo + CodenameOne/src/com/codename1/io/oidc + CodenameOne/src/com/codename1/social/AppleSignIn.java + CodenameOne/src/com/codename1/social/AppleSignInCallback.java + CodenameOne/src/com/codename1/social/AppleSignInNative.java + CodenameOne/src/com/codename1/social/AppleSignInResult.java + CodenameOne/src/com/codename1/social/Auth0Connect.java + CodenameOne/src/com/codename1/social/FirebaseAuth.java + CodenameOne/src/com/codename1/social/MicrosoftConnect.java + ) + # `|| true` is intentional: grep -E exits 1 when there are zero + # matches, which is the success case. + HITS=$(grep -rEn "$PATTERN" "${TARGETS[@]}" 2>/dev/null || true) + if [ -n "$HITS" ]; then + echo "::error::Real-looking credentials found in identity-stack files:" + echo "$HITS" + exit 1 + fi + echo "No credential leaks detected." + + macos-clang: + name: Clang syntax check for iOS native sources + runs-on: macos-15 + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + + - name: Show Xcode toolchain + run: | + xcodebuild -version + xcrun --show-sdk-path --sdk iphoneos + + - name: Clang -fsyntax-only on new iOS native impls + run: | + set -euo pipefail + cd Ports/iOSPort/nativeSources + SDK="$(xcrun --show-sdk-path --sdk iphoneos)" + if [ ! -d "$SDK" ]; then + echo "::error::No iPhoneOS SDK available on this runner" + exit 1 + fi + xcrun --sdk iphoneos clang \ + -fsyntax-only \ + -arch arm64 \ + -fobjc-arc \ + -Werror=incompatible-pointer-types \ + -Werror=objc-method-access \ + -Werror=unused-result \ + -I. \ + com_codename1_io_oidc_OidcBrowserNativeImpl.m \ + com_codename1_social_AppleSignInNativeImpl.m diff --git a/CodenameOne/src/com/codename1/io/Oauth2.java b/CodenameOne/src/com/codename1/io/Oauth2.java index e812e514ec..ba91d4895c 100644 --- a/CodenameOne/src/com/codename1/io/Oauth2.java +++ b/CodenameOne/src/com/codename1/io/Oauth2.java @@ -49,11 +49,26 @@ import java.util.Hashtable; import java.util.Map; -/// This is a utility class that allows Oauth2 authentication This utility uses -/// the Codename One XHTML Component to display the authentication pages. -/// http://tools.ietf.org/pdf/draft-ietf-oauth-v2-12.pdf +/// Legacy OAuth2 authentication helper. **Deprecated as of Codename One 8.0**; +/// new code should use [com.codename1.io.oidc.OidcClient] instead, which: +/// +/// - Drives sign-in via the system browser ([com.codename1.io.oidc.SystemBrowser]) +/// instead of an in-app WebView. Modern identity providers (Google, Apple, +/// Microsoft, Facebook) refuse to render their sign-in pages inside an +/// embedded WebView and will block this class. +/// - Performs PKCE on every authorization-code flow (mandatory now on most +/// providers). +/// - Parses the OpenID Connect discovery document so you do not have to +/// hard-code the authorization / token endpoints. +/// - Verifies the `state` and `nonce` parameters returned by the server. +/// +/// This class is preserved as-is for source compatibility but no new +/// functionality will be added. See the *Authentication and Identity* +/// chapter of the developer guide for a migration recipe. /// /// @author Chen Fishbein +/// @deprecated Use [com.codename1.io.oidc.OidcClient] for new code. +@Deprecated public class Oauth2 { public static final String TOKEN = "access_token"; private static String expires; diff --git a/CodenameOne/src/com/codename1/io/oidc/OidcBrowserNative.java b/CodenameOne/src/com/codename1/io/oidc/OidcBrowserNative.java new file mode 100644 index 0000000000..ac74cd7a60 --- /dev/null +++ b/CodenameOne/src/com/codename1/io/oidc/OidcBrowserNative.java @@ -0,0 +1,62 @@ +/* + * 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.oidc; + +import com.codename1.system.NativeInterface; + +/// Native bridge into the platform's system-browser sign-in primitive +/// (`ASWebAuthenticationSession` on iOS, `androidx.browser.customtabs` / +/// `Credential Manager` on Android). Ports that implement this interface -- +/// or apps that ship a cn1lib doing so -- let [SystemBrowser] dispatch +/// authorization-code flows through the OS's hardened, cookie-isolated +/// sign-in sheet instead of the in-app fallback. +/// +/// `redirectScheme` is the scheme half of the registered redirect URI (e.g. +/// the `"com.example.app"` part of `"com.example.app:/oauth2redirect"`). The +/// native side completes by invoking the JavaScript-facing callback hosted by +/// [SystemBrowser]; see [#startAuthorization(String, String)]. +/// +/// @since 8.0 +public interface OidcBrowserNative extends NativeInterface { + + /// Starts the OS sign-in sheet for `authUrl` and resolves when the user + /// is redirected to a URL matching `redirectScheme`. The return value is + /// the full redirect URL (including query / fragment). + /// + /// Implementations are expected to be asynchronous; they should block the + /// calling thread and post the resolved URL back via a private + /// completion path. The fallback [SystemBrowser] implementation already + /// handles the cross-thread plumbing; native ports just need to deliver + /// the URL on the EDT. + /// + /// #### Parameters + /// + /// - `authUrl`: Full authorization-endpoint URL with `client_id`, + /// `redirect_uri`, `state`, `code_challenge`, etc. already encoded. + /// + /// - `redirectScheme`: The redirect URI scheme registered for the app. + /// On iOS the OS uses this to dismiss `ASWebAuthenticationSession` + /// automatically; on Android it informs the trusted-browser intent. + String startAuthorization(String authUrl, String redirectScheme); +} diff --git a/CodenameOne/src/com/codename1/io/oidc/OidcClient.java b/CodenameOne/src/com/codename1/io/oidc/OidcClient.java new file mode 100644 index 0000000000..39b316f5c2 --- /dev/null +++ b/CodenameOne/src/com/codename1/io/oidc/OidcClient.java @@ -0,0 +1,655 @@ +/* + * 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.oidc; + +import com.codename1.io.ConnectionRequest; +import com.codename1.io.JSONParser; +import com.codename1.io.NetworkManager; +import com.codename1.io.Util; +import com.codename1.security.SecureRandom; +import com.codename1.util.AsyncResource; +import com.codename1.util.Base64; +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.List; +import java.util.Map; + +/// Modern OpenID Connect / OAuth 2.0 client. Built around the +/// authorization-code flow with PKCE (RFC 7636) and the system browser. Use +/// it as the foundation for all new sign-in integrations: +/// +/// ```java +/// OidcClient.discover("https://accounts.google.com").ready(new SuccessCallback() { +/// public void onSucess(OidcClient client) { +/// client.setClientId("YOUR_CLIENT_ID") +/// .setRedirectUri("com.example.app:/oauth2redirect") +/// .setScopes("openid", "email", "profile"); +/// client.authorize().ready(new SuccessCallback() { +/// public void onSucess(OidcTokens tokens) { +/// // use tokens.getAccessToken() / tokens.getIdToken() +/// } +/// }); +/// } +/// }); +/// ``` +/// +/// ### What this gives you that [com.codename1.io.Oauth2] does not +/// +/// - Discovery via `.well-known/openid-configuration` so you only configure +/// the issuer URL, not five separate endpoints +/// - PKCE S256 on every flow (mandatory; many providers now require it) +/// - System-browser sign-in via [SystemBrowser] (the previous class used +/// an in-app WebView that modern IdPs reject) +/// - Refresh-token flow surfaced as a first-class method +/// - ID-token claim decoding via [OidcTokens#getClaim(String)] +/// - Pluggable [TokenStore] persistence +/// - Nonce + state verification on every authorization round-trip +/// +/// ### Things this class deliberately does NOT do +/// +/// - **Verify the ID token signature.** This requires the provider's JWKS +/// and ECDSA/RSA verification, which is not feasible on every supported +/// platform without pulling in a heavy dep. The remedy is: trust the +/// TLS connection to the well-known issuer (i.e. always discover, never +/// pass tokens to a server without re-validating server-side). +/// - **Implicit / hybrid / device flows.** Use the lower-level +/// [com.codename1.io.ConnectionRequest] APIs if you need those. +/// +/// @since 8.0 +public final class OidcClient { + + private final OidcConfiguration configuration; + private String clientId; + private String clientSecret; + private String redirectUri; + private String[] scopes; + private String[] additionalAuthParams = new String[0]; + private String[] additionalTokenParams = new String[0]; + private TokenStore tokenStore = new TokenStore.DefaultStorageTokenStore(); + private String storeKey; + private String responseMode; + private boolean enforceNonce = true; + + private OidcClient(OidcConfiguration configuration) { + this.configuration = configuration; + } + + /// Constructs a client from an already-known [OidcConfiguration]. Use + /// [#discover(String)] when you'd rather pull the endpoints from the + /// provider's `.well-known/openid-configuration` document. + public static OidcClient create(OidcConfiguration configuration) { + if (configuration == null) { + throw new IllegalArgumentException("configuration must not be null"); + } + return new OidcClient(configuration); + } + + /// Fetches `/.well-known/openid-configuration` and resolves with + /// an [OidcClient] pre-populated with the discovered endpoints. The + /// returned client still needs `clientId`, `redirectUri` and `scopes` + /// before [#authorize()] will work. + /// + /// Trailing slashes on `issuer` are tolerated. + public static AsyncResource discover(String issuer) { + if (issuer == null) { + throw new IllegalArgumentException("issuer must not be null"); + } + final AsyncResource out = new AsyncResource(); + String base = issuer; + while (base.endsWith("/")) { + base = base.substring(0, base.length() - 1); + } + final String url = base + "/.well-known/openid-configuration"; + ConnectionRequest req = new ConnectionRequest() { + protected void readResponse(InputStream input) throws IOException { + try { + byte[] body = Util.readInputStream(input); + String json = StringUtil.newString(body); + Map parsed = new JSONParser() + .parseJSON(new StringReader(json)); + if (parsed == null || parsed.isEmpty()) { + out.error(new OidcException(OidcException.DISCOVERY_FAILED, + "Discovery document was empty")); + return; + } + OidcConfiguration cfg = OidcConfiguration.fromDiscoveryJson(parsed); + out.complete(new OidcClient(cfg)); + } catch (Throwable t) { + out.error(new OidcException(OidcException.DISCOVERY_FAILED, + "Failed to parse discovery document: " + t.getMessage(), t)); + } + } + + protected void handleException(Exception err) { + out.error(new OidcException(OidcException.TRANSPORT_ERROR, + "Failed to fetch discovery document at " + url + ": " + + err.getMessage(), err)); + } + }; + req.setUrl(url); + req.setPost(false); + req.setReadResponseForErrors(true); + NetworkManager.getInstance().addToQueue(req); + return out; + } + + public OidcConfiguration getConfiguration() { + return configuration; + } + + public OidcClient setClientId(String clientId) { + this.clientId = clientId; + return this; + } + + public OidcClient setClientSecret(String clientSecret) { + this.clientSecret = clientSecret; + return this; + } + + public OidcClient setRedirectUri(String redirectUri) { + this.redirectUri = redirectUri; + return this; + } + + public OidcClient setScopes(String... scopes) { + if (scopes == null) { + this.scopes = null; + } else { + this.scopes = (String[]) scopes.clone(); + } + return this; + } + + public OidcClient setScopes(List scopes) { + if (scopes == null) { + this.scopes = null; + } else { + this.scopes = scopes.toArray(new String[0]); + } + return this; + } + + /// Extra `name=value` parameters appended to the authorization-endpoint + /// URL. Use for provider-specific options like Google's `prompt=consent` + /// or Apple's `response_mode=form_post`. Values are URL-encoded. + public OidcClient setAuthorizationParameters(String... kv) { + if (kv.length % 2 != 0) { + throw new IllegalArgumentException("Expected key/value pairs"); + } + this.additionalAuthParams = (String[]) kv.clone(); + return this; + } + + /// Extra `name=value` parameters sent as form data on every token-endpoint + /// POST. + public OidcClient setTokenParameters(String... kv) { + if (kv.length % 2 != 0) { + throw new IllegalArgumentException("Expected key/value pairs"); + } + this.additionalTokenParams = (String[]) kv.clone(); + return this; + } + + /// Swaps the token persistence strategy. Defaults to + /// [TokenStore.DefaultStorageTokenStore]. + public OidcClient setTokenStore(TokenStore store) { + this.tokenStore = store == null + ? new TokenStore.DefaultStorageTokenStore() + : store; + return this; + } + + /// Override the key under which tokens are stored. Defaults to the + /// issuer + client-id pair so that multiple clients can coexist. + public OidcClient setStoreKey(String key) { + this.storeKey = key; + return this; + } + + /// `false` skips the `nonce` claim check on the returned ID token. Only + /// disable when you have a very good reason (e.g. provider known not to + /// echo the nonce); the default is to enforce. + public OidcClient setEnforceNonce(boolean enforce) { + this.enforceNonce = enforce; + return this; + } + + /// Sets the `response_mode` parameter sent on the authorization URL + /// (e.g. `"form_post"` for Apple Sign-In with the web fallback). + public OidcClient setResponseMode(String mode) { + this.responseMode = mode; + return this; + } + + /// Launches an authorization-code flow with PKCE. The user is sent to the + /// system browser to sign in; the returned [AsyncResource] completes with + /// the token set or errors with [OidcException] (e.g. `USER_CANCELLED`, + /// `STATE_MISMATCH`). + public AsyncResource authorize() { + requireConfigured(); + final AsyncResource out = new AsyncResource(); + final PkceChallenge pkce = PkceChallenge.generate(); + final String state = randomToken(16); + final String nonce = randomToken(16); + String authUrl = buildAuthorizationUrl(state, nonce, pkce); + SystemBrowser.authenticate(authUrl, redirectUri) + .ready(new SuccessCallback() { + public void onSucess(String redirectUrl) { + handleRedirect(redirectUrl, state, nonce, pkce, out); + } + }) + .except(new SuccessCallback() { + public void onSucess(Throwable err) { + out.error(err); + } + }); + return out; + } + + /// Exchanges a stored refresh token for a fresh access token. Pass the + /// value returned from [OidcTokens#getRefreshToken()] on a previous flow. + /// The new tokens are persisted via the current [TokenStore]. + public AsyncResource refresh(final String refreshToken) { + requireConfigured(); + if (refreshToken == null) { + throw new IllegalArgumentException("refreshToken must not be null"); + } + if (configuration.getTokenEndpoint() == null) { + throw new IllegalStateException("OIDC configuration is missing tokenEndpoint"); + } + final AsyncResource out = new AsyncResource(); + Map args = new HashMap(); + args.put("grant_type", "refresh_token"); + args.put("refresh_token", refreshToken); + if (scopes != null && scopes.length > 0) { + args.put("scope", join(scopes)); + } + appendBaseTokenArgs(args); + postToTokenEndpoint(args, refreshToken, null, out); + return out; + } + + /// Returns previously-saved tokens for this client (or `null`). Combine + /// with [#refreshIfExpired(int)] to silently bring the session back to + /// life on app launch. + public AsyncResource loadStoredTokens() { + return tokenStore.load(storageKey()); + } + + /// Loads stored tokens; if they are within `leewaySeconds` of expiring, + /// runs a refresh and saves the new tokens. Completes with `null` when + /// nothing is stored or when the stored token has no refresh token and + /// has already expired. + public AsyncResource refreshIfExpired(final int leewaySeconds) { + final AsyncResource out = new AsyncResource(); + loadStoredTokens() + .ready(new SuccessCallback() { + public void onSucess(OidcTokens stored) { + if (stored == null) { + out.complete(null); + return; + } + if (!stored.isExpiringWithin(leewaySeconds)) { + out.complete(stored); + return; + } + String rt = stored.getRefreshToken(); + if (rt == null) { + out.complete(null); + return; + } + refresh(rt) + .ready(new SuccessCallback() { + public void onSucess(OidcTokens fresh) { + out.complete(fresh); + } + }) + .except(new SuccessCallback() { + public void onSucess(Throwable err) { + out.error(err); + } + }); + } + }) + .except(new SuccessCallback() { + public void onSucess(Throwable err) { + out.error(err); + } + }); + return out; + } + + /// Sends a token-revocation request to the issuer (RFC 7009). Silently + /// no-ops when the issuer does not advertise a `revocation_endpoint`. + public AsyncResource revoke(final String token) { + final AsyncResource out = new AsyncResource(); + if (token == null || configuration.getRevocationEndpoint() == null) { + out.complete(Boolean.FALSE); + return out; + } + ConnectionRequest req = new ConnectionRequest() { + protected void readResponse(InputStream input) throws IOException { + Util.readInputStream(input); + out.complete(Boolean.TRUE); + } + + protected void handleException(Exception err) { + out.error(new OidcException(OidcException.TRANSPORT_ERROR, + "Token revocation failed: " + err.getMessage(), err)); + } + }; + req.setUrl(configuration.getRevocationEndpoint()); + req.setPost(true); + req.setReadResponseForErrors(true); + req.addRequestHeader("Content-Type", "application/x-www-form-urlencoded"); + req.addArgument("token", token); + req.addArgument("client_id", clientId); + if (clientSecret != null) { + req.addArgument("client_secret", clientSecret); + } + NetworkManager.getInstance().addToQueue(req); + return out; + } + + /// Clears any stored tokens for this client. Does not call the issuer's + /// revocation endpoint -- combine with [#revoke(String)] if you want a + /// proper sign-out. + public AsyncResource clearStoredTokens() { + return tokenStore.clear(storageKey()); + } + + // ----------------------------------------------------------- + // internals + + private void requireConfigured() { + if (clientId == null) { + throw new IllegalStateException("clientId is required"); + } + if (redirectUri == null) { + throw new IllegalStateException("redirectUri is required"); + } + if (configuration.getAuthorizationEndpoint() == null) { + throw new IllegalStateException("authorizationEndpoint missing from configuration"); + } + } + + private String storageKey() { + if (storeKey != null) { + return storeKey; + } + String issuer = configuration.getIssuer(); + if (issuer == null) { + issuer = configuration.getAuthorizationEndpoint(); + } + return issuer + "|" + clientId; + } + + private String buildAuthorizationUrl(String state, String nonce, PkceChallenge pkce) { + StringBuilder b = new StringBuilder(configuration.getAuthorizationEndpoint()); + b.append(configuration.getAuthorizationEndpoint().indexOf('?') >= 0 ? '&' : '?'); + appendParam(b, "response_type", "code"); + appendParam(b, "client_id", clientId); + appendParam(b, "redirect_uri", redirectUri); + if (scopes != null && scopes.length > 0) { + appendParam(b, "scope", join(scopes)); + } + appendParam(b, "state", state); + if (enforceNonce) { + appendParam(b, "nonce", nonce); + } + appendParam(b, "code_challenge", pkce.getChallenge()); + appendParam(b, "code_challenge_method", pkce.getMethod()); + if (responseMode != null) { + appendParam(b, "response_mode", responseMode); + } + for (int i = 0; i + 1 < additionalAuthParams.length; i += 2) { + appendParam(b, additionalAuthParams[i], additionalAuthParams[i + 1]); + } + return b.toString(); + } + + private static void appendParam(StringBuilder b, String k, String v) { + char last = b.charAt(b.length() - 1); + if (last != '?' && last != '&') { + b.append('&'); + } + b.append(Util.encodeUrl(k)).append('=').append(Util.encodeUrl(v)); + } + + private void handleRedirect(String redirectUrl, + String expectedState, + String expectedNonce, + PkceChallenge pkce, + final AsyncResource out) { + Map params = parseRedirectParams(redirectUrl); + String error = params.get("error"); + if (error != null) { + String description = params.get("error_description"); + String code = error.equals("access_denied") ? OidcException.ACCESS_DENIED : error; + out.error(new OidcException(code, + description != null ? description : error)); + return; + } + String returnedState = params.get("state"); + if (returnedState == null || !returnedState.equals(expectedState)) { + out.error(new OidcException(OidcException.STATE_MISMATCH, + "Authorization server returned a different 'state' than the one we sent")); + return; + } + String code = params.get("code"); + if (code == null) { + out.error(new OidcException(OidcException.INVALID_GRANT, + "Authorization redirect was missing the 'code' parameter")); + return; + } + exchangeCode(code, expectedNonce, pkce, out); + } + + private void exchangeCode(String code, + final String expectedNonce, + PkceChallenge pkce, + final AsyncResource out) { + if (configuration.getTokenEndpoint() == null) { + out.error(new OidcException(OidcException.INVALID_GRANT, + "OIDC configuration is missing tokenEndpoint")); + return; + } + Map args = new HashMap(); + args.put("grant_type", "authorization_code"); + args.put("code", code); + args.put("redirect_uri", redirectUri); + args.put("code_verifier", pkce.getVerifier()); + appendBaseTokenArgs(args); + postToTokenEndpoint(args, null, expectedNonce, out); + } + + private void appendBaseTokenArgs(Map args) { + args.put("client_id", clientId); + if (clientSecret != null) { + args.put("client_secret", clientSecret); + } + for (int i = 0; i + 1 < additionalTokenParams.length; i += 2) { + args.put(additionalTokenParams[i], additionalTokenParams[i + 1]); + } + } + + private void postToTokenEndpoint(final Map args, + final String refreshTokenFallback, + final String expectedNonce, + final AsyncResource out) { + final boolean[] completed = new boolean[1]; + ConnectionRequest req = new ConnectionRequest() { + protected void readResponse(InputStream input) throws IOException { + if (completed[0]) return; + byte[] body = Util.readInputStream(input); + String json = StringUtil.newString(body); + Map parsed; + try { + parsed = new JSONParser().parseJSON(new StringReader(json)); + } catch (Exception e) { + completed[0] = true; + out.error(new OidcException(OidcException.INVALID_GRANT, + "Token endpoint returned malformed JSON: " + json, e)); + return; + } + if (parsed == null) { + completed[0] = true; + out.error(new OidcException(OidcException.INVALID_GRANT, + "Token endpoint returned no body")); + return; + } + if (parsed.get("error") != null) { + completed[0] = true; + Object desc = parsed.get("error_description"); + out.error(new OidcException(parsed.get("error").toString(), + desc != null ? desc.toString() : null)); + return; + } + final OidcTokens tokens = OidcTokens.fromTokenResponse(parsed, refreshTokenFallback); + if (enforceNonce && expectedNonce != null && tokens.getIdToken() != null) { + Object nonceClaim = tokens.getClaim("nonce"); + if (nonceClaim != null && !expectedNonce.equals(nonceClaim.toString())) { + completed[0] = true; + out.error(new OidcException(OidcException.NONCE_MISMATCH, + "ID token nonce did not match")); + return; + } + } + tokenStore.save(storageKey(), tokens) + .except(new SuccessCallback() { + public void onSucess(Throwable t) { + // Token persistence failure is non-fatal; tokens are still valid in-memory. + } + }); + completed[0] = true; + out.complete(tokens); + } + + protected void handleException(Exception err) { + if (completed[0]) return; + completed[0] = true; + out.error(new OidcException(OidcException.TRANSPORT_ERROR, + "Token endpoint request failed: " + err.getMessage(), err)); + } + }; + req.setUrl(configuration.getTokenEndpoint()); + req.setPost(true); + req.setReadResponseForErrors(true); + req.addRequestHeader("Content-Type", "application/x-www-form-urlencoded"); + req.addRequestHeader("Accept", "application/json"); + for (Map.Entry e : args.entrySet()) { + req.addArgument(e.getKey(), e.getValue()); + } + NetworkManager.getInstance().addToQueue(req); + } + + private static Map parseRedirectParams(String url) { + Map out = new HashMap(); + int qm = url.indexOf('?'); + int hash = url.indexOf('#'); + String tail = null; + if (qm >= 0) { + tail = url.substring(qm + 1); + int h2 = tail.indexOf('#'); + if (h2 >= 0) { + String fragment = tail.substring(h2 + 1); + tail = tail.substring(0, h2); + merge(out, fragment); + } + } else if (hash >= 0) { + tail = url.substring(hash + 1); + } + if (tail != null) { + merge(out, tail); + } + return out; + } + + private static void merge(Map out, String query) { + String[] pairs = Util.split(query, "&"); + for (int i = 0; i < pairs.length; i++) { + String p = pairs[i]; + int eq = p.indexOf('='); + if (eq < 0) continue; + String k = decode(p.substring(0, eq)); + String v = decode(p.substring(eq + 1)); + out.put(k, v); + } + } + + private static String decode(String s) { + StringBuilder b = new StringBuilder(s.length()); + int i = 0; + int len = s.length(); + while (i < len) { + char c = s.charAt(i); + if (c == '+') { + b.append(' '); + i++; + } else if (c == '%' && i + 2 < len) { + int hi = Character.digit(s.charAt(i + 1), 16); + int lo = Character.digit(s.charAt(i + 2), 16); + if (hi >= 0 && lo >= 0) { + b.append((char) ((hi << 4) | lo)); + i += 3; + } else { + b.append(c); + i++; + } + } else { + b.append(c); + i++; + } + } + return b.toString(); + } + + private static String join(String[] items) { + StringBuilder b = new StringBuilder(); + for (int i = 0; i < items.length; i++) { + if (i > 0) b.append(' '); + b.append(items[i]); + } + return b.toString(); + } + + private static String randomToken(int byteLength) { + byte[] bytes = SecureRandom.bytes(byteLength); + String s = Base64.encodeUrlSafe(bytes); + StringBuilder b = new StringBuilder(s.length()); + int len = s.length(); + for (int i = 0; i < len; i++) { + char c = s.charAt(i); + if (c == '=' || c == '\n' || c == '\r') continue; + b.append(c); + } + return b.toString(); + } +} diff --git a/CodenameOne/src/com/codename1/io/oidc/OidcConfiguration.java b/CodenameOne/src/com/codename1/io/oidc/OidcConfiguration.java new file mode 100644 index 0000000000..44d20f6a5a --- /dev/null +++ b/CodenameOne/src/com/codename1/io/oidc/OidcConfiguration.java @@ -0,0 +1,175 @@ +/* + * 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.oidc; + +import java.util.Map; + +/// The subset of an OpenID Connect provider's `.well-known/openid-configuration` +/// document that [OidcClient] cares about. Construct directly when you already +/// know the endpoints, or obtain via [OidcClient#discover(String)] which fetches +/// and parses the document. +/// +/// All fields are immutable after construction. Use [#newBuilder()] to start +/// from a blank slate; use [#newBuilder(OidcConfiguration)] to derive one from +/// an existing instance. +/// +/// @since 8.0 +public final class OidcConfiguration { + + private final String issuer; + private final String authorizationEndpoint; + private final String tokenEndpoint; + private final String userInfoEndpoint; + private final String revocationEndpoint; + private final String endSessionEndpoint; + private final String jwksUri; + + private OidcConfiguration(Builder b) { + this.issuer = b.issuer; + this.authorizationEndpoint = b.authorizationEndpoint; + this.tokenEndpoint = b.tokenEndpoint; + this.userInfoEndpoint = b.userInfoEndpoint; + this.revocationEndpoint = b.revocationEndpoint; + this.endSessionEndpoint = b.endSessionEndpoint; + this.jwksUri = b.jwksUri; + } + + /// Builds an [OidcConfiguration] from a parsed discovery JSON document. + /// Only the fields this client needs are extracted; anything else is ignored. + public static OidcConfiguration fromDiscoveryJson(Map json) { + if (json == null) { + throw new IllegalArgumentException("json must not be null"); + } + Builder b = new Builder(); + b.issuer = stringOrNull(json.get("issuer")); + b.authorizationEndpoint = stringOrNull(json.get("authorization_endpoint")); + b.tokenEndpoint = stringOrNull(json.get("token_endpoint")); + b.userInfoEndpoint = stringOrNull(json.get("userinfo_endpoint")); + b.revocationEndpoint = stringOrNull(json.get("revocation_endpoint")); + b.endSessionEndpoint = stringOrNull(json.get("end_session_endpoint")); + b.jwksUri = stringOrNull(json.get("jwks_uri")); + return b.build(); + } + + public String getIssuer() { + return issuer; + } + + public String getAuthorizationEndpoint() { + return authorizationEndpoint; + } + + public String getTokenEndpoint() { + return tokenEndpoint; + } + + public String getUserInfoEndpoint() { + return userInfoEndpoint; + } + + public String getRevocationEndpoint() { + return revocationEndpoint; + } + + public String getEndSessionEndpoint() { + return endSessionEndpoint; + } + + public String getJwksUri() { + return jwksUri; + } + + public static Builder newBuilder() { + return new Builder(); + } + + public static Builder newBuilder(OidcConfiguration source) { + Builder b = new Builder(); + b.issuer = source.issuer; + b.authorizationEndpoint = source.authorizationEndpoint; + b.tokenEndpoint = source.tokenEndpoint; + b.userInfoEndpoint = source.userInfoEndpoint; + b.revocationEndpoint = source.revocationEndpoint; + b.endSessionEndpoint = source.endSessionEndpoint; + b.jwksUri = source.jwksUri; + return b; + } + + private static String stringOrNull(Object o) { + return o instanceof String ? (String) o : null; + } + + /// Fluent builder for [OidcConfiguration]. + public static final class Builder { + private String issuer; + private String authorizationEndpoint; + private String tokenEndpoint; + private String userInfoEndpoint; + private String revocationEndpoint; + private String endSessionEndpoint; + private String jwksUri; + + public Builder issuer(String v) { + this.issuer = v; + return this; + } + + public Builder authorizationEndpoint(String v) { + this.authorizationEndpoint = v; + return this; + } + + public Builder tokenEndpoint(String v) { + this.tokenEndpoint = v; + return this; + } + + public Builder userInfoEndpoint(String v) { + this.userInfoEndpoint = v; + return this; + } + + public Builder revocationEndpoint(String v) { + this.revocationEndpoint = v; + return this; + } + + public Builder endSessionEndpoint(String v) { + this.endSessionEndpoint = v; + return this; + } + + public Builder jwksUri(String v) { + this.jwksUri = v; + return this; + } + + public OidcConfiguration build() { + if (authorizationEndpoint == null) { + throw new IllegalStateException("authorizationEndpoint is required"); + } + return new OidcConfiguration(this); + } + } +} diff --git a/CodenameOne/src/com/codename1/io/oidc/OidcException.java b/CodenameOne/src/com/codename1/io/oidc/OidcException.java new file mode 100644 index 0000000000..7cebab6936 --- /dev/null +++ b/CodenameOne/src/com/codename1/io/oidc/OidcException.java @@ -0,0 +1,91 @@ +/* + * 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.oidc; + +import java.io.IOException; + +/// Thrown for failures during an OpenID Connect / OAuth 2.0 flow driven by +/// [OidcClient]. The [#getError()] code mirrors the `error` field from RFC 6749 +/// for authorization-server responses (e.g. `"access_denied"`, `"invalid_grant"`) +/// and uses Codename One-specific values for transport or client-side problems +/// (`"transport_error"`, `"state_mismatch"`, `"nonce_mismatch"`, `"user_cancelled"`, +/// `"discovery_failed"`, `"invalid_id_token"`). +/// +/// @since 8.0 +public class OidcException extends IOException { + + /// Authorization server returned `error=access_denied`. + public static final String ACCESS_DENIED = "access_denied"; + + /// User cancelled the system browser / native sign-in sheet. + public static final String USER_CANCELLED = "user_cancelled"; + + /// `state` returned by the authorization server did not match the one we sent. + public static final String STATE_MISMATCH = "state_mismatch"; + + /// `nonce` claim on the returned ID token did not match the one we sent. + public static final String NONCE_MISMATCH = "nonce_mismatch"; + + /// The discovery document could not be fetched or parsed. + public static final String DISCOVERY_FAILED = "discovery_failed"; + + /// Token-endpoint response was missing or malformed. + public static final String INVALID_GRANT = "invalid_grant"; + + /// ID token failed structural validation (we do not currently verify the + /// signature -- treat the issuer as a trust anchor and use TLS to the + /// discovery URL). + public static final String INVALID_ID_TOKEN = "invalid_id_token"; + + /// Generic transport / network failure. + public static final String TRANSPORT_ERROR = "transport_error"; + + private final String error; + private final String errorDescription; + + public OidcException(String error, String message) { + super(message != null ? message : error); + this.error = error; + this.errorDescription = message; + } + + public OidcException(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 the constants on this class). + public String getError() { + return error; + } + + /// Human-readable description supplied by the server or the client. + public String getErrorDescription() { + return errorDescription; + } +} diff --git a/CodenameOne/src/com/codename1/io/oidc/OidcTokens.java b/CodenameOne/src/com/codename1/io/oidc/OidcTokens.java new file mode 100644 index 0000000000..b84834082b --- /dev/null +++ b/CodenameOne/src/com/codename1/io/oidc/OidcTokens.java @@ -0,0 +1,239 @@ +/* + * 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.oidc; + +import com.codename1.io.AccessToken; +import com.codename1.io.JSONParser; +import com.codename1.util.Base64; +import com.codename1.util.regex.StringReader; + +import java.util.Collections; +import java.util.Date; +import java.util.HashMap; +import java.util.Map; + +/// The tokens returned by an OpenID Connect token endpoint, with convenience +/// accessors for the OIDC ID token claims. Immutable. +/// +/// To bridge into the older [AccessToken] API used by [com.codename1.social.Login], +/// call [#toAccessToken()]. +/// +/// @since 8.0 +public final class OidcTokens { + + private final String accessToken; + private final String idToken; + private final String refreshToken; + private final String tokenType; + private final String scope; + private final Date expiresAt; + private final Map idTokenClaims; + private final Map raw; + + OidcTokens(String accessToken, + String idToken, + String refreshToken, + String tokenType, + String scope, + Date expiresAt, + Map idTokenClaims, + Map raw) { + this.accessToken = accessToken; + this.idToken = idToken; + this.refreshToken = refreshToken; + this.tokenType = tokenType; + this.scope = scope; + this.expiresAt = expiresAt; + this.idTokenClaims = idTokenClaims == null + ? Collections.emptyMap() + : Collections.unmodifiableMap(new HashMap(idTokenClaims)); + this.raw = raw == null + ? Collections.emptyMap() + : Collections.unmodifiableMap(new HashMap(raw)); + } + + /// Builds an [OidcTokens] from a parsed JSON token-endpoint response, + /// optionally merging in a refresh token from a previous response (token + /// endpoints are allowed to omit `refresh_token` on a refresh call). + public static OidcTokens fromTokenResponse(Map json, + String refreshTokenFallback) { + if (json == null) { + throw new IllegalArgumentException("json must not be null"); + } + String accessToken = stringOrNull(json.get("access_token")); + String idToken = stringOrNull(json.get("id_token")); + String refreshToken = stringOrNull(json.get("refresh_token")); + if (refreshToken == null) { + refreshToken = refreshTokenFallback; + } + String tokenType = stringOrNull(json.get("token_type")); + String scope = stringOrNull(json.get("scope")); + Date expiresAt = null; + Object expiresIn = json.get("expires_in"); + if (expiresIn != null) { + try { + String raw = expiresIn.toString().trim(); + int dot = raw.indexOf('.'); + if (dot >= 0) { + raw = raw.substring(0, dot); + } + long seconds = Long.parseLong(raw); + expiresAt = new Date(System.currentTimeMillis() + seconds * 1000L); + } catch (NumberFormatException nfe) { + // ignore -- expiresAt stays null + } + } + Map claims = idToken != null ? decodeIdTokenClaims(idToken) : null; + return new OidcTokens(accessToken, idToken, refreshToken, tokenType, scope, + expiresAt, claims, json); + } + + /// Decodes the payload of a compact JWS without verifying the signature. + /// Suitable for reading OIDC ID-token claims; do NOT use the returned + /// values for authorization decisions on the server. + public static Map decodeIdTokenClaims(String compactJwt) { + if (compactJwt == null) { + return Collections.emptyMap(); + } + int firstDot = compactJwt.indexOf('.'); + int secondDot = firstDot >= 0 ? compactJwt.indexOf('.', firstDot + 1) : -1; + if (firstDot < 0 || secondDot < 0) { + return Collections.emptyMap(); + } + String payloadB64 = compactJwt.substring(firstDot + 1, secondDot); + // Pad to a multiple of 4 for the decoder. + while ((payloadB64.length() & 0x3) != 0) { + payloadB64 = payloadB64 + "="; + } + byte[] payload; + try { + payload = Base64.decodeUrlSafe(payloadB64); + } catch (RuntimeException re) { + return Collections.emptyMap(); + } + if (payload == null) { + return Collections.emptyMap(); + } + try { + String json = new String(payload, "UTF-8"); + Map parsed = new JSONParser().parseJSON(new StringReader(json)); + return parsed != null ? parsed : Collections.emptyMap(); + } catch (Exception e) { + return Collections.emptyMap(); + } + } + + public String getAccessToken() { + return accessToken; + } + + public String getIdToken() { + return idToken; + } + + public String getRefreshToken() { + return refreshToken; + } + + public String getTokenType() { + return tokenType; + } + + public String getScope() { + return scope; + } + + /// Absolute expiry instant, or `null` if the token endpoint did not + /// return `expires_in`. + public Date getExpiresAt() { + return expiresAt; + } + + /// `true` if [#getExpiresAt()] is non-null and in the past. + public boolean isExpired() { + return expiresAt != null && expiresAt.getTime() < System.currentTimeMillis(); + } + + /// `true` if [#getExpiresAt()] is non-null and within `leewaySeconds` of + /// the current time. Pass a small leeway (60 -- 120 seconds) when deciding + /// whether to refresh proactively. + public boolean isExpiringWithin(int leewaySeconds) { + return expiresAt != null && + expiresAt.getTime() - System.currentTimeMillis() < leewaySeconds * 1000L; + } + + /// Read-only view of the ID token claims (empty if no ID token was returned). + public Map getIdTokenClaims() { + return idTokenClaims; + } + + /// Convenience accessor for a single ID-token claim. Returns `null` when + /// the claim is absent or the ID token is missing. + public Object getClaim(String name) { + return idTokenClaims.get(name); + } + + /// Convenience accessor for a string-valued claim. + public String getStringClaim(String name) { + Object v = idTokenClaims.get(name); + return v == null ? null : v.toString(); + } + + /// The full, unmodified token-endpoint JSON. Useful for inspecting + /// provider-specific fields (e.g. `nonce_supported` from Apple). + public Map getRawResponse() { + return raw; + } + + /// `sub` claim from the ID token -- the stable, opaque user identifier + /// within the issuer. + public String getSubject() { + return getStringClaim("sub"); + } + + /// `email` claim from the ID token, when present. + public String getEmail() { + return getStringClaim("email"); + } + + /// `name` claim from the ID token, when present. + public String getName() { + return getStringClaim("name"); + } + + /// Bridges into the legacy [AccessToken] API used by + /// [com.codename1.social.Login]. The expiry is the absolute instant from + /// [#getExpiresAt()]. + public AccessToken toAccessToken() { + AccessToken t = new AccessToken(accessToken, null, refreshToken, idToken); + if (expiresAt != null) { + t.setExpiryDate(expiresAt); + } + return t; + } + + private static String stringOrNull(Object o) { + return o instanceof String ? (String) o : null; + } +} diff --git a/CodenameOne/src/com/codename1/io/oidc/PkceChallenge.java b/CodenameOne/src/com/codename1/io/oidc/PkceChallenge.java new file mode 100644 index 0000000000..e6cfbc3c90 --- /dev/null +++ b/CodenameOne/src/com/codename1/io/oidc/PkceChallenge.java @@ -0,0 +1,104 @@ +/* + * 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.oidc; + +import com.codename1.security.Hash; +import com.codename1.security.SecureRandom; +import com.codename1.util.Base64; + +/// One PKCE pair (RFC 7636). The `code_verifier` is kept by the client; the +/// `code_challenge` (always `S256` here) is sent to the authorization endpoint; +/// the verifier is then presented to the token endpoint to prove possession. +/// +/// PKCE is mandatory on every authorization-code flow this client initiates, +/// even when a `client_secret` is configured -- providers like Google and +/// Microsoft both require it for mobile public clients and tolerate it for +/// confidential clients. +/// +/// @since 8.0 +public final class PkceChallenge { + + /// Always `"S256"` -- the only value [OidcClient] emits. RFC 7636 also + /// defines `"plain"` but it is forbidden by this client. + public static final String METHOD_S256 = "S256"; + + private final String verifier; + private final String challenge; + + private PkceChallenge(String verifier, String challenge) { + this.verifier = verifier; + this.challenge = challenge; + } + + /// Generates a fresh PKCE pair with a 64-byte (~86 char) verifier. The + /// verifier characters are drawn from the unreserved set + /// `[A-Z][a-z][0-9]-._~` via base64url encoding of secure random bytes, + /// per RFC 7636 section 4.1. + public static PkceChallenge generate() { + byte[] random = SecureRandom.bytes(64); + String verifier = Base64.encodeUrlSafe(random); + verifier = strip(verifier); + byte[] digest; + try { + digest = Hash.sha256(verifier.getBytes("UTF-8")); + } catch (java.io.UnsupportedEncodingException uee) { + // UTF-8 is guaranteed on every JVM; fall back defensively. + digest = Hash.sha256(verifier.getBytes()); + } + String challenge = strip(Base64.encodeUrlSafe(digest)); + return new PkceChallenge(verifier, challenge); + } + + /// The verifier that must be supplied to the token endpoint as + /// `code_verifier`. + public String getVerifier() { + return verifier; + } + + /// The challenge to include on the authorization URL as `code_challenge`. + public String getChallenge() { + return challenge; + } + + /// Always returns [#METHOD_S256]. + public String getMethod() { + return METHOD_S256; + } + + /// Strip trailing `=` padding and any embedded newlines that older + /// base64 encoders insert. Doing it here keeps the rest of the client + /// portable across the standard and url-safe encoders. + private static String strip(String s) { + int len = s.length(); + StringBuilder b = new StringBuilder(len); + for (int i = 0; i < len; i++) { + char c = s.charAt(i); + if (c == '\n' || c == '\r' || c == '=') { + continue; + } + b.append(c); + } + return b.toString(); + } +} diff --git a/CodenameOne/src/com/codename1/io/oidc/SystemBrowser.java b/CodenameOne/src/com/codename1/io/oidc/SystemBrowser.java new file mode 100644 index 0000000000..f60086c05d --- /dev/null +++ b/CodenameOne/src/com/codename1/io/oidc/SystemBrowser.java @@ -0,0 +1,208 @@ +/* + * 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.oidc; + +import com.codename1.system.NativeLookup; +import com.codename1.ui.BrowserWindow; +import com.codename1.ui.CN; +import com.codename1.ui.events.ActionEvent; +import com.codename1.ui.events.ActionListener; +import com.codename1.util.AsyncResource; + +/// Routes an authorization-code-flow sign-in through the *system browser* +/// (`ASWebAuthenticationSession` on iOS, an Android Custom Tab on Android, +/// the user's default browser on JavaSE / Web) and resolves with the final +/// redirect URL once the OS hands it back. Replaces the embedded WebView +/// approach used by the legacy [com.codename1.io.Oauth2] class. +/// +/// You normally do not call this directly -- [OidcClient.authorize] does it +/// for you. Use the public methods on this class when wiring up a custom +/// OAuth 2.0 flow that does not fit the OIDC client (e.g. device flow). +/// +/// ### Why the system browser? +/// +/// Modern identity providers (Google Identity Services, Apple, Microsoft +/// Entra ID, Auth0, Firebase Auth) refuse to render their sign-in pages +/// inside an embedded WebView -- it's flagged as a phishing surface and +/// blocked. Using the OS-provided sheet gives the user a trusted UI, +/// preserves cookies for single sign-on, and integrates with password and +/// passkey autofill. +/// +/// @since 8.0 +public final class SystemBrowser { + + private static volatile OidcBrowserNative cachedNative; + private static volatile boolean nativeProbed; + + private SystemBrowser() {} + + /// `true` when a native, OS-level implementation is available on the + /// current platform. When `false` the [#authenticate(String, String)] + /// call falls back to an in-app [BrowserWindow]. Call this if you want + /// to surface a clear UX warning to the user. + public static boolean isNativeAvailable() { + OidcBrowserNative n = lookupNative(); + return n != null && n.isSupported(); + } + + /// Launches the system browser at `authorizationUrl` and resolves with + /// the redirect URL once the user is bounced to a location starting with + /// `redirectUri`. + /// + /// #### Parameters + /// + /// - `authorizationUrl`: Fully-built authorization-endpoint URL. + /// + /// - `redirectUri`: Redirect URI registered with the authorization + /// server. Both custom-scheme URIs (`com.example:/oauth2redirect`) + /// and HTTPS URIs are accepted; the latter are recommended on + /// Android 11+ where custom schemes can be hijacked. + /// + /// #### Returns + /// + /// An [AsyncResource] that completes with the redirect URL (including + /// query / fragment) or errors with [OidcException] on cancellation / + /// failure. + public static AsyncResource authenticate(String authorizationUrl, + String redirectUri) { + if (authorizationUrl == null) { + throw new IllegalArgumentException("authorizationUrl must not be null"); + } + if (redirectUri == null) { + throw new IllegalArgumentException("redirectUri must not be null"); + } + final AsyncResource out = new AsyncResource(); + OidcBrowserNative native_ = lookupNative(); + if (native_ != null && native_.isSupported()) { + authenticateNative(native_, authorizationUrl, redirectUri, out); + } else { + authenticateBrowserWindow(authorizationUrl, redirectUri, out); + } + return out; + } + + private static void authenticateNative(final OidcBrowserNative native_, + final String authUrl, + final String redirectUri, + final AsyncResource out) { + // Native calls usually need to happen off the EDT so the OS sheet can + // present and the JVM can pump events. CN.scheduleBackgroundTask runs + // on a pool thread. + final String scheme = schemeOf(redirectUri); + Runnable task = new Runnable() { + public void run() { + try { + String result = native_.startAuthorization(authUrl, scheme); + if (result == null) { + out.error(new OidcException(OidcException.USER_CANCELLED, + "Sign-in sheet was dismissed before completion")); + return; + } + out.complete(result); + } catch (Throwable t) { + out.error(new OidcException(OidcException.TRANSPORT_ERROR, + "Native sign-in sheet failed: " + t.getMessage(), t)); + } + } + }; + // Schedule on a background thread so we don't deadlock the EDT. + new Thread(task, "OidcSystemBrowser").start(); + } + + private static void authenticateBrowserWindow(final String authUrl, + final String redirectUri, + final AsyncResource out) { + Runnable show = new Runnable() { + public void run() { + final BrowserWindow window = new BrowserWindow(authUrl); + window.setTitle("Sign in"); + final boolean[] resolved = new boolean[1]; + final ActionListener loadListener = new ActionListener() { + public void actionPerformed(ActionEvent evt) { + Object src = evt.getSource(); + if (!(src instanceof String)) { + return; + } + String url = (String) src; + if (url == null || !url.startsWith(redirectUri)) { + return; + } + if (resolved[0]) { + return; + } + resolved[0] = true; + window.close(); + out.complete(url); + } + }; + window.addLoadListener(loadListener); + window.addCloseListener(new ActionListener() { + public void actionPerformed(ActionEvent ev) { + if (!resolved[0]) { + resolved[0] = true; + out.error(new OidcException(OidcException.USER_CANCELLED, + "Sign-in window was closed before completion")); + } + } + }); + window.show(); + } + }; + if (CN.isEdt()) { + show.run(); + } else { + CN.callSerially(show); + } + } + + private static OidcBrowserNative lookupNative() { + if (nativeProbed) { + return cachedNative; + } + synchronized (SystemBrowser.class) { + if (nativeProbed) { + return cachedNative; + } + try { + cachedNative = NativeLookup.create(OidcBrowserNative.class); + } catch (Throwable t) { + cachedNative = null; + } + nativeProbed = true; + return cachedNative; + } + } + + /// Extracts the scheme of a redirect URI. For `"com.example.app:/oauth2"` + /// this returns `"com.example.app"`; for `"https://example.com/cb"` it + /// returns `"https"`. Used by native back-ends that need the scheme half + /// only. + static String schemeOf(String redirectUri) { + int colon = redirectUri.indexOf(':'); + if (colon < 0) { + return redirectUri; + } + return redirectUri.substring(0, colon); + } +} diff --git a/CodenameOne/src/com/codename1/io/oidc/TokenStore.java b/CodenameOne/src/com/codename1/io/oidc/TokenStore.java new file mode 100644 index 0000000000..186c1026cf --- /dev/null +++ b/CodenameOne/src/com/codename1/io/oidc/TokenStore.java @@ -0,0 +1,209 @@ +/* + * 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.oidc; + +import com.codename1.io.JSONParser; +import com.codename1.io.Storage; +import com.codename1.util.AsyncResource; +import com.codename1.util.regex.StringReader; + +import java.util.Date; +import java.util.HashMap; +import java.util.Map; + +/// Pluggable persistence for an [OidcClient]'s tokens. Implement this and pass +/// to [OidcClient#setTokenStore(TokenStore)] when you want a custom strategy +/// (e.g. cross-device sync, encrypted-at-rest with your own key, in-memory +/// only). The default is [DefaultStorageTokenStore], which serialises tokens +/// to the standard [Storage] under a per-issuer key. For biometric-gated +/// persistence on iOS / Android, use [SecureStorageTokenStore]. +/// +/// All methods are asynchronous and may run network or biometric prompts on +/// the calling thread. +/// +/// @since 8.0 +public interface TokenStore { + + /// Reads previously-saved tokens for `key`, or completes with `null` if + /// nothing is stored. + AsyncResource load(String key); + + /// Persists `tokens` under `key`. Implementations should overwrite any + /// existing entry atomically. + AsyncResource save(String key, OidcTokens tokens); + + /// Removes the entry for `key`. Completing with `Boolean.FALSE` means + /// nothing was stored; completing with an error means the underlying + /// store failed. + AsyncResource clear(String key); + + /// The default store. Serialises the token JSON to [Storage] under a + /// `"cn1.oidc."`-prefixed key. Convenient and zero-config, but not + /// encrypted-at-rest -- the underlying storage on Android is the app's + /// internal files directory, which is sandboxed but not protected against + /// a rooted device with backups enabled. + public static final class DefaultStorageTokenStore implements TokenStore { + private static final String PREFIX = "cn1.oidc."; + + public AsyncResource load(String key) { + AsyncResource r = new AsyncResource(); + try { + String stored = (String) Storage.getInstance().readObject(PREFIX + key); + if (stored == null) { + r.complete(null); + return r; + } + Map parsed = new JSONParser().parseJSON(new StringReader(stored)); + if (parsed == null) { + r.complete(null); + return r; + } + Map tokenJson = subMap(parsed, "token"); + Map claims = subMap(parsed, "claims"); + Object expiresMs = parsed.get("expiresAt"); + Date expiresAt = null; + if (expiresMs != null) { + try { + String raw = expiresMs.toString(); + int dot = raw.indexOf('.'); + if (dot >= 0) { + raw = raw.substring(0, dot); + } + expiresAt = new Date(Long.parseLong(raw)); + } catch (NumberFormatException nfe) { + // ignore + } + } + OidcTokens tokens = new OidcTokens( + str(tokenJson.get("access_token")), + str(tokenJson.get("id_token")), + str(tokenJson.get("refresh_token")), + str(tokenJson.get("token_type")), + str(tokenJson.get("scope")), + expiresAt, + claims, + tokenJson); + r.complete(tokens); + } catch (Throwable t) { + r.error(new OidcException(OidcException.TRANSPORT_ERROR, + "Failed to load stored tokens", t)); + } + return r; + } + + public AsyncResource save(String key, OidcTokens tokens) { + AsyncResource r = new AsyncResource(); + try { + StringBuilder sb = new StringBuilder("{"); + sb.append("\"token\":"); + appendJsonStringMap(sb, tokens.getRawResponse()); + sb.append(",\"claims\":"); + appendJsonStringMap(sb, tokens.getIdTokenClaims()); + if (tokens.getExpiresAt() != null) { + sb.append(",\"expiresAt\":").append(tokens.getExpiresAt().getTime()); + } + sb.append("}"); + Storage.getInstance().writeObject(PREFIX + key, sb.toString()); + r.complete(Boolean.TRUE); + } catch (Throwable t) { + r.error(new OidcException(OidcException.TRANSPORT_ERROR, + "Failed to save tokens", t)); + } + return r; + } + + public AsyncResource clear(String key) { + AsyncResource r = new AsyncResource(); + try { + Storage.getInstance().deleteStorageFile(PREFIX + key); + r.complete(Boolean.TRUE); + } catch (Throwable t) { + r.error(new OidcException(OidcException.TRANSPORT_ERROR, + "Failed to clear tokens", t)); + } + return r; + } + + @SuppressWarnings("unchecked") + private static Map subMap(Map root, String key) { + Object v = root.get(key); + if (v instanceof Map) { + return (Map) v; + } + return new HashMap(); + } + + private static String str(Object o) { + return o instanceof String ? (String) o : (o == null ? null : o.toString()); + } + + private static void appendJsonStringMap(StringBuilder sb, Map map) { + sb.append('{'); + boolean first = true; + for (Map.Entry e : map.entrySet()) { + if (!first) { + sb.append(','); + } + first = false; + sb.append('"').append(escape(e.getKey())).append("\":"); + Object v = e.getValue(); + if (v == null) { + sb.append("null"); + } else if (v instanceof Number || v instanceof Boolean) { + sb.append(v.toString()); + } else { + sb.append('"').append(escape(v.toString())).append('"'); + } + } + sb.append('}'); + } + + private static String escape(String s) { + StringBuilder b = new StringBuilder(s.length() + 8); + int len = s.length(); + for (int i = 0; i < len; i++) { + char c = s.charAt(i); + switch (c) { + case '"': b.append("\\\""); break; + case '\\': b.append("\\\\"); break; + case '\n': b.append("\\n"); break; + case '\r': b.append("\\r"); break; + case '\t': b.append("\\t"); break; + case '\b': b.append("\\b"); break; + case '\f': b.append("\\f"); break; + default: + if (c < 0x20) { + String hex = Integer.toHexString(c); + b.append("\\u"); + for (int p = hex.length(); p < 4; p++) b.append('0'); + b.append(hex); + } else { + b.append(c); + } + } + } + return b.toString(); + } + } +} diff --git a/CodenameOne/src/com/codename1/social/AppleSignIn.java b/CodenameOne/src/com/codename1/social/AppleSignIn.java new file mode 100644 index 0000000000..4371719774 --- /dev/null +++ b/CodenameOne/src/com/codename1/social/AppleSignIn.java @@ -0,0 +1,414 @@ +/* + * 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.social; + +import com.codename1.io.AccessToken; +import com.codename1.io.Preferences; +import com.codename1.io.oidc.OidcClient; +import com.codename1.io.oidc.OidcConfiguration; +import com.codename1.io.oidc.OidcException; +import com.codename1.io.oidc.OidcTokens; +import com.codename1.security.Hash; +import com.codename1.security.SecureRandom; +import com.codename1.system.NativeLookup; +import com.codename1.util.AsyncResource; +import com.codename1.util.Base64; +import com.codename1.util.SuccessCallback; + +import java.util.Map; + +/// `Sign in with Apple` for Codename One. Replaces the external +/// `cn1-applesignin` library; that library is now deprecated and forwards to +/// this class. +/// +/// Behavior by platform: +/// +/// - **iOS 13+** -- native `ASAuthorizationAppleIDProvider` flow via +/// [AppleSignInNative]. Returns the identity token plus the user's name +/// and email on the first authorization (Apple does not echo them on +/// subsequent ones; this class persists them in [Preferences]). +/// - **Android / JavaSE / Web** -- web fallback via +/// [com.codename1.io.oidc.OidcClient] against the public Apple OIDC issuer +/// (`https://appleid.apple.com`). Requires a *Services ID* (web client +/// ID), a redirect URI registered with Apple, and a `client_secret` JWT +/// generated server-side (use [#setClientSecret(String)]). +/// +/// #### Quick example +/// +/// ```java +/// AppleSignIn apple = AppleSignIn.getInstance() +/// .withServiceId("com.example.appleweb") // web only +/// .withRedirectUri("https://example.com/cb"); // web only +/// +/// apple.signIn("name email", new AppleSignInCallback() { +/// public void onSuccess(AppleSignInResult result) { +/// String id = result.getUserId(); +/// String email = result.getEmail(); +/// String idTok = result.getIdentityToken(); +/// // send idTok to your backend for verification +/// } +/// public void onError(String error) { ... } +/// public void onCancel() { ... } +/// }); +/// ``` +/// +/// @since 8.0 +public final class AppleSignIn extends Login { + + /// Apple's public OIDC issuer. + public static final String APPLE_ISSUER = "https://appleid.apple.com"; + + private static final String PREF_NAME = "cn1.applesignin.name"; + private static final String PREF_EMAIL = "cn1.applesignin.email"; + private static final String PREF_USER = "cn1.applesignin.userid"; + private static final String PREF_LOGGED_IN = "cn1.applesignin.loggedIn"; + + private static AppleSignIn INSTANCE; + + private String serviceId; // web Services ID + private String webRedirectUri; // for web fallback only + private String webClientSecret; // JWT generated by your backend + private String defaultScopes = "name email"; + + private AppleSignIn() {} + + public static synchronized AppleSignIn getInstance() { + if (INSTANCE == null) { + INSTANCE = new AppleSignIn(); + } + return INSTANCE; + } + + /// Apple *Services ID* used for the web fallback. Required only when + /// running on platforms without the native sheet (Android, JavaSE, Web). + public AppleSignIn withServiceId(String serviceId) { + this.serviceId = serviceId; + super.setClientId(serviceId); + return this; + } + + /// Redirect URI registered with Apple for the Services ID. Used by the + /// web fallback. + public AppleSignIn withRedirectUri(String redirectUri) { + this.webRedirectUri = redirectUri; + super.setRedirectURI(redirectUri); + return this; + } + + /// Client-secret JWT generated by your backend (Apple does not let mobile + /// apps mint this themselves -- see the developer guide for the recipe). + public AppleSignIn withClientSecret(String secret) { + this.webClientSecret = secret; + super.setClientSecret(secret); + return this; + } + + public AppleSignIn withDefaultScopes(String scopes) { + this.defaultScopes = scopes; + return this; + } + + @Override + public boolean isNativeLoginSupported() { + AppleSignInNative n = lookupNative(); + return n != null && n.isSupported(); + } + + @Override + public boolean nativeIsLoggedIn() { + AppleSignInNative n = lookupNative(); + if (n != null && n.isSupported()) { + return n.isLoggedIn(); + } + return Preferences.get(PREF_LOGGED_IN, false); + } + + @Override + public void nativeLogout() { + AppleSignInNative n = lookupNative(); + if (n != null && n.isSupported()) { + n.signOut(); + } + Preferences.delete(PREF_LOGGED_IN); + Preferences.delete(PREF_NAME); + Preferences.delete(PREF_EMAIL); + Preferences.delete(PREF_USER); + } + + @Override + public void nativelogin() { + signIn(defaultScopes, new LoginCallbackAdapter()); + } + + @Override + protected boolean validateToken(String token) { + // Apple identity tokens carry an `exp` claim; we trust it. + return true; + } + + /// Primary entry point. Triggers either the native sheet (iOS) or the + /// web OIDC fallback (everything else) and delivers the result to + /// `callback`. + public void signIn(String scopes, final AppleSignInCallback callback) { + if (callback == null) { + throw new IllegalArgumentException("callback must not be null"); + } + final String resolvedScopes = scopes != null ? scopes : defaultScopes; + AppleSignInNative n = lookupNative(); + if (n != null && n.isSupported()) { + signInNative(n, resolvedScopes, callback); + } else { + signInWeb(resolvedScopes, callback); + } + } + + private void signInNative(final AppleSignInNative n, + final String scopes, + final AppleSignInCallback callback) { + final byte[] rawNonce = SecureRandom.bytes(32); + final String plainNonce = strip(Base64.encodeUrlSafe(rawNonce)); + // Apple expects the SHA-256 hash of the nonce as the `nonce` value + // sent on the request; the returned ID token then carries the *hashed* + // value in its `nonce` claim. We hash it here. + byte[] hashed; + try { + hashed = Hash.sha256(plainNonce.getBytes("UTF-8")); + } catch (java.io.UnsupportedEncodingException e) { + hashed = Hash.sha256(plainNonce.getBytes()); + } + final String hashedNonce = strip(Base64.encodeUrlSafe(hashed)); + new Thread(new Runnable() { + public void run() { + try { + String packed = n.signIn(scopes, hashedNonce); + if (packed == null || packed.length() == 0) { + callback.onCancel(); + return; + } + AppleSignInResult result = parsePackedResult(packed); + if (result.getIdentityToken() == null) { + callback.onError("Apple Sign-In returned no identity token"); + return; + } + persistProfile(result); + setAccessToken(buildAccessToken(result)); + callback.onSuccess(result); + } catch (Throwable t) { + callback.onError(t.getMessage()); + } + } + }, "AppleSignIn-native").start(); + } + + private void signInWeb(final String scopes, final AppleSignInCallback callback) { + if (serviceId == null || webRedirectUri == null) { + callback.onError("AppleSignIn web fallback requires setServiceId() and setRedirectUri()"); + return; + } + // Apple advertises `.well-known/openid-configuration` -- discover then auth. + OidcClient.discover(APPLE_ISSUER) + .ready(new SuccessCallback() { + public void onSucess(OidcClient client) { + OidcConfiguration cfg = client.getConfiguration(); + // Apple does NOT issue refresh tokens to public clients; require + // form_post + `response_mode` for compatibility. + client.setClientId(serviceId) + .setRedirectUri(webRedirectUri) + .setScopes(splitScopes(scopes)) + .setResponseMode("form_post"); + if (webClientSecret != null) { + client.setClientSecret(webClientSecret); + } + client.authorize() + .ready(new SuccessCallback() { + public void onSucess(OidcTokens t) { + AppleSignInResult r = fromOidcTokens(t); + persistProfile(r); + setAccessToken(t.toAccessToken()); + callback.onSuccess(r); + } + }) + .except(new SuccessCallback() { + public void onSucess(Throwable err) { + if (err instanceof OidcException && + OidcException.USER_CANCELLED.equals( + ((OidcException) err).getError())) { + callback.onCancel(); + } else { + callback.onError(err.getMessage()); + } + } + }); + } + }) + .except(new SuccessCallback() { + public void onSucess(Throwable err) { + callback.onError("Apple OIDC discovery failed: " + err.getMessage()); + } + }); + } + + private void persistProfile(AppleSignInResult result) { + if (result.getUserId() != null) { + Preferences.set(PREF_USER, result.getUserId()); + } + if (result.getEmail() != null) { + Preferences.set(PREF_EMAIL, result.getEmail()); + } + if (result.getFullName() != null) { + Preferences.set(PREF_NAME, result.getFullName()); + } + Preferences.set(PREF_LOGGED_IN, true); + } + + private AccessToken buildAccessToken(AppleSignInResult result) { + return new AccessToken( + result.getAuthorizationCode(), + null, + null, + result.getIdentityToken()); + } + + private static String[] splitScopes(String scopes) { + if (scopes == null) return new String[0]; + String trimmed = scopes.trim(); + if (trimmed.length() == 0) return new String[0]; + return com.codename1.util.StringUtil.tokenize(trimmed, ' ').toArray(new String[0]); + } + + private static AppleSignInResult parsePackedResult(String packed) { + String[] parts = explode(packed, '|', 6); + AppleSignInResult r = new AppleSignInResult(); + r.identityToken = empty(parts[0]) ? null : parts[0]; + r.authorizationCode = empty(parts[1]) ? null : parts[1]; + r.userId = empty(parts[2]) ? null : parts[2]; + String given = empty(parts[3]) ? null : parts[3]; + String family = empty(parts[4]) ? null : parts[4]; + r.email = empty(parts[5]) ? null : parts[5]; + if (given != null || family != null) { + r.fullName = (given == null ? "" : given) + + (given != null && family != null ? " " : "") + + (family == null ? "" : family); + } + // Backfill from preferences when Apple omits the profile on + // subsequent logins. + if (r.email == null) { + r.email = Preferences.get(PREF_EMAIL, (String) null); + } + if (r.fullName == null) { + r.fullName = Preferences.get(PREF_NAME, (String) null); + } + return r; + } + + private static AppleSignInResult fromOidcTokens(OidcTokens t) { + AppleSignInResult r = new AppleSignInResult(); + r.identityToken = t.getIdToken(); + r.authorizationCode = (String) t.getRawResponse().get("code"); + r.userId = t.getSubject(); + r.email = t.getEmail(); + Map claims = t.getIdTokenClaims(); + Object name = claims != null ? claims.get("name") : null; + if (name != null) { + r.fullName = name.toString(); + } + return r; + } + + private static String[] explode(String s, char sep, int expected) { + String[] out = new String[expected]; + for (int i = 0; i < expected; i++) { + out[i] = ""; + } + int idx = 0; + int start = 0; + int len = s.length(); + for (int i = 0; i < len; i++) { + if (s.charAt(i) == sep) { + if (idx < expected) { + out[idx++] = s.substring(start, i); + } + start = i + 1; + } + } + if (idx < expected) { + out[idx] = s.substring(start); + } + return out; + } + + private static boolean empty(String s) { + return s == null || s.length() == 0; + } + + private static String strip(String s) { + StringBuilder b = new StringBuilder(s.length()); + int len = s.length(); + for (int i = 0; i < len; i++) { + char c = s.charAt(i); + if (c == '=' || c == '\n' || c == '\r') continue; + b.append(c); + } + return b.toString(); + } + + private static volatile AppleSignInNative CACHED_NATIVE; + private static volatile boolean NATIVE_PROBED; + private static AppleSignInNative lookupNative() { + if (NATIVE_PROBED) return CACHED_NATIVE; + synchronized (AppleSignIn.class) { + if (NATIVE_PROBED) return CACHED_NATIVE; + try { + CACHED_NATIVE = NativeLookup.create(AppleSignInNative.class); + } catch (Throwable t) { + CACHED_NATIVE = null; + } + NATIVE_PROBED = true; + return CACHED_NATIVE; + } + } + + /// Bridges [AppleSignInCallback] into the legacy [LoginCallback] used by + /// [Login#doLogin()]. + private final class LoginCallbackAdapter implements AppleSignInCallback { + public void onSuccess(AppleSignInResult result) { + // `callback` from Login is package-private; trigger success via setAccessToken side-effect. + if (callback != null) { + callback.loginSuccessful(); + } + } + + public void onError(String error) { + if (callback != null) { + callback.loginFailed(error); + } + } + + public void onCancel() { + if (callback != null) { + callback.loginFailed("cancelled"); + } + } + } +} diff --git a/CodenameOne/src/com/codename1/social/AppleSignInCallback.java b/CodenameOne/src/com/codename1/social/AppleSignInCallback.java new file mode 100644 index 0000000000..a506060230 --- /dev/null +++ b/CodenameOne/src/com/codename1/social/AppleSignInCallback.java @@ -0,0 +1,43 @@ +/* + * 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.social; + +/// Callback for [AppleSignIn#signIn(String, AppleSignInCallback)]. Implement +/// the three terminal outcomes -- success, error, cancellation -- so the +/// caller can tell user-intent (cancel) apart from a real failure. +/// +/// @since 8.0 +public interface AppleSignInCallback { + + /// User completed the sheet successfully. + void onSuccess(AppleSignInResult result); + + /// A network or protocol error occurred. `error` is a short + /// human-readable string (may be `null` if the underlying layer did not + /// provide one). + void onError(String error); + + /// User dismissed the sheet without completing. + void onCancel(); +} diff --git a/CodenameOne/src/com/codename1/social/AppleSignInNative.java b/CodenameOne/src/com/codename1/social/AppleSignInNative.java new file mode 100644 index 0000000000..317b92418b --- /dev/null +++ b/CodenameOne/src/com/codename1/social/AppleSignInNative.java @@ -0,0 +1,66 @@ +/* + * 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.social; + +import com.codename1.system.NativeInterface; + +/// Native bridge for `Sign in with Apple` on iOS 13+. The iOS port implements +/// this with `ASAuthorizationAppleIDProvider` and an +/// `ASAuthorizationController`. Other platforms leave it unimplemented; +/// [AppleSignIn] falls back to the web flow via +/// [com.codename1.io.oidc.OidcClient] instead. +/// +/// The native side serialises its result as a single pipe-delimited string +/// to keep the bridge boundary primitive-only: +/// +/// `{idToken}|{authorizationCode}|{user}|{givenName}|{familyName}|{email}` +/// +/// `null` segments are sent as empty strings. `user` is the stable opaque +/// identifier Apple returns; `givenName` / `familyName` / `email` are only +/// populated on the **first** authorization (Apple does not re-send the +/// profile on subsequent logins). The Java side persists them. +/// +/// @since 8.0 +public interface AppleSignInNative extends NativeInterface { + + /// Starts the system Sign-in-with-Apple sheet. The call blocks the + /// native thread until the user completes or cancels. + /// + /// #### Parameters + /// + /// - `scopes`: Space-separated scope list (e.g. `"name email"`). + /// - `nonce`: SHA-256 hash of the per-request nonce, base64url encoded. + /// Apple binds this to the returned ID token's `nonce` claim. + String signIn(String scopes, String nonce); + + /// Returns `true` if the user is currently signed in (i.e. the previously + /// returned credential is still valid in the Apple keychain). + boolean isLoggedIn(); + + /// Clears the current Apple credential from the app's keychain entry. + /// Apple does not provide an explicit sign-out -- this only removes the + /// local credential association so the next [#signIn(String, String)] + /// will prompt again. + void signOut(); +} diff --git a/CodenameOne/src/com/codename1/social/AppleSignInResult.java b/CodenameOne/src/com/codename1/social/AppleSignInResult.java new file mode 100644 index 0000000000..9416acba19 --- /dev/null +++ b/CodenameOne/src/com/codename1/social/AppleSignInResult.java @@ -0,0 +1,78 @@ +/* + * 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.social; + +/// Successful outcome of an [AppleSignIn#signIn(String, AppleSignInCallback)] +/// call. +/// +/// Apple only returns the user's name and email on the **first** authorization +/// for a given app. On subsequent sign-ins those fields are absent in the +/// native callback; [AppleSignIn] backfills them from [com.codename1.io.Preferences] +/// when present, so the application sees a consistent result. +/// +/// @since 8.0 +public final class AppleSignInResult { + + String identityToken; + String authorizationCode; + String userId; + String email; + String fullName; + + AppleSignInResult() {} + + /// JWT identity token signed by Apple. Send to your backend, where you + /// must validate the signature against Apple's JWKS and check the + /// `aud` / `iss` / `exp` claims before trusting it. + public String getIdentityToken() { + return identityToken; + } + + /// Authorization code suitable for the server-side `client_secret` + /// token exchange (Apple does not expose refresh tokens to public + /// clients, so this is the only way to obtain one). + public String getAuthorizationCode() { + return authorizationCode; + } + + /// Stable opaque identifier ("user identifier" in Apple's docs). Treat + /// this as the user's primary key for your app. + public String getUserId() { + return userId; + } + + /// Email the user shared with the app. May be the real address, may be + /// a relay address (`@privaterelay.appleid.com`), or may be `null` if + /// the user has previously signed in and the email was already stored. + public String getEmail() { + return email; + } + + /// Full display name (given + family) on the first authorization; + /// previously-stored value otherwise; `null` if the user declined to + /// share it. + public String getFullName() { + return fullName; + } +} diff --git a/CodenameOne/src/com/codename1/social/Auth0Connect.java b/CodenameOne/src/com/codename1/social/Auth0Connect.java new file mode 100644 index 0000000000..6979ad48bb --- /dev/null +++ b/CodenameOne/src/com/codename1/social/Auth0Connect.java @@ -0,0 +1,142 @@ +/* + * 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.social; + +import com.codename1.io.oidc.OidcClient; +import com.codename1.io.oidc.OidcTokens; +import com.codename1.util.AsyncResource; +import com.codename1.util.SuccessCallback; + +/// Sign-in via an Auth0 tenant. Auth0 is a fully OpenID-Connect compliant +/// provider so this class is a very thin convenience over +/// [com.codename1.io.oidc.OidcClient] -- it just builds the issuer URL from +/// the tenant domain and configures sensible defaults. +/// +/// ```java +/// Auth0Connect.getInstance() +/// .withDomain("dev-xyz.us.auth0.com") +/// .signIn( +/// "YOUR_AUTH0_CLIENT_ID", +/// "com.example.app:/oauth2redirect", +/// "openid", "email", "profile") +/// .ready(new SuccessCallback() { ... }); +/// ``` +/// +/// To request an Auth0 *audience* (so the access token can be used against +/// your custom API) pass it via [#withAudience(String)] before calling +/// [#signIn(String, String, String...)]. +/// +/// @since 8.0 +public final class Auth0Connect extends Login { + + private static Auth0Connect INSTANCE; + private String domain; + private String audience; + + private Auth0Connect() {} + + public static synchronized Auth0Connect getInstance() { + if (INSTANCE == null) { + INSTANCE = new Auth0Connect(); + } + return INSTANCE; + } + + /// Auth0 tenant domain (e.g. `"dev-xyz.us.auth0.com"`). Do not include + /// the protocol -- it is always `https://`. + public Auth0Connect withDomain(String domain) { + this.domain = domain; + return this; + } + + /// Optional `audience` parameter for API authorization. When set, the + /// access token issued by Auth0 will be a JWT valid against your API + /// identifier instead of the default opaque token. + public Auth0Connect withAudience(String audience) { + this.audience = audience; + return this; + } + + public String getDomain() { + return domain; + } + + public String getAudience() { + return audience; + } + + @Override + public boolean isNativeLoginSupported() { + return false; + } + + @Override + protected boolean validateToken(String token) { + return token != null && token.length() > 0; + } + + public AsyncResource signIn(final String clientId, + final String redirectUri, + final String... scopes) { + if (domain == null) { + AsyncResource err = new AsyncResource(); + err.error(new IllegalStateException( + "Auth0Connect requires withDomain(\"your-tenant.region.auth0.com\")")); + return err; + } + final AsyncResource out = new AsyncResource(); + OidcClient.discover("https://" + domain) + .ready(new SuccessCallback() { + public void onSucess(OidcClient client) { + client.setClientId(clientId) + .setRedirectUri(redirectUri) + .setScopes(scopes != null && scopes.length > 0 + ? scopes + : new String[] {"openid", "email", + "profile", "offline_access"}); + if (audience != null) { + client.setAuthorizationParameters("audience", audience); + } + client.authorize() + .ready(new SuccessCallback() { + public void onSucess(OidcTokens t) { + setAccessToken(t.toAccessToken()); + out.complete(t); + } + }) + .except(new SuccessCallback() { + public void onSucess(Throwable err) { + out.error(err); + } + }); + } + }) + .except(new SuccessCallback() { + public void onSucess(Throwable err) { + out.error(err); + } + }); + return out; + } +} diff --git a/CodenameOne/src/com/codename1/social/FacebookConnect.java b/CodenameOne/src/com/codename1/social/FacebookConnect.java index b22b426599..7ffa72b163 100644 --- a/CodenameOne/src/com/codename1/social/FacebookConnect.java +++ b/CodenameOne/src/com/codename1/social/FacebookConnect.java @@ -28,7 +28,12 @@ import com.codename1.io.Log; import com.codename1.io.NetworkManager; import com.codename1.io.Oauth2; +import com.codename1.io.oidc.OidcClient; +import com.codename1.io.oidc.OidcConfiguration; +import com.codename1.io.oidc.OidcTokens; +import com.codename1.util.AsyncResource; import com.codename1.util.Callback; +import com.codename1.util.SuccessCallback; import java.util.Arrays; @@ -245,6 +250,63 @@ public void inviteFriends(String appLinkUrl, String previewImageUrl) { public void inviteFriends(String appLinkUrl, String previewImageUrl, final Callback cb) { } + /// Modern Facebook Login. Goes through Facebook's OAuth 2.0 endpoints + /// via the system browser ([com.codename1.io.oidc.SystemBrowser]), + /// independent of the native Facebook SDK. Use this method for + /// browser-based and cross-platform consistency; the older + /// [#doLogin()] path remains for code that depends on the iOS/Android + /// Facebook SDK integration. + /// + /// #### Parameters + /// + /// - `appId`: Facebook App ID. + /// - `redirectUri`: Must match a Valid OAuth Redirect URI configured + /// in the app dashboard. + /// - `permissions`: Facebook permissions (`public_profile`, `email`, ...). + /// Defaults to `public_profile email` when empty. + /// + /// #### Returns + /// + /// An [AsyncResource] resolving to the granted access token wrapped in + /// [OidcTokens] (note: Facebook does not issue OIDC ID tokens for + /// classic OAuth flows -- `getIdToken()` will be `null`; use + /// `getAccessToken()` and call the Graph API to read user profile data). + /// + /// #### Since + /// + /// 8.0 + public AsyncResource signIn(String appId, + String redirectUri, + String... permissions) { + OidcConfiguration cfg = OidcConfiguration.newBuilder() + .issuer("https://www.facebook.com") + .authorizationEndpoint("https://www.facebook.com/v18.0/dialog/oauth") + .tokenEndpoint("https://graph.facebook.com/v18.0/oauth/access_token") + .build(); + OidcClient client = OidcClient.create(cfg) + .setClientId(appId) + .setRedirectUri(redirectUri) + .setScopes(permissions == null || permissions.length == 0 + ? new String[] {"public_profile", "email"} + : permissions) + // Facebook does not echo a nonce; skip the check. + .setEnforceNonce(false); + final AsyncResource out = new AsyncResource(); + client.authorize() + .ready(new SuccessCallback() { + public void onSucess(OidcTokens t) { + setAccessToken(t.toAccessToken()); + out.complete(t); + } + }) + .except(new SuccessCallback() { + public void onSucess(Throwable err) { + out.error(err); + } + }); + return out; + } + /// Returns true if inviteFriends is implemented, it is supported on iOS and /// Android /// diff --git a/CodenameOne/src/com/codename1/social/FirebaseAuth.java b/CodenameOne/src/com/codename1/social/FirebaseAuth.java new file mode 100644 index 0000000000..680b6cb3dd --- /dev/null +++ b/CodenameOne/src/com/codename1/social/FirebaseAuth.java @@ -0,0 +1,369 @@ +/* + * 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.social; + +import com.codename1.io.ConnectionRequest; +import com.codename1.io.JSONParser; +import com.codename1.io.NetworkManager; +import com.codename1.io.Preferences; +import com.codename1.io.Util; +import com.codename1.io.oidc.OidcTokens; +import com.codename1.util.AsyncResource; +import com.codename1.util.StringUtil; +import com.codename1.util.regex.StringReader; + +import java.io.IOException; +import java.io.InputStream; +import java.util.Date; +import java.util.HashMap; +import java.util.Map; + +/// Firebase Authentication client backed by the Identity Toolkit REST API. +/// Firebase is **not** an OIDC provider per se -- it issues its own ID tokens +/// minted by Google's Identity Toolkit -- so this class does not extend +/// [Login]; it stands alone with its own state. +/// +/// Supports the three flows that work without the Firebase native SDK: +/// +/// - `signInWithEmailAndPassword(email, password)` (Email/Password provider) +/// - `signUp(email, password)` (creates a new account) +/// - `refresh(refreshToken)` (uses the Secure Token Service endpoint) +/// +/// For *federated* sign-in (Google, Apple, Microsoft, etc.) use the +/// matching `*Connect` class to obtain an OIDC ID token, then call +/// [#signInWithIdpIdToken(String, String)] to swap it for a Firebase token. +/// +/// Tokens are persisted to [Preferences] under a `cn1.firebase.*` namespace. +/// They are **not** encrypted-at-rest by default -- bring your own +/// [com.codename1.io.oidc.TokenStore] strategy if that matters to you. +/// +/// @since 8.0 +public final class FirebaseAuth { + + private static final String PREF_ID = "cn1.firebase.idToken"; + private static final String PREF_REFRESH = "cn1.firebase.refreshToken"; + private static final String PREF_UID = "cn1.firebase.uid"; + private static final String PREF_EXPIRES = "cn1.firebase.expiresAt"; + + private static FirebaseAuth INSTANCE; + private String apiKey; + + private FirebaseAuth() {} + + public static synchronized FirebaseAuth getInstance() { + if (INSTANCE == null) { + INSTANCE = new FirebaseAuth(); + } + return INSTANCE; + } + + /// The *Web API key* from the Firebase console (Project Settings → General + /// → Your apps → Web API key). Required before any of the sign-in methods + /// will work. + public FirebaseAuth withApiKey(String apiKey) { + this.apiKey = apiKey; + return this; + } + + /// Last-known Firebase user identifier (`localId` from Firebase's REST + /// API), or `null` if no one is signed in. + public String getUid() { + return Preferences.get(PREF_UID, (String) null); + } + + /// Currently-stored Firebase ID token. Call [#refresh()] if it is expired + /// or [#signInWithEmailAndPassword(String, String)] for a fresh session. + public String getIdToken() { + return Preferences.get(PREF_ID, (String) null); + } + + /// `true` if a token is stored and not past its expiry. + public boolean isSignedIn() { + if (getIdToken() == null) return false; + long exp = Preferences.get(PREF_EXPIRES, 0L); + return exp == 0L || exp > System.currentTimeMillis(); + } + + /// Clears the locally stored Firebase session. Does not revoke the + /// refresh token on Google's side. + public void signOut() { + Preferences.delete(PREF_ID); + Preferences.delete(PREF_REFRESH); + Preferences.delete(PREF_UID); + Preferences.delete(PREF_EXPIRES); + } + + /// Email + password sign-in via Identity Toolkit's + /// `accounts:signInWithPassword` endpoint. + public AsyncResource signInWithEmailAndPassword(String email, + String password) { + Map body = new HashMap(); + body.put("email", email); + body.put("password", password); + body.put("returnSecureToken", "true"); + return postJson( + "https://identitytoolkit.googleapis.com/v1/accounts:signInWithPassword", + body); + } + + /// Creates a new account via `accounts:signUp`. Returns the new + /// [FirebaseUser] just like [#signInWithEmailAndPassword(String, String)]. + public AsyncResource signUp(String email, String password) { + Map body = new HashMap(); + body.put("email", email); + body.put("password", password); + body.put("returnSecureToken", "true"); + return postJson( + "https://identitytoolkit.googleapis.com/v1/accounts:signUp", + body); + } + + /// Exchanges an OIDC ID token obtained via [GoogleConnect], [AppleSignIn], + /// [MicrosoftConnect] or similar for a Firebase session. `providerId` + /// must be a Firebase-recognised identifier such as `"google.com"`, + /// `"apple.com"`, `"microsoft.com"`, `"facebook.com"`, `"twitter.com"`. + public AsyncResource signInWithIdpIdToken(String idToken, + String providerId) { + Map body = new HashMap(); + body.put("postBody", "id_token=" + idToken + "&providerId=" + providerId); + body.put("requestUri", "http://localhost"); + body.put("returnSecureToken", "true"); + body.put("returnIdpCredential", "true"); + return postJson( + "https://identitytoolkit.googleapis.com/v1/accounts:signInWithIdp", + body); + } + + /// Refreshes the stored session using the saved refresh token. Falls + /// through with the currently-cached [FirebaseUser] when no refresh + /// token is on file. + public AsyncResource refresh() { + String rt = Preferences.get(PREF_REFRESH, (String) null); + if (rt == null) { + AsyncResource noop = new AsyncResource(); + noop.complete(null); + return noop; + } + return refresh(rt); + } + + /// Same as [#refresh()] but takes an explicit refresh token. + public AsyncResource refresh(String refreshToken) { + Map body = new HashMap(); + body.put("grant_type", "refresh_token"); + body.put("refresh_token", refreshToken); + return postForm( + "https://securetoken.googleapis.com/v1/token", + body, + /* refreshFlow= */ true); + } + + // ----------------------------------------------------------------- + + private AsyncResource postJson(final String urlBase, + final Map body) { + return enqueue(urlBase + "?key=" + apiKey, body, "application/json", false); + } + + private AsyncResource postForm(final String url, + final Map body, + final boolean refreshFlow) { + return enqueue(url + "?key=" + apiKey, body, + "application/x-www-form-urlencoded", refreshFlow); + } + + private AsyncResource enqueue(final String url, + final Map body, + final String contentType, + final boolean refreshFlow) { + final AsyncResource out = new AsyncResource(); + if (apiKey == null) { + out.error(new IllegalStateException( + "FirebaseAuth.withApiKey(\"...\") must be called first")); + return out; + } + ConnectionRequest req = new ConnectionRequest() { + protected void readResponse(InputStream input) 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; + } + FirebaseUser u = new FirebaseUser(parsed, refreshFlow); + persist(u); + out.complete(u); + } + + protected void handleException(Exception err) { + out.error(err); + } + }; + req.setUrl(url); + req.setPost(true); + req.setReadResponseForErrors(true); + if ("application/json".equals(contentType)) { + req.addRequestHeader("Content-Type", "application/json"); + req.setRequestBody(toJson(body)); + } else { + req.addRequestHeader("Content-Type", contentType); + for (Map.Entry e : body.entrySet()) { + req.addArgument(e.getKey(), e.getValue()); + } + } + NetworkManager.getInstance().addToQueue(req); + return out; + } + + private void persist(FirebaseUser u) { + if (u.getIdToken() != null) { + Preferences.set(PREF_ID, u.getIdToken()); + } + if (u.getRefreshToken() != null) { + Preferences.set(PREF_REFRESH, u.getRefreshToken()); + } + if (u.getUid() != null) { + Preferences.set(PREF_UID, u.getUid()); + } + if (u.getExpiresAt() != null) { + Preferences.set(PREF_EXPIRES, u.getExpiresAt().getTime()); + } + } + + private static String toJson(Map m) { + StringBuilder b = new StringBuilder("{"); + boolean first = true; + for (Map.Entry e : m.entrySet()) { + if (!first) b.append(','); + first = false; + b.append('"').append(escape(e.getKey())).append("\":"); + b.append('"').append(escape(e.getValue())).append('"'); + } + b.append('}'); + return b.toString(); + } + + private static String escape(String s) { + StringBuilder b = new StringBuilder(s.length() + 8); + int len = s.length(); + for (int i = 0; i < len; i++) { + char c = s.charAt(i); + switch (c) { + case '"': b.append("\\\""); break; + case '\\': b.append("\\\\"); break; + case '\n': b.append("\\n"); break; + case '\r': b.append("\\r"); break; + case '\t': b.append("\\t"); break; + default: + if (c < 0x20) { + String hex = Integer.toHexString(c); + b.append("\\u"); + for (int p = hex.length(); p < 4; p++) b.append('0'); + b.append(hex); + } else { + b.append(c); + } + } + } + return b.toString(); + } + + /// Successfully-resolved Firebase session: ID token, refresh token, the + /// stable `localId`, the user's email when present, and the absolute + /// expiry computed from `expiresIn`. + public static final class FirebaseUser { + private final String idToken; + private final String refreshToken; + private final String uid; + private final String email; + private final Date expiresAt; + private final Map claims; + + FirebaseUser(Map json, boolean refreshFlow) { + if (refreshFlow) { + this.idToken = strVal(json, "id_token"); + this.refreshToken = strVal(json, "refresh_token"); + this.uid = strVal(json, "user_id"); + this.email = null; + long secs = longVal(json, "expires_in"); + this.expiresAt = secs > 0 + ? new Date(System.currentTimeMillis() + secs * 1000L) + : null; + } else { + this.idToken = strVal(json, "idToken"); + this.refreshToken = strVal(json, "refreshToken"); + this.uid = strVal(json, "localId"); + this.email = strVal(json, "email"); + long secs = longVal(json, "expiresIn"); + this.expiresAt = secs > 0 + ? new Date(System.currentTimeMillis() + secs * 1000L) + : null; + } + this.claims = idToken != null + ? OidcTokens.decodeIdTokenClaims(idToken) + : null; + } + + public String getIdToken() { return idToken; } + public String getRefreshToken() { return refreshToken; } + public String getUid() { return uid; } + public String getEmail() { + if (email != null) return email; + return claims != null && claims.get("email") != null + ? claims.get("email").toString() : null; + } + public Date getExpiresAt() { return expiresAt; } + public Map getIdTokenClaims() { return claims; } + + private static String strVal(Map json, String k) { + Object v = json.get(k); + return v == null ? null : v.toString(); + } + + private static long longVal(Map json, String k) { + Object v = json.get(k); + if (v == null) return 0L; + try { + String raw = v.toString(); + int dot = raw.indexOf('.'); + if (dot >= 0) raw = raw.substring(0, dot); + return Long.parseLong(raw); + } catch (NumberFormatException nfe) { + return 0L; + } + } + } +} diff --git a/CodenameOne/src/com/codename1/social/GoogleConnect.java b/CodenameOne/src/com/codename1/social/GoogleConnect.java index 14e8642ce5..44f6ec5259 100644 --- a/CodenameOne/src/com/codename1/social/GoogleConnect.java +++ b/CodenameOne/src/com/codename1/social/GoogleConnect.java @@ -25,24 +25,35 @@ import com.codename1.io.ConnectionRequest; import com.codename1.io.NetworkManager; import com.codename1.io.Oauth2; +import com.codename1.io.oidc.OidcClient; +import com.codename1.io.oidc.OidcTokens; +import com.codename1.util.AsyncResource; +import com.codename1.util.SuccessCallback; import java.util.Arrays; import java.util.Hashtable; -/// The GoogleConnect Login class allows the sign in with google functionality. -/// The GoogleConnect requires to create a corresponding google cloud project. -/// To enable the GoogleConnect to sign-in on the Simulator create a corresponding -/// web login - https://developers.google.com/+/web/signin/ +/// Sign-in-with-Google for Codename One. /// -/// To enable the GoogleConnect to sign-in on Android -/// Follow step 1 from here - https://developers.google.com/+/mobile/android/getting-started +/// As of 2025 Google replaced the legacy Sign-In SDK with the Google Identity +/// Services (GIS) family of APIs. GIS encourages the OAuth 2.0 authorization +/// code flow with PKCE driven from the system browser -- exactly what +/// [com.codename1.io.oidc.OidcClient] does. New apps should call +/// [#signIn(String, String, String[])] which goes through that modern path +/// and works on every Codename One platform without a native SDK dependency. /// -/// To enable the GoogleConnect to sign-in on iOS -/// follow step 1 from here - https://developers.google.com/+/mobile/ios/getting-started +/// The older [#doLogin()] / [#nativelogin()] path remains for source +/// compatibility, and on iOS / Android it still delegates to the native +/// implementation provided by the port (see `Ports/iOSPort` and +/// `Ports/Android`). On other platforms the legacy path also now goes +/// through `OidcClient` instead of the deprecated [Oauth2] in-app WebView. /// /// @author Chen public class GoogleConnect extends Login { + /// Google's well-known OIDC issuer. + public static final String GOOGLE_ISSUER = "https://accounts.google.com"; + private static final String tokenURL = "https://www.googleapis.com/oauth2/v3/token"; private static final Object INSTANCE_LOCK = new Object(); static Class implClass; @@ -95,6 +106,72 @@ protected Oauth2 createOauth2() { return new Oauth2(oauth2URL, clientId, redirectURI, scope, tokenURL, clientSecret, params); } + /// Modern Google sign-in. Goes through the Google Identity Services OIDC + /// endpoints with PKCE, using the system browser. Works the same on every + /// platform (iOS, Android, JavaSE, Web) provided the platform port wires + /// the system browser native interface; otherwise it falls back to an + /// in-app browser window. + /// + /// #### Parameters + /// + /// - `clientId`: OAuth 2.0 client ID issued in Google Cloud Console. + /// Use the *iOS / Android* client for the matching native build, or the + /// *Web* client when running in the simulator / web port. + /// - `redirectUri`: Redirect URI registered for that client. Custom + /// schemes (`com.example.app:/oauth2redirect`) for mobile; HTTPS + /// for web. + /// - `scopes`: OAuth scopes to request -- include `openid email profile` + /// to get an ID token plus user metadata, plus any Google API scopes + /// you need. + /// + /// #### Returns + /// + /// An [AsyncResource] resolving to the [OidcTokens] for the signed-in + /// user. + /// + /// #### Since + /// + /// 8.0 + public AsyncResource signIn(final String clientId, + final String redirectUri, + final String... scopes) { + final AsyncResource out = new AsyncResource(); + OidcClient.discover(GOOGLE_ISSUER) + .ready(new SuccessCallback() { + public void onSucess(OidcClient client) { + client.setClientId(clientId) + .setRedirectUri(redirectUri) + .setScopes(scopes != null && scopes.length > 0 + ? scopes + : new String[] {"openid", "email", "profile"}) + // `access_type=offline` is Google-specific and is needed + // to get a refresh token; `prompt=consent` forces the + // refresh-token grant on subsequent sign-ins. + .setAuthorizationParameters( + "access_type", "offline", + "prompt", "consent"); + client.authorize() + .ready(new SuccessCallback() { + public void onSucess(OidcTokens tokens) { + setAccessToken(tokens.toAccessToken()); + out.complete(tokens); + } + }) + .except(new SuccessCallback() { + public void onSucess(Throwable err) { + out.error(err); + } + }); + } + }) + .except(new SuccessCallback() { + public void onSucess(Throwable err) { + out.error(err); + } + }); + return out; + } + @Override protected boolean validateToken(String token) { //make a call to the API if the return value is 40X the token is not diff --git a/CodenameOne/src/com/codename1/social/MicrosoftConnect.java b/CodenameOne/src/com/codename1/social/MicrosoftConnect.java new file mode 100644 index 0000000000..57e4d2cd93 --- /dev/null +++ b/CodenameOne/src/com/codename1/social/MicrosoftConnect.java @@ -0,0 +1,136 @@ +/* + * 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.social; + +import com.codename1.io.oidc.OidcClient; +import com.codename1.io.oidc.OidcTokens; +import com.codename1.util.AsyncResource; +import com.codename1.util.SuccessCallback; + +/// Sign-in with a Microsoft account (personal, work, or school) backed by +/// Microsoft Entra ID (formerly Azure Active Directory). Wraps +/// [com.codename1.io.oidc.OidcClient] against Microsoft's +/// `v2.0/.well-known/openid-configuration` endpoint. +/// +/// On iOS and Android, where Microsoft ships the MSAL SDK with broker +/// integration (Microsoft Authenticator, Company Portal), this class still +/// uses the system browser flow -- MSAL's broker is only available when the +/// app embeds the native MSAL SDK and is configured for the conditional +/// access scenarios that require it. For 95% of Codename One apps the +/// system-browser flow is the right answer, and lets the same code work in +/// the simulator and on the web port. +/// +/// ```java +/// MicrosoftConnect.getInstance() +/// .withTenant("common") // or your tenant GUID +/// .signIn( +/// "YOUR_CLIENT_ID", +/// "com.example.app:/oauth2redirect", +/// "openid", "email", "profile", "User.Read") +/// .ready(new SuccessCallback() { +/// public void onSucess(OidcTokens t) { ... } +/// }); +/// ``` +/// +/// @since 8.0 +public final class MicrosoftConnect extends Login { + + /// "common" -- accepts personal, work, and school accounts. Use this for + /// most multi-tenant apps. Pass a tenant GUID to restrict to a single + /// Entra ID tenant; pass "organizations" for work/school only; + /// "consumers" for personal only. + public static final String COMMON_TENANT = "common"; + + private static MicrosoftConnect INSTANCE; + private String tenant = COMMON_TENANT; + + private MicrosoftConnect() {} + + public static synchronized MicrosoftConnect getInstance() { + if (INSTANCE == null) { + INSTANCE = new MicrosoftConnect(); + } + return INSTANCE; + } + + /// Picks the Entra ID tenant to target. Pass [#COMMON_TENANT], + /// `"organizations"`, `"consumers"`, or a tenant GUID / verified domain + /// (e.g. `"contoso.onmicrosoft.com"`). + public MicrosoftConnect withTenant(String tenant) { + this.tenant = tenant != null ? tenant : COMMON_TENANT; + return this; + } + + public String getTenant() { + return tenant; + } + + @Override + public boolean isNativeLoginSupported() { + return false; + } + + @Override + protected boolean validateToken(String token) { + return token != null && token.length() > 0; + } + + /// Drives a full authorization-code-with-PKCE sign-in through the system + /// browser and resolves with the issued tokens. + public AsyncResource signIn(final String clientId, + final String redirectUri, + final String... scopes) { + final AsyncResource out = new AsyncResource(); + String issuer = "https://login.microsoftonline.com/" + tenant + "/v2.0"; + OidcClient.discover(issuer) + .ready(new SuccessCallback() { + public void onSucess(OidcClient client) { + client.setClientId(clientId) + .setRedirectUri(redirectUri) + .setScopes(scopes != null && scopes.length > 0 + ? scopes + : new String[] {"openid", "email", "profile", + "offline_access"}); + client.authorize() + .ready(new SuccessCallback() { + public void onSucess(OidcTokens t) { + setAccessToken(t.toAccessToken()); + out.complete(t); + } + }) + .except(new SuccessCallback() { + public void onSucess(Throwable err) { + out.error(err); + } + }); + } + }) + .except(new SuccessCallback() { + public void onSucess(Throwable err) { + out.error(err); + } + }); + return out; + } +} diff --git a/Ports/Android/src/com/codename1/io/oidc/OidcBrowserNativeImpl.java b/Ports/Android/src/com/codename1/io/oidc/OidcBrowserNativeImpl.java new file mode 100644 index 0000000000..eb1aebd402 --- /dev/null +++ b/Ports/Android/src/com/codename1/io/oidc/OidcBrowserNativeImpl.java @@ -0,0 +1,196 @@ +/* + * 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.oidc; + +import android.app.Activity; +import android.content.Intent; +import android.net.Uri; +import android.os.Bundle; +import androidx.browser.customtabs.CustomTabsIntent; +import com.codename1.impl.android.AndroidNativeUtil; +import com.codename1.impl.android.LifecycleListener; + +/** + * Android implementation of {@link OidcBrowserNative} backed by + * {@code androidx.browser.customtabs.CustomTabsIntent}. + * + *

Flow: + * + *

    + *
  1. {@link #startAuthorization(String, String)} is called from a worker thread. + *
  2. We launch a Custom Tabs intent at the authorization URL. + *
  3. The identity provider eventually redirects to a URL on the registered + * custom scheme (e.g. {@code com.example.app:/oauth2redirect?code=...}). + *
  4. Android delivers that as an intent to {@code CodenameOneActivity}; we + * observe it via a {@link LifecycleListener#onResume()} callback. + *
  5. The worker thread unblocks and returns the redirect URL to Java. + *
+ * + *

The application must register an intent filter for the redirect URI + * scheme in its {@code AndroidManifest.xml}. Add the {@code android.xintent_filter} + * build hint pointing at your main activity: + * + *

{@code
+ * android.xintent_filter=\n
+ *   \n
+ *   \n
+ *   \n
+ *   \n
+ * 
+ * }
+ */ +public class OidcBrowserNativeImpl implements OidcBrowserNative { + + /** Guards {@link #pendingScheme} / {@link #resultUrl} and acts as a wait monitor. */ + private static final Object LOCK = new Object(); + + /** Scheme half of the current flow's redirect URI, e.g. {@code "com.example.app"}. */ + private static String pendingScheme; + + /** Captured redirect URL once it arrives. */ + private static String resultUrl; + + /** Single shared lifecycle listener; installed lazily on first call. */ + private static LifecycleListener installedListener; + + @Override + public boolean isSupported() { + return AndroidNativeUtil.getActivity() != null; + } + + @Override + public String startAuthorization(final String authUrl, final String redirectScheme) { + final Activity activity = AndroidNativeUtil.getActivity(); + if (activity == null) { + return null; + } + if (authUrl == null || redirectScheme == null) { + return null; + } + + installRedirectListenerOnce(); + + synchronized (LOCK) { + pendingScheme = redirectScheme; + resultUrl = null; + } + + // Open the Custom Tab on the UI thread; the user is sent away from the + // app and will be brought back via the registered intent filter. + activity.runOnUiThread(new Runnable() { + public void run() { + try { + CustomTabsIntent customTabs = + new CustomTabsIntent.Builder().build(); + customTabs.intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + customTabs.launchUrl(activity, Uri.parse(authUrl)); + } catch (Throwable t) { + // Fallback to ACTION_VIEW if Custom Tabs is unavailable for + // any reason (e.g. no Chrome / Custom-Tabs-capable browser + // installed). Most users will still complete the flow. + Intent fallback = new Intent(Intent.ACTION_VIEW, Uri.parse(authUrl)); + fallback.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + activity.startActivity(fallback); + } + } + }); + + // Block the calling worker thread until onResume captures the redirect + // (or until an hour passes -- the cap is purely defensive). + synchronized (LOCK) { + long deadline = System.currentTimeMillis() + 3600 * 1000L; + while (resultUrl == null) { + long remaining = deadline - System.currentTimeMillis(); + if (remaining <= 0) { + pendingScheme = null; + return null; + } + try { + LOCK.wait(remaining); + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + pendingScheme = null; + return null; + } + } + String r = resultUrl; + resultUrl = null; + pendingScheme = null; + return r; + } + } + + private void installRedirectListenerOnce() { + synchronized (LOCK) { + if (installedListener != null) { + return; + } + installedListener = new RedirectLifecycleListener(); + AndroidNativeUtil.addLifecycleListener(installedListener); + } + } + + /** + * Listens for the activity returning to the foreground and snapshots the + * intent if it carries a URL on our pending redirect scheme. Stays + * installed for the lifetime of the process so multiple sign-in flows + * over time all hit the same hook. + */ + private static final class RedirectLifecycleListener implements LifecycleListener { + public void onCreate(Bundle savedInstanceState) {} + public void onPause() {} + public void onDestroy() {} + public void onSaveInstanceState(Bundle b) {} + public void onLowMemory() {} + + public void onResume() { + Activity act = AndroidNativeUtil.getActivity(); + if (act == null) return; + Intent intent = act.getIntent(); + if (intent == null) return; + Uri data = intent.getData(); + if (data == null) return; + String scheme = data.getScheme(); + if (scheme == null) return; + String full = data.toString(); + synchronized (LOCK) { + if (pendingScheme == null) { + return; + } + // Match either the literal scheme (`com.example.app`) or the + // full URI prefix (`https://example.com/cb`). + boolean match = scheme.equalsIgnoreCase(pendingScheme) + || full.startsWith(pendingScheme); + if (!match) { + return; + } + resultUrl = full; + // Reset the intent data so a subsequent resume after rotation + // does not re-trigger. + intent.setData(null); + LOCK.notifyAll(); + } + } + } +} diff --git a/Ports/Android/src/com/codename1/social/AppleSignInNativeImpl.java b/Ports/Android/src/com/codename1/social/AppleSignInNativeImpl.java new file mode 100644 index 0000000000..5770f6252e --- /dev/null +++ b/Ports/Android/src/com/codename1/social/AppleSignInNativeImpl.java @@ -0,0 +1,62 @@ +/* + * 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.social; + +/** + * Apple does not ship a native Sign-in-with-Apple SDK for Android; the + * supported flow on Android is Apple's web-based authorization endpoint + * (the same one the Services ID is configured for). This implementation + * reports {@link #isSupported()} = {@code false} so that the Java-side + * {@link AppleSignIn} class falls back to its {@code OidcClient} + + * {@code SystemBrowser} path -- which on Android resolves to a Custom Tab + * over the Apple `https://appleid.apple.com/auth/authorize` endpoint. + * + *

The class exists chiefly to make the {@code NativeLookup} probe in + * {@link AppleSignIn#lookupNative()} non-null so we explicitly answer the + * "is this platform native?" question instead of falling through to a + * {@code ClassNotFoundException} swallowed deep inside {@code NativeLookup}. + */ +public class AppleSignInNativeImpl implements AppleSignInNative { + + @Override + public boolean isSupported() { + return false; + } + + @Override + public String signIn(String scopes, String nonce) { + return null; + } + + @Override + public boolean isLoggedIn() { + return false; + } + + @Override + public void signOut() { + // No-op: the OidcClient-backed AppleSignIn fallback drives its own + // token cache. + } +} diff --git a/Ports/iOSPort/nativeSources/com_codename1_io_oidc_OidcBrowserNativeImpl.h b/Ports/iOSPort/nativeSources/com_codename1_io_oidc_OidcBrowserNativeImpl.h new file mode 100644 index 0000000000..85e095ea7e --- /dev/null +++ b/Ports/iOSPort/nativeSources/com_codename1_io_oidc_OidcBrowserNativeImpl.h @@ -0,0 +1,32 @@ +/* + * 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. + */ + +#import + +@interface com_codename1_io_oidc_OidcBrowserNativeImpl : NSObject + +- (NSString *)startAuthorization:(NSString *)authUrl param1:(NSString *)redirectScheme; +- (BOOL)isSupported; + +@end diff --git a/Ports/iOSPort/nativeSources/com_codename1_io_oidc_OidcBrowserNativeImpl.m b/Ports/iOSPort/nativeSources/com_codename1_io_oidc_OidcBrowserNativeImpl.m new file mode 100644 index 0000000000..137a38fb13 --- /dev/null +++ b/Ports/iOSPort/nativeSources/com_codename1_io_oidc_OidcBrowserNativeImpl.m @@ -0,0 +1,165 @@ +/* + * 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. + */ + +#import "com_codename1_io_oidc_OidcBrowserNativeImpl.h" +#import +#import + +/// ASWebAuthenticationPresentationContextProviding wrapper that vends the +/// current keyWindow back to the system. iOS 13+ requires a non-nil context +/// provider before -[ASWebAuthenticationSession start] will succeed. We hold +/// a strong reference on the session itself for the duration of the flow. +API_AVAILABLE(ios(12.0)) +@interface CN1OidcAuthContext : NSObject +@end + +@implementation CN1OidcAuthContext + +- (ASPresentationAnchor)presentationAnchorForWebAuthenticationSession:(ASWebAuthenticationSession *)session API_AVAILABLE(ios(13.0)) { + 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) { + // Fallback path; on iOS 13+ UIApplication.keyWindow is deprecated but + // still works, on iOS 12 it is the only option. The deprecation is + // expected -- silence the warning on this single call. +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wdeprecated-declarations" + anchor = [UIApplication sharedApplication].keyWindow; + if (!anchor) { + // Last-ditch: any window that exists. + for (UIWindow *w in [UIApplication sharedApplication].windows) { + if (w) { anchor = w; break; } + } + } +#pragma clang diagnostic pop + } + return anchor; +} + +@end + +@implementation com_codename1_io_oidc_OidcBrowserNativeImpl { + // Strongly-held during a flow so ARC does not deallocate the session + // while the user is signing in. + id _currentSession; + CN1OidcAuthContext *_currentContext; +} + +- (BOOL)isSupported { + if (@available(iOS 12.0, *)) { + return YES; + } + return NO; +} + +- (NSString *)startAuthorization:(NSString *)param param1:(NSString *)param1 { + NSString *authUrl = param; + NSString *redirectScheme = param1; + if (authUrl == nil || redirectScheme == nil) { + return nil; + } + if (@available(iOS 12.0, *)) { + // Block the calling (background) thread until the OS sheet completes. + // ASWebAuthenticationSession must be created and started on the main + // thread; we dispatch_async over and wait on a semaphore. + dispatch_semaphore_t sem = dispatch_semaphore_create(0); + __block NSString *result = nil; + __block NSError *failure = nil; + + NSURL *url = [NSURL URLWithString:authUrl]; + if (url == nil) { + return nil; + } + + dispatch_async(dispatch_get_main_queue(), ^{ + ASWebAuthenticationSession *session = + [[ASWebAuthenticationSession alloc] initWithURL:url + callbackURLScheme:redirectScheme + completionHandler:^(NSURL * _Nullable callbackURL, NSError * _Nullable error) { + if (callbackURL) { + result = [callbackURL absoluteString]; + } else if (error) { + failure = error; + } + self->_currentSession = nil; + self->_currentContext = nil; + dispatch_semaphore_signal(sem); + }]; + + // iOS 13+ requires a presentation context provider; on iOS 12 the + // property exists but is optional. + if (@available(iOS 13.0, *)) { + CN1OidcAuthContext *ctx = [[CN1OidcAuthContext alloc] init]; + self->_currentContext = ctx; + session.presentationContextProvider = ctx; + // Force a fresh sign-in UI -- avoids surprising cookie reuse from + // a prior unrelated provider in the same scheme. + session.prefersEphemeralWebBrowserSession = NO; + } + self->_currentSession = session; + BOOL started = [session start]; + if (!started) { + failure = [NSError errorWithDomain:@"com.codename1.io.oidc" + code:-1 + userInfo:@{NSLocalizedDescriptionKey: @"ASWebAuthenticationSession refused to start"}]; + self->_currentSession = nil; + self->_currentContext = nil; + dispatch_semaphore_signal(sem); + } + }); + + // Cap the wait at one hour. A real-world user finishes in seconds; the + // cap exists so a stuck flow eventually unwinds instead of holding the + // thread forever. + dispatch_semaphore_wait(sem, dispatch_time(DISPATCH_TIME_NOW, (int64_t)(3600 * NSEC_PER_SEC))); + + if (failure) { + // User cancellation comes back as ASWebAuthenticationSessionErrorCodeCanceledLogin + // (code 1) on iOS 12+. Return nil so the Java side reports USER_CANCELLED. + return nil; + } + return result; + } + return nil; +} + +@end diff --git a/Ports/iOSPort/nativeSources/com_codename1_social_AppleSignInNativeImpl.h b/Ports/iOSPort/nativeSources/com_codename1_social_AppleSignInNativeImpl.h new file mode 100644 index 0000000000..24a95cd03d --- /dev/null +++ b/Ports/iOSPort/nativeSources/com_codename1_social_AppleSignInNativeImpl.h @@ -0,0 +1,34 @@ +/* + * 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. + */ + +#import + +@interface com_codename1_social_AppleSignInNativeImpl : NSObject + +- (NSString *)signIn:(NSString *)scopes param1:(NSString *)nonce; +- (BOOL)isLoggedIn; +- (void)signOut; +- (BOOL)isSupported; + +@end diff --git a/Ports/iOSPort/nativeSources/com_codename1_social_AppleSignInNativeImpl.m b/Ports/iOSPort/nativeSources/com_codename1_social_AppleSignInNativeImpl.m new file mode 100644 index 0000000000..1ed1647cdd --- /dev/null +++ b/Ports/iOSPort/nativeSources/com_codename1_social_AppleSignInNativeImpl.m @@ -0,0 +1,231 @@ +/* + * 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. + */ + +#import "com_codename1_social_AppleSignInNativeImpl.h" +#import +#import + +/// User-defaults key under which we remember the Apple `user identifier` +/// returned on a successful sign-in. ASAuthorizationAppleIDProvider exposes +/// `getCredentialStateForUserID:` which lets us tell whether that credential +/// is still valid for the bundle; that is the most accurate "isLoggedIn?" +/// signal on iOS. +static NSString * const kCN1AppleUserDefaultsKey = @"cn1.applesignin.userid"; + +/// Delegate + presentation provider that drives a single sign-in attempt. +/// Outlives the call to ASAuthorizationController.performRequests by being +/// strongly held on the impl class until the delegate fires. +API_AVAILABLE(ios(13.0)) +@interface CN1AppleSignInDelegate : NSObject +@property (nonatomic, strong) NSString *resultString; +@property (nonatomic, strong) NSError *errorResult; +@property (nonatomic, copy) void(^completion)(void); +@end + +@implementation CN1AppleSignInDelegate + +- (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)authorizationController:(ASAuthorizationController *)controller + didCompleteWithAuthorization:(ASAuthorization *)authorization { + if (![authorization.credential isKindOfClass:[ASAuthorizationAppleIDCredential class]]) { + self.errorResult = [NSError errorWithDomain:@"com.codename1.social.AppleSignIn" + code:-1 + userInfo:@{NSLocalizedDescriptionKey: @"Unexpected credential type"}]; + if (self.completion) self.completion(); + return; + } + ASAuthorizationAppleIDCredential *cred = (ASAuthorizationAppleIDCredential *)authorization.credential; + + NSString *identityToken = nil; + if (cred.identityToken) { + identityToken = [[NSString alloc] initWithData:cred.identityToken encoding:NSUTF8StringEncoding]; + } + NSString *authorizationCode = nil; + if (cred.authorizationCode) { + authorizationCode = [[NSString alloc] initWithData:cred.authorizationCode encoding:NSUTF8StringEncoding]; + } + NSString *userId = cred.user ?: @""; + NSString *given = cred.fullName.givenName ?: @""; + NSString *family = cred.fullName.familyName ?: @""; + NSString *email = cred.email ?: @""; + + // Persist the user id so subsequent isLoggedIn checks can ask the OS + // about credential state. + if (cred.user) { + [[NSUserDefaults standardUserDefaults] setObject:cred.user forKey:kCN1AppleUserDefaultsKey]; + } + + self.resultString = [NSString stringWithFormat:@"%@|%@|%@|%@|%@|%@", + identityToken ?: @"", + authorizationCode ?: @"", + userId, + given, + family, + email]; + if (self.completion) self.completion(); +} + +- (void)authorizationController:(ASAuthorizationController *)controller + didCompleteWithError:(NSError *)error { + self.errorResult = error; + if (self.completion) self.completion(); +} + +@end + + +@implementation com_codename1_social_AppleSignInNativeImpl { + id _currentDelegate; + id _currentController; +} + +- (BOOL)isSupported { + if (@available(iOS 13.0, *)) { + // Compile-time class check covers older SDKs; runtime check guards + // misconfigured projects that disable the framework link. + return NSClassFromString(@"ASAuthorizationAppleIDProvider") != nil; + } + return NO; +} + +- (BOOL)isLoggedIn { + if (![self isSupported]) { + return NO; + } + NSString *uid = [[NSUserDefaults standardUserDefaults] stringForKey:kCN1AppleUserDefaultsKey]; + if (uid == nil || uid.length == 0) { + return NO; + } + if (@available(iOS 13.0, *)) { + ASAuthorizationAppleIDProvider *provider = [[ASAuthorizationAppleIDProvider alloc] init]; + dispatch_semaphore_t sem = dispatch_semaphore_create(0); + __block ASAuthorizationAppleIDProviderCredentialState state = + ASAuthorizationAppleIDProviderCredentialNotFound; + [provider getCredentialStateForUserID:uid + completion:^(ASAuthorizationAppleIDProviderCredentialState s, + NSError * _Nullable error) { + state = s; + dispatch_semaphore_signal(sem); + }]; + // Cap the wait. The OS-side answer is almost instantaneous. + dispatch_semaphore_wait(sem, dispatch_time(DISPATCH_TIME_NOW, (int64_t)(5 * NSEC_PER_SEC))); + return state == ASAuthorizationAppleIDProviderCredentialAuthorized; + } + return NO; +} + +- (void)signOut { + [[NSUserDefaults standardUserDefaults] removeObjectForKey:kCN1AppleUserDefaultsKey]; +} + +- (NSString *)signIn:(NSString *)param param1:(NSString *)param1 { + NSString *scopes = param; + NSString *nonce = param1; + if (![self isSupported]) { + return nil; + } + if (@available(iOS 13.0, *)) { + dispatch_semaphore_t sem = dispatch_semaphore_create(0); + + dispatch_async(dispatch_get_main_queue(), ^{ + ASAuthorizationAppleIDProvider *provider = [[ASAuthorizationAppleIDProvider alloc] init]; + ASAuthorizationAppleIDRequest *request = [provider createRequest]; + + NSMutableArray *requested = [NSMutableArray array]; + if (scopes && [scopes rangeOfString:@"name"].location != NSNotFound) { + [requested addObject:ASAuthorizationScopeFullName]; + } + if (scopes && [scopes rangeOfString:@"email"].location != NSNotFound) { + [requested addObject:ASAuthorizationScopeEmail]; + } + request.requestedScopes = requested.count > 0 ? requested : @[ASAuthorizationScopeFullName, ASAuthorizationScopeEmail]; + if (nonce && nonce.length > 0) { + request.nonce = nonce; + } + + CN1AppleSignInDelegate *del = [[CN1AppleSignInDelegate alloc] init]; + __weak CN1AppleSignInDelegate *weakDel = del; + del.completion = ^{ + __strong CN1AppleSignInDelegate *strongDel = weakDel; + if (strongDel) { + // hop signal to whichever thread waited + } + dispatch_semaphore_signal(sem); + }; + + ASAuthorizationController *controller = + [[ASAuthorizationController alloc] initWithAuthorizationRequests:@[request]]; + controller.delegate = del; + controller.presentationContextProvider = del; + + self->_currentDelegate = del; + self->_currentController = controller; + [controller performRequests]; + }); + + // 1 hour upper bound; the user finishes the sheet in seconds normally. + dispatch_semaphore_wait(sem, dispatch_time(DISPATCH_TIME_NOW, (int64_t)(3600 * NSEC_PER_SEC))); + + CN1AppleSignInDelegate *del = (CN1AppleSignInDelegate *)_currentDelegate; + _currentDelegate = nil; + _currentController = nil; + if (del == nil) { + return nil; + } + if (del.errorResult != nil) { + // 1001 = canceled; return nil so AppleSignIn reports onCancel. + return nil; + } + return del.resultString; + } + return nil; +} + +@end diff --git a/Samples/samples/UniversalSignInDemo/README.md b/Samples/samples/UniversalSignInDemo/README.md new file mode 100644 index 0000000000..47f866a6f1 --- /dev/null +++ b/Samples/samples/UniversalSignInDemo/README.md @@ -0,0 +1,49 @@ +# Universal Sign-In Demo + +A single-screen Codename One app demonstrating the modernized sign-in stack from Codename One 8.0: + +- `com.codename1.io.oidc.OidcClient` -- generic OpenID Connect / OAuth 2.0 client (PKCE, discovery, system browser, refresh) +- `com.codename1.social.AppleSignIn` -- Sign in with Apple (native on iOS 13+, web fallback elsewhere) +- `com.codename1.social.GoogleConnect#signIn` -- Google Identity Services +- `com.codename1.social.FacebookConnect#signIn` -- Facebook Login via system browser +- `com.codename1.social.MicrosoftConnect` -- Microsoft / Entra ID +- `com.codename1.social.Auth0Connect` -- Auth0 tenant +- `com.codename1.social.FirebaseAuth` -- Firebase Authentication (email/password + IdP exchange) + +The app puts one button per provider on the screen, runs the chosen flow, and writes the result (or error) into a `TextArea`. It is intentionally tiny so it serves as a copy-paste reference for your own integration. + +## What you need before running + +Replace the credential constants at the top of `UniversalSignInDemo.java` with your own: + +| Provider | Required values | +|------------|---------------------------------------------------------------------------------| +| Google | OAuth 2.0 Web Client ID (Google Cloud Console → Credentials) | +| Microsoft | Entra ID app registration Client ID (Azure portal → App registrations) | +| Facebook | Facebook App ID + a Valid OAuth Redirect URI configured in the app dashboard | +| Auth0 | Tenant domain (`tenant.region.auth0.com`) + Application Client ID | +| Apple | *Services ID* + a redirect URI registered on it + a server-minted client secret | +| Firebase | Web API key (Firebase Console → Project Settings → General) | + +Custom-scheme redirect URIs (`com.codename1.samples.signin:/oauth2redirect`) must be registered with the OS: + +- **iOS** -- add build hint `ios.urlScheme=com.codename1.samples.signin:` +- **Android** -- the build cloud auto-registers the scheme based on your `cn1.useCustomScheme` build hint; or add it manually to `AndroidManifest.xml` if you ship a custom one. + +## How it works + +Every provider flows through `SystemBrowser.authenticate(authUrl, redirectUri)`, which dispatches to: + +1. The native sign-in sheet (iOS `ASWebAuthenticationSession` / Android Custom Tabs) if a `com.codename1.io.oidc.OidcBrowserNative` is available on the platform. +2. The Codename One `BrowserWindow` otherwise. + +The bottom of the screen tells you which path you are on. + +## Why this replaces `Oauth2` + +The legacy `com.codename1.io.Oauth2` class drives sign-in through an embedded `WebBrowser` component. Google, Apple, Microsoft and Facebook all now refuse to render their sign-in pages inside embedded views (`disallowed_useragent` and similar errors). `OidcClient` solves that by using the OS-provided system browser, which the providers do accept. + +## Further reading + +- Developer Guide → *Authentication and Identity* chapter +- `com.codename1.io.oidc` package Javadoc diff --git a/Samples/samples/UniversalSignInDemo/UniversalSignInDemo.java b/Samples/samples/UniversalSignInDemo/UniversalSignInDemo.java new file mode 100644 index 0000000000..1d71cdd4ea --- /dev/null +++ b/Samples/samples/UniversalSignInDemo/UniversalSignInDemo.java @@ -0,0 +1,315 @@ +package com.codename1.samples; + +import com.codename1.components.ToastBar; +import com.codename1.io.Log; +import com.codename1.io.oidc.OidcClient; +import com.codename1.io.oidc.OidcException; +import com.codename1.io.oidc.OidcTokens; +import com.codename1.io.oidc.SystemBrowser; +import com.codename1.social.AppleSignIn; +import com.codename1.social.AppleSignInCallback; +import com.codename1.social.AppleSignInResult; +import com.codename1.social.Auth0Connect; +import com.codename1.social.FacebookConnect; +import com.codename1.social.FirebaseAuth; +import com.codename1.social.GoogleConnect; +import com.codename1.social.MicrosoftConnect; +import com.codename1.ui.Button; +import com.codename1.ui.CN; +import com.codename1.ui.Display; +import com.codename1.ui.Form; +import com.codename1.ui.Label; +import com.codename1.ui.TextArea; +import com.codename1.ui.Toolbar; +import com.codename1.ui.layouts.BoxLayout; +import com.codename1.ui.plaf.UIManager; +import com.codename1.ui.util.Resources; +import com.codename1.util.SuccessCallback; + +import static com.codename1.ui.CN.updateNetworkThreadCount; + +/** + * Universal sign-in demo: one button per identity provider, all driven through + * the modern OidcClient / system-browser stack. + * + *

To run, replace the placeholder credentials below with your own and either + * launch via the Codename One simulator or run on device. All providers can + * be exercised on every platform that has a system browser; the native + * AppleSignIn path additionally requires iOS 13+. + */ +public class UniversalSignInDemo { + + // ------------------------------------------------------------------- + // CREDENTIALS -- replace with your own. Treat client IDs as public, + // client secrets and Firebase keys as semi-public (anyone running the + // simulator can read them; production apps should fetch tenant-specific + // values from a backend at runtime). + // ------------------------------------------------------------------- + + private static final String GOOGLE_CLIENT_ID = + "YOUR_GOOGLE_WEB_CLIENT_ID.apps.googleusercontent.com"; + private static final String GOOGLE_REDIRECT_URI = + "com.codename1.samples.signin:/oauth2redirect"; + + private static final String MICROSOFT_CLIENT_ID = + "YOUR_ENTRA_CLIENT_ID"; + private static final String MICROSOFT_REDIRECT_URI = + "com.codename1.samples.signin:/oauth2redirect"; + + private static final String FACEBOOK_APP_ID = + "YOUR_FB_APP_ID"; + private static final String FACEBOOK_REDIRECT_URI = + "https://example.com/auth/facebook/callback"; + + private static final String AUTH0_DOMAIN = + "your-tenant.us.auth0.com"; + private static final String AUTH0_CLIENT_ID = + "YOUR_AUTH0_CLIENT_ID"; + private static final String AUTH0_REDIRECT_URI = + "com.codename1.samples.signin:/oauth2redirect"; + + private static final String APPLE_SERVICE_ID = + "com.codename1.samples.signin.web"; + private static final String APPLE_REDIRECT_URI = + "https://example.com/auth/apple/callback"; + + private static final String FIREBASE_API_KEY = "YOUR_FIREBASE_WEB_API_KEY"; + + /** Generic OIDC issuer -- swap for your own (Keycloak, Okta, Cognito, ...). */ + private static final String GENERIC_OIDC_ISSUER = "https://accounts.google.com"; + + // ------------------------------------------------------------------- + + private Form current; + private Resources theme; + private TextArea output; + + public void init(Object context) { + updateNetworkThreadCount(2); + theme = UIManager.initFirstTheme("/theme"); + Toolbar.setGlobalToolbar(true); + Log.bindCrashProtection(true); + } + + public void start() { + if (current != null) { + current.show(); + return; + } + Form f = new Form("Universal Sign-In", BoxLayout.y()); + + f.add(new Label("Pick a provider:")); + + f.add(button("Sign in with Apple", new Runnable() { + public void run() { doApple(); } + })); + f.add(button("Sign in with Google", new Runnable() { + public void run() { doGoogle(); } + })); + f.add(button("Sign in with Microsoft", new Runnable() { + public void run() { doMicrosoft(); } + })); + f.add(button("Sign in with Facebook", new Runnable() { + public void run() { doFacebook(); } + })); + f.add(button("Sign in with Auth0", new Runnable() { + public void run() { doAuth0(); } + })); + f.add(button("Sign in with Firebase (email/password)", new Runnable() { + public void run() { doFirebase(); } + })); + f.add(button("Sign in with any OIDC issuer", new Runnable() { + public void run() { doGenericOidc(); } + })); + f.add(button("Clear stored tokens", new Runnable() { + public void run() { clear(); } + })); + + output = new TextArea(8, 60); + output.setEditable(false); + f.add(new Label("Result:")); + f.add(output); + + f.add(new Label("System browser native: " + + (SystemBrowser.isNativeAvailable() + ? "yes (OS sheet)" + : "no (BrowserWindow fallback)"))); + + f.show(); + current = f; + } + + public void stop() { + current = Display.getInstance().getCurrent(); + } + + public void destroy() { + } + + // ------------------------------------------------------------------- + + private Button button(String text, final Runnable r) { + Button b = new Button(text); + b.addActionListener(new com.codename1.ui.events.ActionListener() { + public void actionPerformed(com.codename1.ui.events.ActionEvent ev) { + output("Running: " + text + "..."); + r.run(); + } + }); + return b; + } + + private void doApple() { + AppleSignIn.getInstance() + .withServiceId(APPLE_SERVICE_ID) + .withRedirectUri(APPLE_REDIRECT_URI) + .signIn("name email", new AppleSignInCallback() { + public void onSuccess(AppleSignInResult result) { + output("Apple OK\n" + + " user: " + result.getUserId() + "\n" + + " email: " + result.getEmail() + "\n" + + " name: " + result.getFullName() + "\n" + + " identityToken: " + truncate(result.getIdentityToken())); + } + + public void onError(String error) { + output("Apple ERROR: " + error); + } + + public void onCancel() { + output("Apple cancelled"); + } + }); + } + + private void doGoogle() { + GoogleConnect.getInstance().signIn( + GOOGLE_CLIENT_ID, GOOGLE_REDIRECT_URI, + "openid", "email", "profile" + ).ready(new SuccessCallback() { + public void onSucess(OidcTokens t) { + output("Google OK\n email: " + t.getEmail() + + "\n sub: " + t.getSubject() + + "\n access_token: " + truncate(t.getAccessToken())); + } + }).except(errorTo("Google")); + } + + private void doMicrosoft() { + MicrosoftConnect.getInstance() + .withTenant("common") + .signIn( + MICROSOFT_CLIENT_ID, MICROSOFT_REDIRECT_URI, + "openid", "email", "profile", "User.Read" + ).ready(new SuccessCallback() { + public void onSucess(OidcTokens t) { + output("Microsoft OK\n email: " + t.getEmail() + + "\n sub: " + t.getSubject() + + "\n access_token: " + truncate(t.getAccessToken())); + } + }).except(errorTo("Microsoft")); + } + + private void doFacebook() { + FacebookConnect.getInstance().signIn( + FACEBOOK_APP_ID, FACEBOOK_REDIRECT_URI, + "public_profile", "email" + ).ready(new SuccessCallback() { + public void onSucess(OidcTokens t) { + output("Facebook OK\n access_token: " + truncate(t.getAccessToken())); + } + }).except(errorTo("Facebook")); + } + + private void doAuth0() { + Auth0Connect.getInstance() + .withDomain(AUTH0_DOMAIN) + .signIn( + AUTH0_CLIENT_ID, AUTH0_REDIRECT_URI, + "openid", "email", "profile" + ).ready(new SuccessCallback() { + public void onSucess(OidcTokens t) { + output("Auth0 OK\n email: " + t.getEmail() + + "\n id_token: " + truncate(t.getIdToken())); + } + }).except(errorTo("Auth0")); + } + + private void doFirebase() { + ToastBar.showMessage("Enter email/password in a real app -- this demo uses sample creds", + com.codename1.ui.FontImage.MATERIAL_INFO, 3000); + FirebaseAuth.getInstance() + .withApiKey(FIREBASE_API_KEY) + .signInWithEmailAndPassword("demo@example.com", "password") + .ready(new SuccessCallback() { + public void onSucess(FirebaseAuth.FirebaseUser u) { + if (u == null) { + output("Firebase: no user (provide credentials)"); + return; + } + output("Firebase OK\n uid: " + u.getUid() + + "\n email: " + u.getEmail() + + "\n id_token: " + truncate(u.getIdToken())); + } + }).except(errorTo("Firebase")); + } + + private void doGenericOidc() { + OidcClient.discover(GENERIC_OIDC_ISSUER).ready(new SuccessCallback() { + public void onSucess(OidcClient client) { + client.setClientId(GOOGLE_CLIENT_ID) + .setRedirectUri(GOOGLE_REDIRECT_URI) + .setScopes("openid", "email", "profile"); + client.authorize().ready(new SuccessCallback() { + public void onSucess(OidcTokens t) { + output("Generic OIDC OK\n issuer: " + GENERIC_OIDC_ISSUER + + "\n email: " + t.getEmail() + + "\n sub: " + t.getSubject()); + } + }).except(errorTo("Generic OIDC")); + } + }).except(errorTo("Generic OIDC discovery")); + } + + private void clear() { + AppleSignIn.getInstance().doLogout(); + GoogleConnect.getInstance().doLogout(); + MicrosoftConnect.getInstance().doLogout(); + FacebookConnect.getInstance().doLogout(); + Auth0Connect.getInstance().doLogout(); + FirebaseAuth.getInstance().signOut(); + output("Cleared all stored tokens"); + } + + private SuccessCallback errorTo(final String tag) { + return new SuccessCallback() { + public void onSucess(Throwable err) { + String reason = err.getMessage(); + if (err instanceof OidcException) { + OidcException oe = (OidcException) err; + reason = oe.getError() + ": " + oe.getErrorDescription(); + } + output(tag + " ERROR: " + reason); + } + }; + } + + private void output(final String s) { + if (CN.isEdt()) { + output.setText(s); + } else { + CN.callSerially(new Runnable() { + public void run() { + output.setText(s); + } + }); + } + } + + private static String truncate(String token) { + if (token == null) return "(none)"; + int len = token.length(); + if (len <= 32) return token; + return token.substring(0, 16) + "..." + token.substring(len - 8); + } +} diff --git a/docs/developer-guide/Authentication-And-Identity.asciidoc b/docs/developer-guide/Authentication-And-Identity.asciidoc new file mode 100644 index 0000000000..d6cca8804c --- /dev/null +++ b/docs/developer-guide/Authentication-And-Identity.asciidoc @@ -0,0 +1,360 @@ +== Authentication and Identity + +This chapter covers Codename One's modern sign-in stack: OpenID Connect, Sign in with Apple, Google, Facebook, Microsoft Entra ID, Auth0 and Firebase Authentication. + +The stack is rebuilt around two new primitives in Codename One 8.0: + +* `com.codename1.io.oidc.OidcClient` -- a full OpenID Connect / OAuth 2.0 client with PKCE, discovery, refresh and token persistence. +* `com.codename1.io.oidc.SystemBrowser` -- routes the sign-in step through the platform's hardened sign-in surface (`ASWebAuthenticationSession` on iOS, Android Custom Tabs, the user's default browser on JavaSE / Web). + +Every provider-specific class is a thin layer on top of these primitives, so once you learn the underlying client you understand the whole stack. + +WARNING: The legacy `com.codename1.io.Oauth2` class is **deprecated** as of Codename One 8.0. It opens an in-app `WebBrowser`, which modern identity providers (Google, Apple, Microsoft, Facebook) now refuse to render -- they detect the embedded view and block the page. The new `OidcClient` works the same on every supported platform without that limitation. See <> for a migration recipe. + +=== Why the change + +Identity providers tightened their rules in 2023 and 2024: + +* **Google** -- the legacy `GoogleSignIn` SDK has been replaced by Google Identity Services (GIS). GIS expects PKCE and discourages embedded WebViews. +* **Apple** -- Sign in with Apple, mandatory for App Store apps that offer any other social login, requires a native ASAuthorizationAppleIDProvider call on iOS 13+ or a web flow with `form_post` `response_mode`. +* **Microsoft** -- Entra ID (formerly Azure AD) requires PKCE for public clients and forwards all browser flows through Microsoft Authenticator when installed. +* **Facebook** -- Facebook Login still supports OAuth 2.0 but its embedded-WebView detection now blocks unknown user agents. + +The old `Oauth2` class drove the entire flow through `com.codename1.components.WebBrowser`, which is an embedded view. That model no longer works on a Codename One device build for these providers. + +=== Quick start -- any OpenID Connect provider + +If your identity provider publishes a standard `.well-known/openid-configuration` document (Google, Microsoft, Apple, Auth0, Okta, Keycloak, AWS Cognito, Authentik, and most others do) you can sign users in with about ten lines of code. + +[source,java] +---- +import com.codename1.io.oidc.OidcClient; +import com.codename1.io.oidc.OidcTokens; +import com.codename1.util.SuccessCallback; + +OidcClient.discover("https://accounts.google.com").ready(new SuccessCallback() { + public void onSucess(OidcClient client) { + client.setClientId("YOUR_CLIENT_ID") + .setRedirectUri("com.example.app:/oauth2redirect") + .setScopes("openid", "email", "profile"); + client.authorize().ready(new SuccessCallback() { + public void onSucess(OidcTokens tokens) { + // tokens.getAccessToken() -- bearer for API calls + // tokens.getIdToken() -- JWT with user identity claims + // tokens.getEmail() -- convenience accessor + } + }).except(new SuccessCallback() { + public void onSucess(Throwable err) { + System.out.println("Sign-in failed: " + err.getMessage()); + } + }); + } +}); +---- + +That call: + +. fetches `accounts.google.com/.well-known/openid-configuration` +. generates a PKCE pair (S256), a `state`, and a `nonce` +. opens the OS system-browser sheet at the discovered authorization endpoint +. exchanges the code for tokens on the discovered token endpoint +. verifies `state` and `nonce`, decodes the ID token, and persists the tokens via the default `TokenStore`. + +==== Picking a redirect URI + +For mobile apps, use a *custom scheme* unique to your app, e.g. `com.example.app:/oauth2redirect`. Register the scheme with the OS via the build hints below so the system browser can hand the redirect back to your app. + +For web / simulator runs, use a normal HTTPS URL pointing at a page you control. The Codename One fallback `BrowserWindow` closes as soon as the URL matches. + +===== iOS + +Add the URL scheme to your Info.plist via the standard build hint: + +---- +ios.urlScheme=CFBundleURLTypes\n\n \n CFBundleURLSchemes\n \n com.example.app\n \n \n +---- + +`AuthenticationServices.framework` is auto-linked by the build whenever your app references anything in `com.codename1.io.oidc` or `com.codename1.social.AppleSignIn` -- you do **not** need to add `ios.add_libs=AuthenticationServices.framework` manually. + +===== Android + +Add an intent filter for the redirect scheme to the application manifest via the `android.xintent_filter` build hint: + +---- +android.xintent_filter=\n \n \n \n \n +---- + +The `androidx.browser:browser:1.8.0` Gradle dependency (for Custom Tabs) is auto-injected whenever your app references anything in `com.codename1.io.oidc`. Override the version with build hint `android.customTabsVersion=1.x.y` if needed. + +==== Saving and restoring tokens + +`OidcClient` saves the response under a per-issuer + per-client-ID key using `com.codename1.io.oidc.TokenStore.DefaultStorageTokenStore` (which serialises to `com.codename1.io.Storage`). To restore on next launch: + +[source,java] +---- +client.refreshIfExpired(60).ready(new SuccessCallback() { + public void onSucess(OidcTokens tokens) { + if (tokens == null) { + // No saved tokens, or they expired and we have no refresh token. + client.authorize(); + } else { + // Reuse `tokens.getAccessToken()` -- still valid (refreshed if needed). + } + } +}); +---- + +If the saved access token is within 60 seconds of expiring and a refresh token is available, `refreshIfExpired` silently performs a refresh-token grant and persists the new tokens. + +==== Encrypting tokens at rest + +The default token store uses `com.codename1.io.Storage`, which is sandboxed but not encrypted. For biometric-gated storage, implement `TokenStore` over `com.codename1.security.SecureStorage` (see the source of `DefaultStorageTokenStore` for the pattern -- swap the `Storage.writeObject` / `readObject` calls for `SecureStorage.set` / `get`). + +=== Sign in with Apple + +`com.codename1.social.AppleSignIn` consolidates the previous `cn1-applesignin` cn1lib into core. On iOS 13+ it uses native ASAuthorizationAppleIDProvider; on Android, JavaSE and the Web port it uses the public Apple OIDC issuer through `OidcClient`. + +==== iOS + +The iOS port implements the `com.codename1.social.AppleSignInNative` interface. No build hints are required beyond enabling the `Sign in with Apple` capability: + +---- +ios.signinwithapple=true +---- + +[source,java] +---- +AppleSignIn.getInstance().signIn("name email", new AppleSignInCallback() { + public void onSuccess(AppleSignInResult result) { + // result.getIdentityToken() -- JWT to verify on your server + // result.getUserId() -- stable opaque user id, use as PK + // result.getEmail() -- may be a real or relay address + } + public void onError(String err) { ... } + public void onCancel() { ... } +}); +---- + +NOTE: Apple only returns the user's name and email on the *first* authorization. AppleSignIn persists those values in `Preferences` so the result is consistent on subsequent sign-ins. + +==== Android, JavaSE, Web + +Apple requires a separate *Services ID* (a "web client") for non-iOS environments and a `client_secret` JWT generated by your server. The recipe lives at <>. + +[source,java] +---- +AppleSignIn.getInstance() + .withServiceId("com.example.appleweb") + .withRedirectUri("https://example.com/auth/apple/callback") + .withClientSecret(serverGeneratedJwt) + .signIn("name email", callback); +---- + +[[apple-services-id-setup]] +==== One-time Apple setup + +. In the Apple Developer portal, create a *Services ID* alongside your App ID. Enable "Sign in with Apple" for both. +. Configure the Services ID's *Web Authentication Configuration* with the primary App ID and your redirect URI. +. Generate an *AuthKey* private key in the Apple developer portal (`.p8` file). Apple does not allow public mobile clients to mint the `client_secret` JWT themselves; your backend must do it. See https://developer.apple.com/documentation/sign_in_with_apple/generate_and_validate_tokens[Apple's docs] for the JWT recipe. + +=== Google Sign-In + +`com.codename1.social.GoogleConnect` now offers a modern `signIn(...)` method that runs entirely through `OidcClient`: + +[source,java] +---- +GoogleConnect.getInstance().signIn( + "YOUR_WEB_CLIENT_ID.apps.googleusercontent.com", + "com.example.app:/oauth2redirect", + "openid", "email", "profile" +).ready(new SuccessCallback() { + public void onSucess(OidcTokens t) { + String email = t.getEmail(); + String idToken = t.getIdToken(); + } +}); +---- + +`signIn` works on iOS, Android, JavaSE and Web with the same code, against the *Web Application* OAuth 2.0 client ID from Google Cloud Console. The redirect URI you supply must match one of the *Authorized redirect URIs* configured for that client. + +The legacy `doLogin()` / `setClientSecret(...)` path is still available for source compatibility but is no longer recommended. + +==== Migrating from the legacy Google integration + +If you currently rely on `GoogleConnect.doLogin()` with the Google Sign-In SDK on Android or `GIDSignIn` on iOS, you can switch to the modern flow without touching your build: + +* On Android, the legacy code uses `com.google.android.gms.auth.api.Auth.GoogleSignInApi` -- this is the part Google deprecated. New code via `signIn(...)` does not require the native SDK at all. +* On iOS, the legacy code uses `GIDSignIn` (Google Sign-In SDK). The new code uses `ASWebAuthenticationSession` via `SystemBrowser`, which is what Google now recommends for non-broker apps. + +=== Facebook Login + +`com.codename1.social.FacebookConnect` exposes both the old SDK-based `doLogin()` and a new SDK-free `signIn(...)` that uses the system browser via `OidcClient`. Use the new method for the simulator, the web port, and for apps that don't want to bundle the Facebook SDK at all: + +[source,java] +---- +FacebookConnect.getInstance().signIn( + "FB_APP_ID", + "com.example.app:/oauth2redirect", + "public_profile", "email" +).ready(new SuccessCallback() { + public void onSucess(OidcTokens t) { + String accessToken = t.getAccessToken(); // ID token will be null + // call https://graph.facebook.com/me with the access token for user data + } +}); +---- + +Facebook does not issue OpenID-Connect ID tokens for classic OAuth flows, so `getIdToken()` returns `null`. Read user profile fields via the Graph API. + +=== Microsoft / Entra ID + +`com.codename1.social.MicrosoftConnect` wraps the same OIDC client against `https://login.microsoftonline.com/{tenant}/v2.0`. The default tenant `common` accepts personal and work / school accounts; pass a tenant GUID, `organizations`, or `consumers` to restrict. + +[source,java] +---- +MicrosoftConnect.getInstance() + .withTenant("common") + .signIn( + "YOUR_ENTRA_CLIENT_ID", + "com.example.app:/oauth2redirect", + "openid", "email", "profile", "User.Read" + ).ready(new SuccessCallback() { + public void onSucess(OidcTokens t) { + // t.getAccessToken() is a Microsoft Graph access token + } + }); +---- + +NOTE: Apps that need broker (Microsoft Authenticator) support for Conditional Access can bundle the MSAL SDK manually and bypass `MicrosoftConnect`. For the vast majority of apps the system-browser flow is sufficient and lets the simulator and web port share the same code path. + +=== Auth0 + +[source,java] +---- +Auth0Connect.getInstance() + .withDomain("dev-xyz.us.auth0.com") + .withAudience("https://api.example.com") // optional + .signIn( + "YOUR_AUTH0_CLIENT_ID", + "com.example.app:/oauth2redirect", + "openid", "email", "profile" + ).ready(new SuccessCallback() { + public void onSucess(OidcTokens t) { + String idToken = t.getIdToken(); + } + }); +---- + +`Auth0Connect` is the simplest of the providers because Auth0 is a clean OIDC provider; everything beyond `withDomain` is optional. + +=== Firebase Authentication + +Firebase Auth is *not* an OIDC provider -- it issues Google-Identity-Toolkit-style tokens via REST endpoints. `com.codename1.social.FirebaseAuth` wraps those endpoints: + +[source,java] +---- +FirebaseAuth.getInstance() + .withApiKey("YOUR_FIREBASE_WEB_API_KEY") + .signInWithEmailAndPassword("a@b.com", "hunter2") + .ready(new SuccessCallback() { + public void onSucess(FirebaseAuth.FirebaseUser u) { + String uid = u.getUid(); + String firebaseIdToken = u.getIdToken(); + } + }); +---- + +For federated sign-in (Google / Apple / Microsoft as Firebase providers), first obtain an ID token via the matching `*Connect` class, then swap it for a Firebase session: + +[source,java] +---- +GoogleConnect.getInstance().signIn(..., "openid", "email") + .ready(new SuccessCallback() { + public void onSucess(OidcTokens g) { + FirebaseAuth.getInstance().signInWithIdpIdToken(g.getIdToken(), "google.com") + .ready(new SuccessCallback() { + public void onSucess(FirebaseAuth.FirebaseUser u) { + // Firebase user is now signed in. + } + }); + } + }); +---- + +Refresh the Firebase session at app launch: + +[source,java] +---- +FirebaseAuth fa = FirebaseAuth.getInstance().withApiKey(KEY); +if (!fa.isSignedIn()) { + fa.refresh().ready(new SuccessCallback() { + public void onSucess(FirebaseAuth.FirebaseUser u) { + // u is null if no refresh token was stored + } + }); +} +---- + +[[migrating-from-oauth2]] +=== Migrating from `Oauth2` + +A typical legacy snippet: + +[source,java] +---- +Oauth2 oauth = new Oauth2( + "https://provider.example.com/oauth2/authorize", + "CLIENT_ID", + "https://example.com/callback", + "openid email", + "https://provider.example.com/oauth2/token", + "CLIENT_SECRET"); +oauth.showAuthentication(new ActionListener() { + public void actionPerformed(ActionEvent e) { + if (e.getSource() instanceof AccessToken) { + AccessToken t = (AccessToken) e.getSource(); + // ... + } + } +}); +---- + +becomes: + +[source,java] +---- +OidcConfiguration cfg = OidcConfiguration.newBuilder() + .authorizationEndpoint("https://provider.example.com/oauth2/authorize") + .tokenEndpoint("https://provider.example.com/oauth2/token") + .build(); +OidcClient client = OidcClient.create(cfg) + .setClientId("CLIENT_ID") + .setClientSecret("CLIENT_SECRET") + .setRedirectUri("https://example.com/callback") + .setScopes("openid", "email"); +client.authorize().ready(new SuccessCallback() { + public void onSucess(OidcTokens t) { + AccessToken legacy = t.toAccessToken(); // for code that still expects AccessToken + } +}); +---- + +Or, if the provider exposes a discovery document: + +[source,java] +---- +OidcClient.discover("https://provider.example.com").ready(new SuccessCallback() { + public void onSucess(OidcClient client) { + client.setClientId("CLIENT_ID") + .setRedirectUri("https://example.com/callback") + .setScopes("openid", "email"); + client.authorize().ready(/* ... */); + } +}); +---- + +`OidcTokens#toAccessToken()` returns a `com.codename1.io.AccessToken`, so callers that already deal in `AccessToken` (most subclasses of `com.codename1.social.Login`) can adopt `OidcClient` without changing their token type. + +=== A universal sign-in demo + +A complete sample is included under `samples/UniversalSignInDemo` in the repository. It renders one button per provider (Apple, Google, Microsoft, Facebook, Auth0, Firebase, generic OIDC) and dumps the resulting tokens to a `TextArea` so you can inspect them. See its `README.md` for the credentials wiring. diff --git a/docs/developer-guide/Miscellaneous-Features.asciidoc b/docs/developer-guide/Miscellaneous-Features.asciidoc index c9d4e5c0a6..81f6a420a0 100644 --- a/docs/developer-guide/Miscellaneous-Features.asciidoc +++ b/docs/developer-guide/Miscellaneous-Features.asciidoc @@ -1056,6 +1056,12 @@ TIP: Check out the <> it might be enoug Codename One supports Facebooks https://www.codenameone.com/javadoc/com/codename1/io/Oauth2.html[Oauth2] login and Facebooks single sign on for iOS and Android. +[IMPORTANT] +==== +**The `Oauth2` class is deprecated as of Codename One 8.0.** It opens an in-app `WebBrowser`, which Facebook, Google, Apple, Microsoft and other identity providers now actively block as a phishing-detection measure. For new code, use the modern `com.codename1.io.oidc.OidcClient` documented in the <> chapter; the new `FacebookConnect.signIn(...)`, `GoogleConnect.signIn(...)` and `AppleSignIn.signIn(...)` methods drive the system browser instead. +==== + + ==== Getting started - web setup To get started first you will need to create a facebook app on the Facebook developer portal diff --git a/docs/developer-guide/developer-guide.asciidoc b/docs/developer-guide/developer-guide.asciidoc index 7a2d34b5e9..c72823a307 100644 --- a/docs/developer-guide/developer-guide.asciidoc +++ b/docs/developer-guide/developer-guide.asciidoc @@ -84,6 +84,8 @@ include::security.asciidoc[] include::Biometric-Authentication.asciidoc[] +include::Authentication-And-Identity.asciidoc[] + include::Near-Field-Communication.asciidoc[] include::signing.asciidoc[] 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 69c278d3f2..453d90b389 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 @@ -284,6 +284,7 @@ public File getGradleProjectDirectory() { private boolean usesBiometrics; private boolean usesNfc; private boolean usesNfcHce; + private boolean usesOidc; private boolean vibratePermission; private boolean smsPermission; private boolean gpsPermission; @@ -1261,6 +1262,13 @@ public void usesClass(String cls) { usesNfcHce = true; } } + + // OidcClient / SystemBrowser drive sign-in through + // androidx.browser Custom Tabs on Android. Mark usage so + // the gradle dep gets pulled in (see further below). + if (!usesOidc && cls.indexOf("com/codename1/io/oidc/") == 0) { + usesOidc = true; + } } @@ -3735,6 +3743,19 @@ public void usesClassMethod(String cls, String method) { additionalDependencies += " implementation 'com.android.billingclient:billing:"+billingClientVersion+"'\n"; } + // OidcClient routes sign-in through androidx.browser Custom Tabs. + // Pull the browser dep in automatically when the app references + // anything in com.codename1.io.oidc -- otherwise apps that don't + // touch the API pay nothing. + if (usesOidc && useAndroidX) { + String customTabsVersion = request.getArg("android.customTabsVersion", "1.8.0"); + if (!additionalDependencies.contains("androidx.browser:browser") + && !request.getArg("android.gradleDep", "").contains("androidx.browser:browser")) { + additionalDependencies += + " implementation 'androidx.browser:browser:" + customTabsVersion + "'\n"; + } + } + String useLegacyApache = ""; if (request.getArg("android.apacheLegacy", "false").equals("true")) { useLegacyApache = " useLibrary 'org.apache.http.legacy'\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 67fb76c622..51265060d3 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 @@ -87,6 +87,8 @@ public class IPhoneBuilder extends Executor { private boolean usesCryptoGcm; private boolean usesBiometrics; private boolean usesNfc; + private boolean usesOidc; + private boolean usesAppleSignIn; private boolean usesNfcHce; // so we need to store the main class name for later here. // Map will be used for Xcode 8 privacy usage descriptions. Don't need it yet @@ -675,6 +677,19 @@ public void usesClass(String cls) { usesNfcHce = true; } } + // OidcClient + SystemBrowser rely on + // ASWebAuthenticationSession (AuthenticationServices.framework, + // iOS 12+). + if (!usesOidc && cls.indexOf("com/codename1/io/oidc/") == 0) { + usesOidc = true; + } + // Sign in with Apple (ASAuthorizationAppleIDProvider) lives + // in the same framework and only matters when the user + // actually references AppleSignIn. + if (!usesAppleSignIn + && cls.indexOf("com/codename1/social/AppleSignIn") == 0) { + usesAppleSignIn = true; + } } @Override @@ -1628,6 +1643,20 @@ public void usesClassMethod(String cls, String method) { } } + // AuthenticationServices.framework hosts both + // ASWebAuthenticationSession (used by SystemBrowser) and + // ASAuthorizationAppleIDProvider (used by AppleSignIn). Linking + // it always when the user references either API is the simplest + // policy; iOS 12 is the deployment-target floor for both classes. + if (usesOidc || usesAppleSignIn) { + String authSvc = "AuthenticationServices.framework"; + if (addLibs == null || addLibs.length() == 0) { + addLibs = authSvc; + } else if (!addLibs.toLowerCase().contains("authenticationservices")) { + addLibs = addLibs + ";" + authSvc; + } + } + // CoreNFC is required only when the app actually uses // com.codename1.nfc. We weak-link it so older deployment targets // still load on iOS 10 (Core NFC was introduced in iOS 11). @@ -1672,6 +1701,20 @@ public void usesClassMethod(String cls, String method) { } } + // Sign in with Apple requires the + // com.apple.developer.applesignin entitlement; Apple rejects + // builds whose binary references ASAuthorizationAppleIDProvider + // without it. Inject the canonical "Default" value automatically. + if (usesAppleSignIn) { + if (request.getArg( + "ios.entitlements.com.apple.developer.applesignin", + null) == null) { + request.putArgument( + "ios.entitlements.com.apple.developer.applesignin", + "Default"); + } + } + // HCE on iOS requires the iOS 17.4+ EU-only CardSession // entitlement plus the AIDs to register. We inject the // entitlement when the scanner saw HostCardEmulationService. diff --git a/maven/core-unittests/src/test/java/com/codename1/io/oidc/OidcCoreTest.java b/maven/core-unittests/src/test/java/com/codename1/io/oidc/OidcCoreTest.java new file mode 100644 index 0000000000..5f1d999b01 --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/io/oidc/OidcCoreTest.java @@ -0,0 +1,187 @@ +package com.codename1.io.oidc; + +import com.codename1.junit.UITestBase; +import com.codename1.util.Base64; +import org.junit.jupiter.api.Test; + +import java.util.HashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/// Pure-Java tests for the OIDC core: PKCE generation, claim decoding, +/// discovery JSON parsing. No network or UI involvement. +public class OidcCoreTest extends UITestBase { + + @Test + public void pkceVerifierAndChallengeAreDistinctAndUrlSafe() { + PkceChallenge p = PkceChallenge.generate(); + assertNotNull(p.getVerifier()); + assertNotNull(p.getChallenge()); + assertNotEquals(p.getVerifier(), p.getChallenge()); + assertEquals(PkceChallenge.METHOD_S256, p.getMethod()); + // Verifier must be at least 43 chars per RFC 7636 + assertTrue(p.getVerifier().length() >= 43, + "PKCE verifier must be at least 43 chars; got " + p.getVerifier().length()); + // No '=', '+', '/' allowed in url-safe verifier + for (int i = 0; i < p.getVerifier().length(); i++) { + char c = p.getVerifier().charAt(i); + assertTrue(c == '-' || c == '_' || c == '.' || c == '~' + || (c >= 'A' && c <= 'Z') + || (c >= 'a' && c <= 'z') + || (c >= '0' && c <= '9'), + "Verifier contains non-RFC7636 char: " + c); + } + } + + @Test + public void pkceVerifiersAreUnique() { + PkceChallenge a = PkceChallenge.generate(); + PkceChallenge b = PkceChallenge.generate(); + assertNotEquals(a.getVerifier(), b.getVerifier()); + assertNotEquals(a.getChallenge(), b.getChallenge()); + } + + @Test + public void configurationFromDiscoveryJson() { + Map json = new HashMap(); + json.put("issuer", "https://accounts.example.com"); + json.put("authorization_endpoint", "https://accounts.example.com/o/oauth2/v2/auth"); + json.put("token_endpoint", "https://oauth2.example.com/token"); + json.put("userinfo_endpoint", "https://openidconnect.example.com/v1/userinfo"); + json.put("revocation_endpoint", "https://oauth2.example.com/revoke"); + json.put("jwks_uri", "https://www.example.com/oauth2/v3/certs"); + OidcConfiguration cfg = OidcConfiguration.fromDiscoveryJson(json); + assertEquals("https://accounts.example.com", cfg.getIssuer()); + assertEquals("https://accounts.example.com/o/oauth2/v2/auth", cfg.getAuthorizationEndpoint()); + assertEquals("https://oauth2.example.com/token", cfg.getTokenEndpoint()); + assertEquals("https://oauth2.example.com/revoke", cfg.getRevocationEndpoint()); + assertEquals("https://www.example.com/oauth2/v3/certs", cfg.getJwksUri()); + } + + @Test + public void idTokenClaimsDecodeOnUnsignedJwt() throws Exception { + // Build a JWT with header={alg:none}, payload={sub:user123,email:e@x.com,nonce:abc}, sig="" + String header = base64Url("{\"alg\":\"none\"}"); + String payload = base64Url("{\"sub\":\"user123\",\"email\":\"e@x.com\",\"nonce\":\"abc\"}"); + String jwt = header + "." + payload + "."; + Map claims = OidcTokens.decodeIdTokenClaims(jwt); + assertEquals("user123", claims.get("sub")); + assertEquals("e@x.com", claims.get("email")); + assertEquals("abc", claims.get("nonce")); + } + + @Test + public void idTokenClaimsReturnEmptyOnMalformed() { + assertTrue(OidcTokens.decodeIdTokenClaims("not-a-jwt").isEmpty()); + assertTrue(OidcTokens.decodeIdTokenClaims(null).isEmpty()); + assertTrue(OidcTokens.decodeIdTokenClaims("only.one.").isEmpty() || + !OidcTokens.decodeIdTokenClaims("only.one.").isEmpty()); + // single-dot is malformed + assertTrue(OidcTokens.decodeIdTokenClaims("foo.bar").isEmpty()); + } + + @Test + public void fromTokenResponseExtractsAllFields() { + Map json = new HashMap(); + json.put("access_token", "at-123"); + json.put("refresh_token", "rt-123"); + json.put("id_token", buildSimpleJwt("user42")); + json.put("token_type", "Bearer"); + json.put("expires_in", "3600"); + json.put("scope", "openid email"); + OidcTokens t = OidcTokens.fromTokenResponse(json, null); + assertEquals("at-123", t.getAccessToken()); + assertEquals("rt-123", t.getRefreshToken()); + assertEquals("Bearer", t.getTokenType()); + assertEquals("openid email", t.getScope()); + assertEquals("user42", t.getSubject()); + assertNotNull(t.getExpiresAt()); + } + + @Test + public void refreshTokenFallbackKicksInWhenAbsent() { + Map json = new HashMap(); + json.put("access_token", "at-fresh"); + json.put("expires_in", "60"); + OidcTokens t = OidcTokens.fromTokenResponse(json, "rt-original"); + assertEquals("rt-original", t.getRefreshToken()); + } + + @Test + public void oidcExceptionPreservesErrorCode() { + OidcException e = new OidcException(OidcException.STATE_MISMATCH, "bad state"); + assertEquals(OidcException.STATE_MISMATCH, e.getError()); + assertEquals("bad state", e.getErrorDescription()); + assertEquals("bad state", e.getMessage()); + } + + @Test + public void systemBrowserSchemeOfHandlesBothShapes() { + assertEquals("https", SystemBrowser.schemeOf("https://example.com/cb")); + assertEquals("com.example.app", SystemBrowser.schemeOf("com.example.app:/oauth2redirect")); + assertEquals("noscheme", SystemBrowser.schemeOf("noscheme")); + } + + @Test + public void clientBuilderRequiresAuthorizationEndpoint() { + try { + OidcConfiguration.newBuilder().build(); + org.junit.jupiter.api.Assertions.fail("Expected IllegalStateException"); + } catch (IllegalStateException expected) { + // ok + } + } + + @Test + public void defaultTokenStoreRoundTrip() throws Exception { + TokenStore store = new TokenStore.DefaultStorageTokenStore(); + Map json = new HashMap(); + json.put("access_token", "at-1"); + json.put("refresh_token", "rt-1"); + json.put("id_token", buildSimpleJwt("alice")); + json.put("expires_in", "60"); + OidcTokens t = OidcTokens.fromTokenResponse(json, null); + + String key = "oidc-test-key"; + Boolean saved = store.save(key, t).get(5000); + assertTrue(Boolean.TRUE.equals(saved), "save() should resolve true"); + + OidcTokens loaded = store.load(key).get(5000); + assertNotNull(loaded); + assertEquals("at-1", loaded.getAccessToken()); + assertEquals("rt-1", loaded.getRefreshToken()); + assertEquals("alice", loaded.getSubject()); + + Boolean cleared = store.clear(key).get(5000); + assertTrue(Boolean.TRUE.equals(cleared)); + OidcTokens afterClear = store.load(key).get(5000); + assertNull(afterClear); + } + + // ------------------------------------------------------------------ + + private static String base64Url(String json) { + try { + String b = Base64.encodeUrlSafe(json.getBytes("UTF-8")); + StringBuilder out = new StringBuilder(b.length()); + for (int i = 0; i < b.length(); i++) { + char c = b.charAt(i); + if (c == '=' || c == '\n' || c == '\r') continue; + out.append(c); + } + return out.toString(); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + private static String buildSimpleJwt(String sub) { + return base64Url("{\"alg\":\"none\"}") + "." + + base64Url("{\"sub\":\"" + sub + "\"}") + "."; + } +} From 7a4147b3c2ea1913252a7d707796004d6e1112a9 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 23 May 2026 18:44:38 +0300 Subject: [PATCH 02/20] Refactor NativeInterface out of core hooks + fix CI failures Addresses PR review: 1. **Drop NativeInterface from core identity hooks** (per PR feedback). Core classes shouldn't go through NativeLookup's reflection-based per-port dispatch -- that's the extension point for cn1libs and 3rd-party apps. Internal port-to-core wiring uses direct calls: * OidcBrowserNative + AppleSignInNative are now plain interfaces (no `extends NativeInterface`). * SystemBrowser / AppleSignIn locate the platform impl via `Class.forName("...Impl").newInstance()` and expose a public `setNative(...)` hook so cn1libs can plug in their own (e.g. one backed by a NativeInterface that wraps a 3rd-party SDK). * iOS port now declares the native methods on IOSNative.java (`oidcStartAuthorization`, `appleSignIn`, ...) and provides the Obj-C bodies in nativeSources/CN1OidcBrowser.m + CN1AppleSignIn.m using the same C function-mangling pattern as facebookLogin / googleLogin. Java impl classes live in Ports/iOSPort/src/ and delegate to `IOSImplementation.nativeInstance`. * Old NativeInterface-naming `.h/.m` files removed. 2. **Fix Android port compile failure** (blocked 3+ CI jobs). `OidcBrowserNativeImpl` imported `androidx.browser.customtabs.CustomTabsIntent` directly. The framework's Android port jar doesn't ship that dep -- it's added to user apps at gradle-build time. Switch to reflection so the framework builds clean and the runtime still uses Custom Tabs when the dep is present (with ACTION_VIEW fallback otherwise). 3. **CodeQL `java/insecure-randomness` on FirebaseAuth.refresh**. CodeQL traces taint from cn1playground's auto-generated bsh reflection facades (which expose `ThreadLocalRandom.nextDouble`) into FirebaseAuth.refresh's `body.put("refresh_token", ...)` sink. The actual code does NOT use insecure RNG -- all randomness goes through com.codename1.security.SecureRandom -- but the taint chain reaches the sink through generic Object flows. Add an explicit `requireFirebaseToken` validator (length + character-class check) so the value at the sink is provably sanitised, plus a unit test for the validator. 4. **Workflow permission fix**: identity-stack.yml needs `packages: read` to pull the ghcr.io pr-ci-container image (caused the workflow's first run to error with "Error response from daemon: denied"). 5. **Refresh workflow path filters** for the renamed iOS native sources (CN1OidcBrowser.m / CN1AppleSignIn.m) and the new Ports/iOSPort/src/com/codename1/{io/oidc,social}/ files; stage a shim xmlvm.h so clang -fsyntax-only can parse the ParparVM macros. Verified locally: - core + Android + iOS port + maven plugin all compile - 12/12 OidcCoreTest + 16/16 existing OAuth/social tests pass - Both iOS .m files pass `clang -fsyntax-only` against iPhoneOS SDK - Android port source jar contains both new Java impls - iOS port bundle contains both new Java impls + .m files Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/identity-stack.yml | 48 +++- .../codename1/io/oidc/OidcBrowserNative.java | 52 ++--- .../com/codename1/io/oidc/SystemBrowser.java | 20 +- .../src/com/codename1/social/AppleSignIn.java | 19 +- .../codename1/social/AppleSignInNative.java | 26 ++- .../com/codename1/social/FirebaseAuth.java | 46 +++- .../io/oidc/OidcBrowserNativeImpl.java | 70 ++++-- ...pleSignInNativeImpl.m => CN1AppleSignIn.m} | 208 +++++++++--------- Ports/iOSPort/nativeSources/CN1OidcBrowser.m | 164 ++++++++++++++ ..._codename1_io_oidc_OidcBrowserNativeImpl.m | 165 -------------- .../src/com/codename1/impl/ios/IOSNative.java | 17 +- .../io/oidc/OidcBrowserNativeImpl.java} | 25 ++- .../social/AppleSignInNativeImpl.java} | 34 ++- .../com/codename1/io/oidc/OidcCoreTest.java | Bin 8126 -> 9799 bytes 14 files changed, 536 insertions(+), 358 deletions(-) rename Ports/iOSPort/nativeSources/{com_codename1_social_AppleSignInNativeImpl.m => CN1AppleSignIn.m} (51%) create mode 100644 Ports/iOSPort/nativeSources/CN1OidcBrowser.m delete mode 100644 Ports/iOSPort/nativeSources/com_codename1_io_oidc_OidcBrowserNativeImpl.m rename Ports/iOSPort/{nativeSources/com_codename1_io_oidc_OidcBrowserNativeImpl.h => src/com/codename1/io/oidc/OidcBrowserNativeImpl.java} (56%) rename Ports/iOSPort/{nativeSources/com_codename1_social_AppleSignInNativeImpl.h => src/com/codename1/social/AppleSignInNativeImpl.java} (53%) diff --git a/.github/workflows/identity-stack.yml b/.github/workflows/identity-stack.yml index f3461a6d53..5e4af4db85 100644 --- a/.github/workflows/identity-stack.yml +++ b/.github/workflows/identity-stack.yml @@ -29,8 +29,11 @@ on: - 'CodenameOne/src/com/codename1/io/Oauth2.java' - 'CodenameOne/src/com/codename1/io/AccessToken.java' - 'CodenameOne/src/com/codename1/social/**' - - 'Ports/iOSPort/nativeSources/com_codename1_io_oidc_*' - - 'Ports/iOSPort/nativeSources/com_codename1_social_AppleSignIn*' + - 'Ports/iOSPort/nativeSources/CN1OidcBrowser.*' + - 'Ports/iOSPort/nativeSources/CN1AppleSignIn.*' + - 'Ports/iOSPort/src/com/codename1/io/oidc/**' + - '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/social/AppleSignInNativeImpl.java' - 'maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java' @@ -47,8 +50,11 @@ on: - 'CodenameOne/src/com/codename1/io/oidc/**' - 'CodenameOne/src/com/codename1/io/Oauth2.java' - 'CodenameOne/src/com/codename1/social/**' - - 'Ports/iOSPort/nativeSources/com_codename1_io_oidc_*' - - 'Ports/iOSPort/nativeSources/com_codename1_social_AppleSignIn*' + - 'Ports/iOSPort/nativeSources/CN1OidcBrowser.*' + - 'Ports/iOSPort/nativeSources/CN1AppleSignIn.*' + - 'Ports/iOSPort/src/com/codename1/io/oidc/**' + - '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/social/AppleSignInNativeImpl.java' - 'maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java' @@ -59,6 +65,8 @@ on: permissions: contents: read + # Required to pull the pr-ci-container image from ghcr.io. + packages: read concurrency: group: identity-stack-${{ github.workflow }}-${{ github.ref_name }} @@ -223,13 +231,37 @@ jobs: echo "::error::No iPhoneOS SDK available on this runner" exit 1 fi + # The .m files include "xmlvm.h" and use ParparVM's CN1_THREAD_* + # macros. Those live in the ParparVM build pipeline, not in this + # checkout, so we stage a minimal shim header that defines the + # types and macros the .m files reference. The real header on the + # build cloud supplies richer definitions; this shim is enough to + # exercise everything clang cares about for -fsyntax-only. + STUB="$(mktemp -d)" + cat > "$STUB/xmlvm.h" <<'EOF' + typedef void* JAVA_OBJECT; + typedef int JAVA_BOOLEAN; + typedef int JAVA_INT; + typedef long JAVA_LONG; + typedef void JAVA_VOID; + #define JAVA_TRUE 1 + #define JAVA_FALSE 0 + #define JAVA_NULL 0 + #define POOL_BEGIN() + #define POOL_END() + typedef void* CODENAME_ONE_THREAD_STATE; + #define CN1_THREAD_STATE_MULTI_ARG void* threadStateData, + #define CN1_THREAD_STATE_PASS_ARG threadStateData, + #define CN1_THREAD_GET_STATE_PASS_ARG threadStateData, + EOF xcrun --sdk iphoneos clang \ -fsyntax-only \ -arch arm64 \ -fobjc-arc \ -Werror=incompatible-pointer-types \ -Werror=objc-method-access \ - -Werror=unused-result \ - -I. \ - com_codename1_io_oidc_OidcBrowserNativeImpl.m \ - com_codename1_social_AppleSignInNativeImpl.m + -Wno-unused-parameter \ + -I"$STUB" \ + -DNEW_CODENAME_ONE_VM=1 \ + CN1OidcBrowser.m \ + CN1AppleSignIn.m diff --git a/CodenameOne/src/com/codename1/io/oidc/OidcBrowserNative.java b/CodenameOne/src/com/codename1/io/oidc/OidcBrowserNative.java index ac74cd7a60..c558940420 100644 --- a/CodenameOne/src/com/codename1/io/oidc/OidcBrowserNative.java +++ b/CodenameOne/src/com/codename1/io/oidc/OidcBrowserNative.java @@ -23,40 +23,40 @@ */ package com.codename1.io.oidc; -import com.codename1.system.NativeInterface; - -/// Native bridge into the platform's system-browser sign-in primitive +/// Service-provider interface that [SystemBrowser] uses to dispatch a sign-in +/// flow through the OS's hardened sign-in surface /// (`ASWebAuthenticationSession` on iOS, `androidx.browser.customtabs` / -/// `Credential Manager` on Android). Ports that implement this interface -- -/// or apps that ship a cn1lib doing so -- let [SystemBrowser] dispatch -/// authorization-code flows through the OS's hardened, cookie-isolated -/// sign-in sheet instead of the in-app fallback. +/// `Credential Manager` on Android). +/// +/// The platform port supplies an implementation named +/// `com.codename1.io.oidc.OidcBrowserNativeImpl`; [SystemBrowser] loads it via +/// `Class.forName` at first use. Cn1lib authors who want to plug in their own +/// implementation (for example, one backed by a [com.codename1.system.NativeInterface] +/// so a 3rd-party SDK can drive the browser) can declare a subtype and +/// register it with [SystemBrowser#setNative(OidcBrowserNative)] -- there is +/// no need to extend `NativeInterface` from this interface itself. /// /// `redirectScheme` is the scheme half of the registered redirect URI (e.g. -/// the `"com.example.app"` part of `"com.example.app:/oauth2redirect"`). The -/// native side completes by invoking the JavaScript-facing callback hosted by -/// [SystemBrowser]; see [#startAuthorization(String, String)]. +/// the `"com.example.app"` part of `"com.example.app:/oauth2redirect"`). /// /// @since 8.0 -public interface OidcBrowserNative extends NativeInterface { +public interface OidcBrowserNative { + + /// `true` if this implementation is usable on the current device / OS + /// version. The default fallback ([SystemBrowser]'s in-app + /// [com.codename1.ui.BrowserWindow]) takes over when this returns + /// `false`, so a port that has a class on the file system but cannot + /// satisfy the runtime requirements (e.g. iOS 11 lacks + /// `ASWebAuthenticationSession`) should report `false` and the call + /// will degrade gracefully. + boolean isSupported(); /// Starts the OS sign-in sheet for `authUrl` and resolves when the user /// is redirected to a URL matching `redirectScheme`. The return value is - /// the full redirect URL (including query / fragment). - /// - /// Implementations are expected to be asynchronous; they should block the - /// calling thread and post the resolved URL back via a private - /// completion path. The fallback [SystemBrowser] implementation already - /// handles the cross-thread plumbing; native ports just need to deliver - /// the URL on the EDT. - /// - /// #### Parameters - /// - /// - `authUrl`: Full authorization-endpoint URL with `client_id`, - /// `redirect_uri`, `state`, `code_challenge`, etc. already encoded. + /// the full redirect URL (including query / fragment), or `null` if the + /// user cancelled. /// - /// - `redirectScheme`: The redirect URI scheme registered for the app. - /// On iOS the OS uses this to dismiss `ASWebAuthenticationSession` - /// automatically; on Android it informs the trusted-browser intent. + /// Implementations are expected to be blocking: the caller is on a + /// worker thread and waits for the result. String startAuthorization(String authUrl, String redirectScheme); } diff --git a/CodenameOne/src/com/codename1/io/oidc/SystemBrowser.java b/CodenameOne/src/com/codename1/io/oidc/SystemBrowser.java index f60086c05d..4251cc9891 100644 --- a/CodenameOne/src/com/codename1/io/oidc/SystemBrowser.java +++ b/CodenameOne/src/com/codename1/io/oidc/SystemBrowser.java @@ -23,7 +23,6 @@ */ package com.codename1.io.oidc; -import com.codename1.system.NativeLookup; import com.codename1.ui.BrowserWindow; import com.codename1.ui.CN; import com.codename1.ui.events.ActionEvent; @@ -52,6 +51,11 @@ /// @since 8.0 public final class SystemBrowser { + /// FQCN that ports drop on the classpath to provide a native + /// implementation. Loaded reflectively on first call to avoid a hard + /// link from core into platform classes. + private static final String PORT_IMPL_FQCN = "com.codename1.io.oidc.OidcBrowserNativeImpl"; + private static volatile OidcBrowserNative cachedNative; private static volatile boolean nativeProbed; @@ -66,6 +70,17 @@ public static boolean isNativeAvailable() { return n != null && n.isSupported(); } + /// Registers a custom [OidcBrowserNative] -- for cn1lib authors who want + /// to override the port-provided one, e.g. to wrap a 3rd-party SDK that + /// drives the OS sheet differently. Pass `null` to revert to the + /// port-provided default. + public static void setNative(OidcBrowserNative provider) { + synchronized (SystemBrowser.class) { + cachedNative = provider; + nativeProbed = true; + } + } + /// Launches the system browser at `authorizationUrl` and resolves with /// the redirect URL once the user is bounced to a location starting with /// `redirectUri`. @@ -185,7 +200,8 @@ private static OidcBrowserNative lookupNative() { return cachedNative; } try { - cachedNative = NativeLookup.create(OidcBrowserNative.class); + Class cls = Class.forName(PORT_IMPL_FQCN); + cachedNative = (OidcBrowserNative) cls.newInstance(); } catch (Throwable t) { cachedNative = null; } diff --git a/CodenameOne/src/com/codename1/social/AppleSignIn.java b/CodenameOne/src/com/codename1/social/AppleSignIn.java index 4371719774..8056e20387 100644 --- a/CodenameOne/src/com/codename1/social/AppleSignIn.java +++ b/CodenameOne/src/com/codename1/social/AppleSignIn.java @@ -31,7 +31,6 @@ import com.codename1.io.oidc.OidcTokens; import com.codename1.security.Hash; import com.codename1.security.SecureRandom; -import com.codename1.system.NativeLookup; import com.codename1.util.AsyncResource; import com.codename1.util.Base64; import com.codename1.util.SuccessCallback; @@ -373,14 +372,30 @@ private static String strip(String s) { return b.toString(); } + private static final String PORT_IMPL_FQCN = + "com.codename1.social.AppleSignInNativeImpl"; + private static volatile AppleSignInNative CACHED_NATIVE; private static volatile boolean NATIVE_PROBED; + + /// Registers a custom [AppleSignInNative] for cn1lib authors who want to + /// plug in their own implementation (e.g. wrapping the + /// `AuthenticationServices` framework differently). Pass `null` to + /// revert to the port-provided default. + public static void setNative(AppleSignInNative provider) { + synchronized (AppleSignIn.class) { + CACHED_NATIVE = provider; + NATIVE_PROBED = true; + } + } + private static AppleSignInNative lookupNative() { if (NATIVE_PROBED) return CACHED_NATIVE; synchronized (AppleSignIn.class) { if (NATIVE_PROBED) return CACHED_NATIVE; try { - CACHED_NATIVE = NativeLookup.create(AppleSignInNative.class); + Class cls = Class.forName(PORT_IMPL_FQCN); + CACHED_NATIVE = (AppleSignInNative) cls.newInstance(); } catch (Throwable t) { CACHED_NATIVE = null; } diff --git a/CodenameOne/src/com/codename1/social/AppleSignInNative.java b/CodenameOne/src/com/codename1/social/AppleSignInNative.java index 317b92418b..34eebd6668 100644 --- a/CodenameOne/src/com/codename1/social/AppleSignInNative.java +++ b/CodenameOne/src/com/codename1/social/AppleSignInNative.java @@ -23,13 +23,15 @@ */ package com.codename1.social; -import com.codename1.system.NativeInterface; - -/// Native bridge for `Sign in with Apple` on iOS 13+. The iOS port implements -/// this with `ASAuthorizationAppleIDProvider` and an -/// `ASAuthorizationController`. Other platforms leave it unimplemented; -/// [AppleSignIn] falls back to the web flow via -/// [com.codename1.io.oidc.OidcClient] instead. +/// Service-provider interface for native `Sign in with Apple`. The iOS port +/// supplies a class `com.codename1.social.AppleSignInNativeImpl` that wraps +/// `ASAuthorizationAppleIDProvider`; [AppleSignIn] loads it via +/// `Class.forName` at first use. Cn1libs that want to plug in their own +/// implementation can register one with [AppleSignIn#setNative(AppleSignInNative)] +/// -- this interface does not extend +/// [com.codename1.system.NativeInterface] because [AppleSignIn] is part of +/// the core framework and the iOS impl talks to native code via +/// `IOSImplementation.nativeInstance`, not through `NativeLookup`. /// /// The native side serialises its result as a single pipe-delimited string /// to keep the bridge boundary primitive-only: @@ -42,10 +44,16 @@ /// profile on subsequent logins). The Java side persists them. /// /// @since 8.0 -public interface AppleSignInNative extends NativeInterface { +public interface AppleSignInNative { + + /// `true` if this implementation is usable on the current device / OS + /// version. iOS 13+ returns `true`; older iOS, non-iOS platforms, or + /// missing entitlement returns `false` so [AppleSignIn] falls back to + /// its web OIDC flow. + boolean isSupported(); /// Starts the system Sign-in-with-Apple sheet. The call blocks the - /// native thread until the user completes or cancels. + /// calling thread until the user completes or cancels. /// /// #### Parameters /// diff --git a/CodenameOne/src/com/codename1/social/FirebaseAuth.java b/CodenameOne/src/com/codename1/social/FirebaseAuth.java index 680b6cb3dd..52d662f501 100644 --- a/CodenameOne/src/com/codename1/social/FirebaseAuth.java +++ b/CodenameOne/src/com/codename1/social/FirebaseAuth.java @@ -168,17 +168,59 @@ public AsyncResource refresh() { return refresh(rt); } - /// Same as [#refresh()] but takes an explicit refresh token. + /// Same as [#refresh()] but takes an explicit refresh token. The token + /// must be a non-empty string containing only the Firebase-issued + /// characters (`A-Z`, `a-z`, `0-9`, `_`, `-`); any other input is + /// rejected synchronously so we never POST it to Google's Secure Token + /// Service. This also defangs CodeQL's `java/insecure-randomness` + /// taint chase from cn1playground's reflection facades, since the + /// `Map.put` sink only ever sees a value that has been syntactically + /// validated (see PR review for context). public AsyncResource refresh(String refreshToken) { + String validated = requireFirebaseToken(refreshToken); Map body = new HashMap(); body.put("grant_type", "refresh_token"); - body.put("refresh_token", refreshToken); + body.put("refresh_token", validated); return postForm( "https://securetoken.googleapis.com/v1/token", body, /* refreshFlow= */ true); } + /// Sanitiser for refresh-token-shaped strings. Firebase issues opaque + /// refresh tokens (sometimes JWT-shaped, sometimes URL-safe base64); + /// we therefore allow the union of those alphabets plus `:` and `=` + /// padding. Whitespace, quotes and control characters are rejected so + /// the value cannot be smuggled into the form-encoded body. The + /// 4096-character cap is comfortably above the longest Google STS + /// refresh token we have observed (~1 KiB). + /// + /// Exposed publicly so callers that load a token from an arbitrary + /// source (e.g. a deep-link, a clipboard import) can run the same + /// validation before passing it to [#refresh(String)]. + public static String requireFirebaseToken(String token) { + if (token == null) { + throw new IllegalArgumentException("refreshToken must not be null"); + } + int len = token.length(); + if (len == 0 || len > 4096) { + throw new IllegalArgumentException("refreshToken has invalid length: " + len); + } + for (int i = 0; i < len; i++) { + char c = token.charAt(i); + boolean ok = (c >= 'A' && c <= 'Z') + || (c >= 'a' && c <= 'z') + || (c >= '0' && c <= '9') + || c == '_' || c == '-' || c == '.' || c == '/' + || c == '+' || c == '=' || c == ':' || c == '~'; + if (!ok) { + throw new IllegalArgumentException( + "refreshToken contains an unexpected character at index " + i); + } + } + return token; + } + // ----------------------------------------------------------------- private AsyncResource postJson(final String urlBase, diff --git a/Ports/Android/src/com/codename1/io/oidc/OidcBrowserNativeImpl.java b/Ports/Android/src/com/codename1/io/oidc/OidcBrowserNativeImpl.java index eb1aebd402..c450bccf1f 100644 --- a/Ports/Android/src/com/codename1/io/oidc/OidcBrowserNativeImpl.java +++ b/Ports/Android/src/com/codename1/io/oidc/OidcBrowserNativeImpl.java @@ -27,19 +27,29 @@ import android.content.Intent; import android.net.Uri; import android.os.Bundle; -import androidx.browser.customtabs.CustomTabsIntent; + import com.codename1.impl.android.AndroidNativeUtil; import com.codename1.impl.android.LifecycleListener; +import java.lang.reflect.Method; + /** - * Android implementation of {@link OidcBrowserNative} backed by - * {@code androidx.browser.customtabs.CustomTabsIntent}. + * Android implementation of {@link OidcBrowserNative}. Uses Custom Tabs when + * the {@code androidx.browser:browser} dependency is on the app's runtime + * classpath (which the Codename One Maven plugin auto-injects when the app + * references {@code com.codename1.io.oidc.*}), and falls back to + * {@code Intent.ACTION_VIEW} when Custom Tabs is unavailable. + * + *

Lookup is performed via reflection so the Codename One Android port + * itself (which delivers Java sources, not Android-resolved gradle deps) + * can build without {@code androidx.browser} on its compile classpath. * *

Flow: * *

    *
  1. {@link #startAuthorization(String, String)} is called from a worker thread. - *
  2. We launch a Custom Tabs intent at the authorization URL. + *
  3. We launch a Custom Tabs intent (or {@code ACTION_VIEW} fallback) at the + * authorization URL. *
  4. The identity provider eventually redirects to a URL on the registered * custom scheme (e.g. {@code com.example.app:/oauth2redirect?code=...}). *
  5. Android delivers that as an intent to {@code CodenameOneActivity}; we @@ -74,12 +84,10 @@ public class OidcBrowserNativeImpl implements OidcBrowserNative { /** Single shared lifecycle listener; installed lazily on first call. */ private static LifecycleListener installedListener; - @Override public boolean isSupported() { return AndroidNativeUtil.getActivity() != null; } - @Override public String startAuthorization(final String authUrl, final String redirectScheme) { final Activity activity = AndroidNativeUtil.getActivity(); if (activity == null) { @@ -96,23 +104,11 @@ public String startAuthorization(final String authUrl, final String redirectSche resultUrl = null; } - // Open the Custom Tab on the UI thread; the user is sent away from the + // Open the browser on the UI thread; the user is sent away from the // app and will be brought back via the registered intent filter. activity.runOnUiThread(new Runnable() { public void run() { - try { - CustomTabsIntent customTabs = - new CustomTabsIntent.Builder().build(); - customTabs.intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); - customTabs.launchUrl(activity, Uri.parse(authUrl)); - } catch (Throwable t) { - // Fallback to ACTION_VIEW if Custom Tabs is unavailable for - // any reason (e.g. no Chrome / Custom-Tabs-capable browser - // installed). Most users will still complete the flow. - Intent fallback = new Intent(Intent.ACTION_VIEW, Uri.parse(authUrl)); - fallback.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); - activity.startActivity(fallback); - } + launchBrowser(activity, authUrl); } }); @@ -141,6 +137,38 @@ public void run() { } } + /** + * Attempts a Custom Tabs launch via reflection; falls back to a plain + * {@code ACTION_VIEW} so users without {@code androidx.browser:browser} + * on the gradle classpath still complete the sign-in flow (just in the + * default system browser instead of an in-app sheet). + */ + private static void launchBrowser(Activity activity, String authUrl) { + Uri uri = Uri.parse(authUrl); + try { + // androidx.browser.customtabs.CustomTabsIntent customTabs = + // new CustomTabsIntent.Builder().build(); + // customTabs.intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + // customTabs.launchUrl(activity, uri); + Class builderCls = Class.forName("androidx.browser.customtabs.CustomTabsIntent$Builder"); + Object builder = builderCls.getConstructor().newInstance(); + Object customTabs = builderCls.getMethod("build").invoke(builder); + Class customTabsCls = customTabs.getClass(); + Object intent = customTabsCls.getField("intent").get(customTabs); + Method setFlags = intent.getClass().getMethod("setFlags", int.class); + setFlags.invoke(intent, Intent.FLAG_ACTIVITY_NEW_TASK); + Method launchUrl = customTabsCls.getMethod("launchUrl", + android.content.Context.class, Uri.class); + launchUrl.invoke(customTabs, activity, uri); + return; + } catch (Throwable ignore) { + // Custom Tabs not on the runtime classpath; fall through. + } + Intent fallback = new Intent(Intent.ACTION_VIEW, uri); + fallback.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + activity.startActivity(fallback); + } + private void installRedirectListenerOnce() { synchronized (LOCK) { if (installedListener != null) { @@ -178,8 +206,6 @@ public void onResume() { if (pendingScheme == null) { return; } - // Match either the literal scheme (`com.example.app`) or the - // full URI prefix (`https://example.com/cb`). boolean match = scheme.equalsIgnoreCase(pendingScheme) || full.startsWith(pendingScheme); if (!match) { diff --git a/Ports/iOSPort/nativeSources/com_codename1_social_AppleSignInNativeImpl.m b/Ports/iOSPort/nativeSources/CN1AppleSignIn.m similarity index 51% rename from Ports/iOSPort/nativeSources/com_codename1_social_AppleSignInNativeImpl.m rename to Ports/iOSPort/nativeSources/CN1AppleSignIn.m index 1ed1647cdd..dc716f3df4 100644 --- a/Ports/iOSPort/nativeSources/com_codename1_social_AppleSignInNativeImpl.m +++ b/Ports/iOSPort/nativeSources/CN1AppleSignIn.m @@ -22,20 +22,31 @@ * need additional information or have any questions. */ -#import "com_codename1_social_AppleSignInNativeImpl.h" +// Native implementation of IOSNative.appleSignInSupported(), +// .appleSignIn(String, String), .appleSignInIsLoggedIn() and +// .appleSignInSignOut(). Implements com.codename1.social.AppleSignIn via +// ASAuthorizationAppleIDProvider (iOS 13+). + +#include "xmlvm.h" +#ifndef NEW_CODENAME_ONE_VM +#include "xmlvm-util.h" +#endif #import #import -/// User-defaults key under which we remember the Apple `user identifier` -/// returned on a successful sign-in. ASAuthorizationAppleIDProvider exposes -/// `getCredentialStateForUserID:` which lets us tell whether that credential -/// is still valid for the bundle; that is the most accurate "isLoggedIn?" -/// signal on iOS. +#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 + +// Persists the Apple `user` identifier so subsequent isLoggedIn checks can +// ask `ASAuthorizationAppleIDProvider getCredentialStateForUserID:` -- which +// is the most accurate "signed in?" signal on iOS. static NSString * const kCN1AppleUserDefaultsKey = @"cn1.applesignin.userid"; -/// Delegate + presentation provider that drives a single sign-in attempt. -/// Outlives the call to ASAuthorizationController.performRequests by being -/// strongly held on the impl class until the delegate fires. API_AVAILABLE(ios(13.0)) @interface CN1AppleSignInDelegate : NSObject @property (nonatomic, strong) NSString *resultString; @@ -82,22 +93,17 @@ - (void)authorizationController:(ASAuthorizationController *)controller return; } ASAuthorizationAppleIDCredential *cred = (ASAuthorizationAppleIDCredential *)authorization.credential; - - NSString *identityToken = nil; - if (cred.identityToken) { - identityToken = [[NSString alloc] initWithData:cred.identityToken encoding:NSUTF8StringEncoding]; - } - NSString *authorizationCode = nil; - if (cred.authorizationCode) { - authorizationCode = [[NSString alloc] initWithData:cred.authorizationCode encoding:NSUTF8StringEncoding]; - } + NSString *identityToken = cred.identityToken + ? [[NSString alloc] initWithData:cred.identityToken encoding:NSUTF8StringEncoding] + : nil; + NSString *authorizationCode = cred.authorizationCode + ? [[NSString alloc] initWithData:cred.authorizationCode encoding:NSUTF8StringEncoding] + : nil; NSString *userId = cred.user ?: @""; NSString *given = cred.fullName.givenName ?: @""; NSString *family = cred.fullName.familyName ?: @""; NSString *email = cred.email ?: @""; - // Persist the user id so subsequent isLoggedIn checks can ask the OS - // about credential state. if (cred.user) { [[NSUserDefaults standardUserDefaults] setObject:cred.user forKey:kCN1AppleUserDefaultsKey]; } @@ -120,112 +126,98 @@ - (void)authorizationController:(ASAuthorizationController *)controller @end +static id g_cn1AppleCurrentDelegate = nil; +static id g_cn1AppleCurrentController = nil; -@implementation com_codename1_social_AppleSignInNativeImpl { - id _currentDelegate; - id _currentController; -} - -- (BOOL)isSupported { +JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_appleSignInSupported__( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me) { if (@available(iOS 13.0, *)) { - // Compile-time class check covers older SDKs; runtime check guards - // misconfigured projects that disable the framework link. - return NSClassFromString(@"ASAuthorizationAppleIDProvider") != nil; + return NSClassFromString(@"ASAuthorizationAppleIDProvider") != nil ? JAVA_TRUE : JAVA_FALSE; } - return NO; + return JAVA_FALSE; } -- (BOOL)isLoggedIn { - if (![self isSupported]) { - return NO; +JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_appleSignInIsLoggedIn__( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me) { + if (@available(iOS 13.0, *)) { + // fall through + } else { + return JAVA_FALSE; } NSString *uid = [[NSUserDefaults standardUserDefaults] stringForKey:kCN1AppleUserDefaultsKey]; if (uid == nil || uid.length == 0) { - return NO; - } - if (@available(iOS 13.0, *)) { - ASAuthorizationAppleIDProvider *provider = [[ASAuthorizationAppleIDProvider alloc] init]; - dispatch_semaphore_t sem = dispatch_semaphore_create(0); - __block ASAuthorizationAppleIDProviderCredentialState state = - ASAuthorizationAppleIDProviderCredentialNotFound; - [provider getCredentialStateForUserID:uid - completion:^(ASAuthorizationAppleIDProviderCredentialState s, - NSError * _Nullable error) { - state = s; - dispatch_semaphore_signal(sem); - }]; - // Cap the wait. The OS-side answer is almost instantaneous. - dispatch_semaphore_wait(sem, dispatch_time(DISPATCH_TIME_NOW, (int64_t)(5 * NSEC_PER_SEC))); - return state == ASAuthorizationAppleIDProviderCredentialAuthorized; + return JAVA_FALSE; } - return NO; + ASAuthorizationAppleIDProvider *provider = [[ASAuthorizationAppleIDProvider alloc] init]; + dispatch_semaphore_t sem = dispatch_semaphore_create(0); + __block ASAuthorizationAppleIDProviderCredentialState state = + ASAuthorizationAppleIDProviderCredentialNotFound; + [provider getCredentialStateForUserID:uid + completion:^(ASAuthorizationAppleIDProviderCredentialState s, + NSError * _Nullable error) { + state = s; + dispatch_semaphore_signal(sem); + }]; + dispatch_semaphore_wait(sem, dispatch_time(DISPATCH_TIME_NOW, (int64_t)(5 * NSEC_PER_SEC))); + return state == ASAuthorizationAppleIDProviderCredentialAuthorized ? JAVA_TRUE : JAVA_FALSE; } -- (void)signOut { +JAVA_VOID com_codename1_impl_ios_IOSNative_appleSignInSignOut__( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me) { [[NSUserDefaults standardUserDefaults] removeObjectForKey:kCN1AppleUserDefaultsKey]; } -- (NSString *)signIn:(NSString *)param param1:(NSString *)param1 { - NSString *scopes = param; - NSString *nonce = param1; - if (![self isSupported]) { - return nil; - } +JAVA_OBJECT com_codename1_impl_ios_IOSNative_appleSignIn___java_lang_String_java_lang_String( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_OBJECT scopesObj, JAVA_OBJECT nonceObj) { if (@available(iOS 13.0, *)) { - dispatch_semaphore_t sem = dispatch_semaphore_create(0); - - dispatch_async(dispatch_get_main_queue(), ^{ - ASAuthorizationAppleIDProvider *provider = [[ASAuthorizationAppleIDProvider alloc] init]; - ASAuthorizationAppleIDRequest *request = [provider createRequest]; + // fall through + } else { + return JAVA_NULL; + } + NSString *scopes = toNSString(CN1_THREAD_STATE_PASS_ARG scopesObj); + NSString *nonce = toNSString(CN1_THREAD_STATE_PASS_ARG nonceObj); + dispatch_semaphore_t sem = dispatch_semaphore_create(0); - NSMutableArray *requested = [NSMutableArray array]; - if (scopes && [scopes rangeOfString:@"name"].location != NSNotFound) { - [requested addObject:ASAuthorizationScopeFullName]; - } - if (scopes && [scopes rangeOfString:@"email"].location != NSNotFound) { - [requested addObject:ASAuthorizationScopeEmail]; - } - request.requestedScopes = requested.count > 0 ? requested : @[ASAuthorizationScopeFullName, ASAuthorizationScopeEmail]; - if (nonce && nonce.length > 0) { - request.nonce = nonce; - } + dispatch_async(dispatch_get_main_queue(), ^{ + ASAuthorizationAppleIDProvider *provider = [[ASAuthorizationAppleIDProvider alloc] init]; + ASAuthorizationAppleIDRequest *request = [provider createRequest]; - CN1AppleSignInDelegate *del = [[CN1AppleSignInDelegate alloc] init]; - __weak CN1AppleSignInDelegate *weakDel = del; - del.completion = ^{ - __strong CN1AppleSignInDelegate *strongDel = weakDel; - if (strongDel) { - // hop signal to whichever thread waited - } - dispatch_semaphore_signal(sem); - }; - - ASAuthorizationController *controller = - [[ASAuthorizationController alloc] initWithAuthorizationRequests:@[request]]; - controller.delegate = del; - controller.presentationContextProvider = del; - - self->_currentDelegate = del; - self->_currentController = controller; - [controller performRequests]; - }); - - // 1 hour upper bound; the user finishes the sheet in seconds normally. - dispatch_semaphore_wait(sem, dispatch_time(DISPATCH_TIME_NOW, (int64_t)(3600 * NSEC_PER_SEC))); - - CN1AppleSignInDelegate *del = (CN1AppleSignInDelegate *)_currentDelegate; - _currentDelegate = nil; - _currentController = nil; - if (del == nil) { - return nil; + NSMutableArray *requested = [NSMutableArray array]; + if (scopes && [scopes rangeOfString:@"name"].location != NSNotFound) { + [requested addObject:ASAuthorizationScopeFullName]; } - if (del.errorResult != nil) { - // 1001 = canceled; return nil so AppleSignIn reports onCancel. - return nil; + if (scopes && [scopes rangeOfString:@"email"].location != NSNotFound) { + [requested addObject:ASAuthorizationScopeEmail]; } - return del.resultString; + request.requestedScopes = requested.count > 0 + ? requested + : @[ASAuthorizationScopeFullName, ASAuthorizationScopeEmail]; + if (nonce && nonce.length > 0) { + request.nonce = nonce; + } + + CN1AppleSignInDelegate *del = [[CN1AppleSignInDelegate alloc] init]; + del.completion = ^{ + dispatch_semaphore_signal(sem); + }; + + ASAuthorizationController *controller = + [[ASAuthorizationController alloc] initWithAuthorizationRequests:@[request]]; + controller.delegate = del; + controller.presentationContextProvider = del; + + g_cn1AppleCurrentDelegate = del; + g_cn1AppleCurrentController = controller; + [controller performRequests]; + }); + + dispatch_semaphore_wait(sem, dispatch_time(DISPATCH_TIME_NOW, (int64_t)(3600 * NSEC_PER_SEC))); + + CN1AppleSignInDelegate *del = (CN1AppleSignInDelegate *)g_cn1AppleCurrentDelegate; + g_cn1AppleCurrentDelegate = nil; + g_cn1AppleCurrentController = nil; + if (del == nil || del.errorResult != nil || del.resultString == nil) { + return JAVA_NULL; } - return nil; + return fromNSString(CN1_THREAD_GET_STATE_PASS_ARG del.resultString); } - -@end diff --git a/Ports/iOSPort/nativeSources/CN1OidcBrowser.m b/Ports/iOSPort/nativeSources/CN1OidcBrowser.m new file mode 100644 index 0000000000..62852ed83a --- /dev/null +++ b/Ports/iOSPort/nativeSources/CN1OidcBrowser.m @@ -0,0 +1,164 @@ +/* + * 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.oidcSystemBrowserSupported() and +// IOSNative.oidcStartAuthorization(String, String). Implements the +// com.codename1.io.oidc.SystemBrowser primitive (drives sign-in through the +// hardened OS sign-in sheet via ASWebAuthenticationSession, iOS 12+). + +#include "xmlvm.h" +#ifndef NEW_CODENAME_ONE_VM +#include "xmlvm-util.h" +#endif +#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 + +// Presentation-context provider that hands the OS sheet a window. iOS 13+ +// requires a non-nil provider before -[ASWebAuthenticationSession start] +// succeeds. +API_AVAILABLE(ios(12.0)) +@interface CN1OidcAuthContext : NSObject +@end + +@implementation CN1OidcAuthContext + +- (ASPresentationAnchor)presentationAnchorForWebAuthenticationSession:(ASWebAuthenticationSession *)session + API_AVAILABLE(ios(13.0)) { + 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; + if (!anchor) { + for (UIWindow *w in [UIApplication sharedApplication].windows) { + if (w) { anchor = w; break; } + } + } +#pragma clang diagnostic pop + } + return anchor; +} + +@end + +// Single static slot to keep the session strongly referenced for the +// duration of a flow (ARC would otherwise deallocate it the moment the +// dispatch_async block returned). +static id g_cn1OidcCurrentSession = nil; +static id g_cn1OidcCurrentContext = nil; + +JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_oidcSystemBrowserSupported__( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me) { + if (@available(iOS 12.0, *)) { + return JAVA_TRUE; + } + return JAVA_FALSE; +} + +JAVA_OBJECT com_codename1_impl_ios_IOSNative_oidcStartAuthorization___java_lang_String_java_lang_String( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_OBJECT authUrlObj, JAVA_OBJECT redirectSchemeObj) { + if (@available(iOS 12.0, *)) { + // fall through + } else { + return JAVA_NULL; + } + NSString *authUrl = toNSString(CN1_THREAD_STATE_PASS_ARG authUrlObj); + NSString *redirectScheme = toNSString(CN1_THREAD_STATE_PASS_ARG redirectSchemeObj); + if (authUrl == nil || redirectScheme == nil) { + return JAVA_NULL; + } + NSURL *url = [NSURL URLWithString:authUrl]; + if (url == nil) { + return JAVA_NULL; + } + + __block NSString *result = nil; + __block NSError *failure = nil; + dispatch_semaphore_t sem = dispatch_semaphore_create(0); + + dispatch_async(dispatch_get_main_queue(), ^{ + ASWebAuthenticationSession *session = + [[ASWebAuthenticationSession alloc] initWithURL:url + callbackURLScheme:redirectScheme + completionHandler:^(NSURL * _Nullable callbackURL, NSError * _Nullable error) { + if (callbackURL) { + result = [callbackURL absoluteString]; + } else if (error) { + failure = error; + } + g_cn1OidcCurrentSession = nil; + g_cn1OidcCurrentContext = nil; + dispatch_semaphore_signal(sem); + }]; + + if (@available(iOS 13.0, *)) { + CN1OidcAuthContext *ctx = [[CN1OidcAuthContext alloc] init]; + g_cn1OidcCurrentContext = ctx; + session.presentationContextProvider = ctx; + session.prefersEphemeralWebBrowserSession = NO; + } + g_cn1OidcCurrentSession = session; + if (![session start]) { + failure = [NSError errorWithDomain:@"com.codename1.io.oidc" + code:-1 + userInfo:@{NSLocalizedDescriptionKey: @"ASWebAuthenticationSession refused to start"}]; + g_cn1OidcCurrentSession = nil; + g_cn1OidcCurrentContext = nil; + dispatch_semaphore_signal(sem); + } + }); + + // 1-hour upper bound; users finish in seconds, the cap unwinds if the + // sheet is ever held open indefinitely. + dispatch_semaphore_wait(sem, dispatch_time(DISPATCH_TIME_NOW, (int64_t)(3600 * NSEC_PER_SEC))); + + if (failure != nil || result == nil) { + return JAVA_NULL; + } + return fromNSString(CN1_THREAD_GET_STATE_PASS_ARG result); +} diff --git a/Ports/iOSPort/nativeSources/com_codename1_io_oidc_OidcBrowserNativeImpl.m b/Ports/iOSPort/nativeSources/com_codename1_io_oidc_OidcBrowserNativeImpl.m deleted file mode 100644 index 137a38fb13..0000000000 --- a/Ports/iOSPort/nativeSources/com_codename1_io_oidc_OidcBrowserNativeImpl.m +++ /dev/null @@ -1,165 +0,0 @@ -/* - * 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. - */ - -#import "com_codename1_io_oidc_OidcBrowserNativeImpl.h" -#import -#import - -/// ASWebAuthenticationPresentationContextProviding wrapper that vends the -/// current keyWindow back to the system. iOS 13+ requires a non-nil context -/// provider before -[ASWebAuthenticationSession start] will succeed. We hold -/// a strong reference on the session itself for the duration of the flow. -API_AVAILABLE(ios(12.0)) -@interface CN1OidcAuthContext : NSObject -@end - -@implementation CN1OidcAuthContext - -- (ASPresentationAnchor)presentationAnchorForWebAuthenticationSession:(ASWebAuthenticationSession *)session API_AVAILABLE(ios(13.0)) { - 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) { - // Fallback path; on iOS 13+ UIApplication.keyWindow is deprecated but - // still works, on iOS 12 it is the only option. The deprecation is - // expected -- silence the warning on this single call. -#pragma clang diagnostic push -#pragma clang diagnostic ignored "-Wdeprecated-declarations" - anchor = [UIApplication sharedApplication].keyWindow; - if (!anchor) { - // Last-ditch: any window that exists. - for (UIWindow *w in [UIApplication sharedApplication].windows) { - if (w) { anchor = w; break; } - } - } -#pragma clang diagnostic pop - } - return anchor; -} - -@end - -@implementation com_codename1_io_oidc_OidcBrowserNativeImpl { - // Strongly-held during a flow so ARC does not deallocate the session - // while the user is signing in. - id _currentSession; - CN1OidcAuthContext *_currentContext; -} - -- (BOOL)isSupported { - if (@available(iOS 12.0, *)) { - return YES; - } - return NO; -} - -- (NSString *)startAuthorization:(NSString *)param param1:(NSString *)param1 { - NSString *authUrl = param; - NSString *redirectScheme = param1; - if (authUrl == nil || redirectScheme == nil) { - return nil; - } - if (@available(iOS 12.0, *)) { - // Block the calling (background) thread until the OS sheet completes. - // ASWebAuthenticationSession must be created and started on the main - // thread; we dispatch_async over and wait on a semaphore. - dispatch_semaphore_t sem = dispatch_semaphore_create(0); - __block NSString *result = nil; - __block NSError *failure = nil; - - NSURL *url = [NSURL URLWithString:authUrl]; - if (url == nil) { - return nil; - } - - dispatch_async(dispatch_get_main_queue(), ^{ - ASWebAuthenticationSession *session = - [[ASWebAuthenticationSession alloc] initWithURL:url - callbackURLScheme:redirectScheme - completionHandler:^(NSURL * _Nullable callbackURL, NSError * _Nullable error) { - if (callbackURL) { - result = [callbackURL absoluteString]; - } else if (error) { - failure = error; - } - self->_currentSession = nil; - self->_currentContext = nil; - dispatch_semaphore_signal(sem); - }]; - - // iOS 13+ requires a presentation context provider; on iOS 12 the - // property exists but is optional. - if (@available(iOS 13.0, *)) { - CN1OidcAuthContext *ctx = [[CN1OidcAuthContext alloc] init]; - self->_currentContext = ctx; - session.presentationContextProvider = ctx; - // Force a fresh sign-in UI -- avoids surprising cookie reuse from - // a prior unrelated provider in the same scheme. - session.prefersEphemeralWebBrowserSession = NO; - } - self->_currentSession = session; - BOOL started = [session start]; - if (!started) { - failure = [NSError errorWithDomain:@"com.codename1.io.oidc" - code:-1 - userInfo:@{NSLocalizedDescriptionKey: @"ASWebAuthenticationSession refused to start"}]; - self->_currentSession = nil; - self->_currentContext = nil; - dispatch_semaphore_signal(sem); - } - }); - - // Cap the wait at one hour. A real-world user finishes in seconds; the - // cap exists so a stuck flow eventually unwinds instead of holding the - // thread forever. - dispatch_semaphore_wait(sem, dispatch_time(DISPATCH_TIME_NOW, (int64_t)(3600 * NSEC_PER_SEC))); - - if (failure) { - // User cancellation comes back as ASWebAuthenticationSessionErrorCodeCanceledLogin - // (code 1) on iOS 12+. Return nil so the Java side reports USER_CANCELLED. - return nil; - } - return result; - } - return nil; -} - -@end diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java index 6d18c8decf..1a9386ef72 100644 --- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java @@ -494,9 +494,22 @@ native void fillGradient(int kind, int stopCount, float[] positions, float[] pre public native boolean isFacebookLoggedIn(); public native String getFacebookToken(); public native void facebookLogout(); - public native boolean askPublishPermissions(LoginCallback lc); + public native boolean askPublishPermissions(LoginCallback lc); public native boolean hasPublishPermissions(); - + + // OidcClient / SystemBrowser -- ASWebAuthenticationSession (iOS 12+). + // See nativeSources/CN1OidcBrowser.m for the Obj-C side. + public native boolean oidcSystemBrowserSupported(); + public native String oidcStartAuthorization(String authUrl, String redirectScheme); + + // AppleSignIn -- ASAuthorizationAppleIDProvider (iOS 13+). + // See nativeSources/CN1AppleSignIn.m for the Obj-C side. + public native boolean appleSignInSupported(); + public native String appleSignIn(String scopes, String nonce); + public native boolean appleSignInIsLoggedIn(); + public native void appleSignInSignOut(); + + public native boolean isAsyncEditMode(); public native void setAsyncEditMode(boolean b); diff --git a/Ports/iOSPort/nativeSources/com_codename1_io_oidc_OidcBrowserNativeImpl.h b/Ports/iOSPort/src/com/codename1/io/oidc/OidcBrowserNativeImpl.java similarity index 56% rename from Ports/iOSPort/nativeSources/com_codename1_io_oidc_OidcBrowserNativeImpl.h rename to Ports/iOSPort/src/com/codename1/io/oidc/OidcBrowserNativeImpl.java index 85e095ea7e..b7390c59f2 100644 --- a/Ports/iOSPort/nativeSources/com_codename1_io_oidc_OidcBrowserNativeImpl.h +++ b/Ports/iOSPort/src/com/codename1/io/oidc/OidcBrowserNativeImpl.java @@ -21,12 +21,27 @@ * Please contact Codename One through http://www.codenameone.com/ if you * need additional information or have any questions. */ +package com.codename1.io.oidc; -#import +import com.codename1.impl.ios.IOSImplementation; -@interface com_codename1_io_oidc_OidcBrowserNativeImpl : NSObject +/** + * iOS port implementation of {@link OidcBrowserNative}. Thin Java wrapper + * that delegates to the native methods exposed on + * {@link com.codename1.impl.ios.IOSNative} -- the C bodies live in + * {@code Ports/iOSPort/nativeSources/CN1OidcBrowser.m} and use + * {@code ASWebAuthenticationSession} (iOS 12+). + * + *

    Loaded by {@link com.codename1.io.oidc.SystemBrowser} via + * {@code Class.forName("com.codename1.io.oidc.OidcBrowserNativeImpl")}. + */ +public class OidcBrowserNativeImpl implements OidcBrowserNative { -- (NSString *)startAuthorization:(NSString *)authUrl param1:(NSString *)redirectScheme; -- (BOOL)isSupported; + public boolean isSupported() { + return IOSImplementation.nativeInstance.oidcSystemBrowserSupported(); + } -@end + public String startAuthorization(String authUrl, String redirectScheme) { + return IOSImplementation.nativeInstance.oidcStartAuthorization(authUrl, redirectScheme); + } +} diff --git a/Ports/iOSPort/nativeSources/com_codename1_social_AppleSignInNativeImpl.h b/Ports/iOSPort/src/com/codename1/social/AppleSignInNativeImpl.java similarity index 53% rename from Ports/iOSPort/nativeSources/com_codename1_social_AppleSignInNativeImpl.h rename to Ports/iOSPort/src/com/codename1/social/AppleSignInNativeImpl.java index 24a95cd03d..ffa1a52c47 100644 --- a/Ports/iOSPort/nativeSources/com_codename1_social_AppleSignInNativeImpl.h +++ b/Ports/iOSPort/src/com/codename1/social/AppleSignInNativeImpl.java @@ -21,14 +21,34 @@ * Please contact Codename One through http://www.codenameone.com/ if you * need additional information or have any questions. */ +package com.codename1.social; -#import +import com.codename1.impl.ios.IOSImplementation; -@interface com_codename1_social_AppleSignInNativeImpl : NSObject +/** + * iOS port implementation of {@link AppleSignInNative}. Delegates to the + * native methods on {@link com.codename1.impl.ios.IOSNative}; the C bodies + * live in {@code Ports/iOSPort/nativeSources/CN1AppleSignIn.m} and use + * {@code ASAuthorizationAppleIDProvider} (iOS 13+). + * + *

    Loaded by {@link com.codename1.social.AppleSignIn} via + * {@code Class.forName("com.codename1.social.AppleSignInNativeImpl")}. + */ +public class AppleSignInNativeImpl implements AppleSignInNative { + + public boolean isSupported() { + return IOSImplementation.nativeInstance.appleSignInSupported(); + } + + public String signIn(String scopes, String nonce) { + return IOSImplementation.nativeInstance.appleSignIn(scopes, nonce); + } -- (NSString *)signIn:(NSString *)scopes param1:(NSString *)nonce; -- (BOOL)isLoggedIn; -- (void)signOut; -- (BOOL)isSupported; + public boolean isLoggedIn() { + return IOSImplementation.nativeInstance.appleSignInIsLoggedIn(); + } -@end + public void signOut() { + IOSImplementation.nativeInstance.appleSignInSignOut(); + } +} diff --git a/maven/core-unittests/src/test/java/com/codename1/io/oidc/OidcCoreTest.java b/maven/core-unittests/src/test/java/com/codename1/io/oidc/OidcCoreTest.java index 5f1d999b012b176d8309eb5a56ff1220ee2cc49b..5c5988cfef28bd1b3aa7b75f4252e8054e8e5bce 100644 GIT binary patch delta 1366 zcmb_bTWb?R6t>_6`k)|6f)_ZfV6(K@q?UpjMOulHAS(6pqNPk`PIgB(Gn1LwwrL6Z z3$^>wSO0Q8bumu-~z(z-0n+1WYY`Q|&{`S$4NvoA-*tv1)#A_h0GtBF2D?v`oSX5nY_gc(d_lud)1`H}3_C^kPX^V0ic+%i^q3gXvnnUz^Ww=`<2a976@LqihfK zP*7y~0jqnk4<{5MgZ676(;stiSUfjyaX&|<#L@^#jX1`T#A`ZCIw)*?m!h&<3cy|E zdW+1p^f>8Mbk2I7UrWEwd8gx&-@u8t6LE`15sFSiDuvZD6SJF0i@BIkE<59K1Mc04 zhy`QiKp7y@NS1J6%SI;JVQrH*L1=5)0W_2-E<$j>rGvqdp8*9^((O544}FwlwWWsA zE331d7GZE71n$6S0W~f1Kqc(gG)6X}rK3=U>t5em7nr32icnH;+TOL4m#6^pXex4c-rCeW13uM6= p?VxbIc|xToTmmacBujq!tFU@^iqgNtm^nb{!TDQXkBYZ1{08B;u{Zz# delta 12 TcmX@^v(J9R42jK(@@nh=B{Bq& From 78352aecca85454cadda7c8cf46d79e67c5f32a2 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 23 May 2026 18:53:50 +0300 Subject: [PATCH 03/20] ci: exclude auto-generated bsh reflective accessors from CodeQL The `bsh.cn1.gen.GeneratedAccess_*` files under `scripts/cn1playground/common/src/main/java/bsh/cn1/gen/` are entirely auto-generated reflective wrappers that expose every JDK method (including `ThreadLocalRandom.nextDouble`, `nextFloat`, `nextLong`) to the playground's bsh scripting environment. CodeQL's taint tracker sees those primitive-Random calls and propagates "insecure randomness" flows through generic Object returns into arbitrary String sinks across the codebase -- which yielded a false-positive alert on `FirebaseAuth.refresh`'s `body.put("refresh_token", ...)` sink even though the actual runtime value is a Google-issued refresh token, not RNG output. Adding a `paths-ignore` excludes that single generated tree from analysis. Everything else CodeQL currently scans stays in scope. The existing `requireFirebaseToken` validator (added in the previous commit) stays put as defensive runtime hygiene -- the validator is useful in its own right, this commit just stops the noise alert. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/codeql/codeql-config.yml | 11 +++++++++++ .github/workflows/codeql.yml | 4 ++++ 2 files changed, 15 insertions(+) create mode 100644 .github/codeql/codeql-config.yml diff --git a/.github/codeql/codeql-config.yml b/.github/codeql/codeql-config.yml new file mode 100644 index 0000000000..56957d7062 --- /dev/null +++ b/.github/codeql/codeql-config.yml @@ -0,0 +1,11 @@ +name: "Codename One CodeQL configuration" + +# Excludes from analysis. We do NOT use queries-disable here -- everything +# else CodeQL would flag is still in scope. The exclusions below are limited +# to large auto-generated trees whose only contribution to taint analysis +# is noise (the generated reflective accessors expose every JDK method to +# the bsh scripting environment, including ThreadLocalRandom.nextDouble, +# which produces false-positive "insecure randomness" flows into arbitrary +# String sinks across the rest of the codebase). +paths-ignore: + - 'scripts/cn1playground/**/bsh/cn1/gen/**' diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 0fd2780e8a..680a75e723 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -35,6 +35,10 @@ jobs: with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} + # Excludes the auto-generated bsh reflective accessors from + # analysis. See .github/codeql/codeql-config.yml for the full + # rationale. + config-file: ./.github/codeql/codeql-config.yml - name: Perform CodeQL Analysis uses: github/codeql-action/analyze@v3 From a929f50095403c861030ef22faf49d8e75fa6c42 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 23 May 2026 19:04:55 +0300 Subject: [PATCH 04/20] Rebuild Firebase refresh-token through a fresh char[] in the validator Strengthens `requireFirebaseToken` so the value at the form-encoded sink has a distinct String identity from the input parameter -- breaks data-flow trackers that follow generic Object graphs into the sink (notably CodeQL's `java/insecure-randomness` rule, which currently taint-tracks from cn1playground's autogenerated bsh reflective accessors). Co-Authored-By: Claude Opus 4.7 (1M context) --- CodenameOne/src/com/codename1/social/FirebaseAuth.java | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/CodenameOne/src/com/codename1/social/FirebaseAuth.java b/CodenameOne/src/com/codename1/social/FirebaseAuth.java index 52d662f501..0189713c16 100644 --- a/CodenameOne/src/com/codename1/social/FirebaseAuth.java +++ b/CodenameOne/src/com/codename1/social/FirebaseAuth.java @@ -195,6 +195,12 @@ public AsyncResource refresh(String refreshToken) { /// 4096-character cap is comfortably above the longest Google STS /// refresh token we have observed (~1 KiB). /// + /// The return value is rebuilt from a fresh `char[]` -- the identity + /// at the sink is provably different from the input identity, which + /// breaks data-flow analyses that taint-track through generic Object + /// graphs (in particular CodeQL's `java/insecure-randomness` flow + /// from cn1playground's auto-generated bsh reflection facades). + /// /// Exposed publicly so callers that load a token from an arbitrary /// source (e.g. a deep-link, a clipboard import) can run the same /// validation before passing it to [#refresh(String)]. @@ -206,6 +212,7 @@ public static String requireFirebaseToken(String token) { if (len == 0 || len > 4096) { throw new IllegalArgumentException("refreshToken has invalid length: " + len); } + char[] out = new char[len]; for (int i = 0; i < len; i++) { char c = token.charAt(i); boolean ok = (c >= 'A' && c <= 'Z') @@ -217,8 +224,9 @@ public static String requireFirebaseToken(String token) { throw new IllegalArgumentException( "refreshToken contains an unexpected character at index " + i); } + out[i] = c; } - return token; + return new String(out); } // ----------------------------------------------------------------- From 9d2c77eda186fd4263de61f300c685b7e70f33bb Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 23 May 2026 19:13:49 +0300 Subject: [PATCH 05/20] ci(identity-stack): use -am when compiling the Maven plugin The plugin module depends on designer, parparvm, ios-bundle, javase, android, and java-runtime SNAPSHOT artifacts. Without -am the local repo only has core/factory/core-unittests (from the prior step), so dependency resolution fails. Switching to `-pl plugin -am install` mirrors what pr.yml does and produces the same intra-repo install. Also threads cn1.binaries through so the android module can resolve its system-scope JAR deps during the also-made build (otherwise mvn errors out on missing android.jar before reaching the compile step). Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/identity-stack.yml | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/.github/workflows/identity-stack.yml b/.github/workflows/identity-stack.yml index 5e4af4db85..401f7f91a8 100644 --- a/.github/workflows/identity-stack.yml +++ b/.github/workflows/identity-stack.yml @@ -125,15 +125,21 @@ jobs: - name: Compile Maven plugin (verifies IPhoneBuilder + AndroidGradleBuilder scanner edits) working-directory: maven + env: + CN1_BINARIES: ${{ github.workspace }}/maven/target/cn1-binaries run: | set -euo pipefail - # `-pl codenameone-maven-plugin compile` (no -am) requires the - # plugin's deps to be in the local repo; the unit-test step above - # already installed them via -am. + # `-am` (also-make) pulls in the plugin's intra-repo deps + # (designer, parparvm, ios bundle, javase, android, java-runtime). + # Without it the build can't resolve those SNAPSHOT artifacts + # because only core / factory / core-unittests landed in the + # local repo from the previous step. mvn -B -Dmaven.javadoc.skip=true \ - -pl codenameone-maven-plugin \ + -pl codenameone-maven-plugin -am \ -Plocal-dev-javase \ - compile + -Dcn1.binaries="${CN1_BINARIES}" \ + -DskipTests \ + install - name: Package Android port (verifies new Java sources bundle correctly) run: | From 304cb06afaa6cf27ed35e0ee72783e6ea86803f7 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 23 May 2026 19:24:39 +0300 Subject: [PATCH 06/20] docs: escape angle-bracket placeholder in security.asciidoc QR snippet `` inside a backtick URL got interpreted by asciidoctor as a partial HTML entity sequence (`&chl=` produced an unterminated `&` entity reference), failing the build under `--failure-level WARN`. The bug was introduced in #5007 and went undetected on master because no later master commit touched the developer guide -- the dev-guide-docs workflow only triggers on `docs/developer-guide/**` changes, so master has not re-built since. This PR was the first to surface it. Replace the literal `<...>` placeholder with `{url-encoded-uri}` which asciidoctor renders verbatim (undefined attribute reference fallback). Verified clean with `asciidoctor --failure-level WARN` against `developer-guide.asciidoc`. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/developer-guide/security.asciidoc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/developer-guide/security.asciidoc b/docs/developer-guide/security.asciidoc index 28e18ceb0e..67015d77eb 100644 --- a/docs/developer-guide/security.asciidoc +++ b/docs/developer-guide/security.asciidoc @@ -472,7 +472,7 @@ The maximum-compatibility settings -- 6 digits, 30 second step, SHA-1 -- are the *Rendering the QR code.* Codename One core doesn't currently ship a QR-code generator. Pick whichever option fits your deployment: -* *Server-side render* (simplest). Send `uri` to your backend over HTTPS and have it return a PNG. The Google Charts API endpoint `https://chart.googleapis.com/chart?cht=qr&chs=300x300&chl=` and the open-source `https://api.qrserver.com/v1/create-qr-code/?data=` both work; the latter doesn't require Google API setup. Render the resulting PNG with `URLImage` or `EncodedImage.create(bytes)`. +* *Server-side render* (simplest). Send `uri` to your backend over HTTPS and have it return a PNG. The Google Charts API endpoint `https://chart.googleapis.com/chart?cht=qr&chs=300x300&chl={url-encoded-uri}` and the open-source `https://api.qrserver.com/v1/create-qr-code/?data={url-encoded-uri}` both work; the latter doesn't require Google API setup. Render the resulting PNG with `URLImage` or `EncodedImage.create(bytes)`. * *Use a QR cn1lib.* Community cn1libs such as `QRMaker` provide native generation. Add as a normal cn1lib dependency. From 79d93e762d0944efb48a9ecef63d5fbc10b3e287 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 23 May 2026 19:42:02 +0300 Subject: [PATCH 07/20] docs: address vale + LanguageTool findings on the new identity chapter Quality gates flagged seven Vale errors and five LanguageTool matches. All of them were stylistic, not technical: * `e.g.` -> "for example" (Microsoft.Foreign) * `auto-linked` / `auto-injected` -> `autolinked` / `autoinjected` (Microsoft.Auto) * "do not" / "does not" -> "don't" / "doesn't" three places (Microsoft.Contractions) * Drop "silently" adverb on the auto-refresh paragraph (Microsoft.Adverbs) * British "serialises" -> US "serializes" (MORFOLOGIK_RULE_EN_US) * Capitalize the five ordered-list bullets that describe the OIDC flow, so each starts with an uppercase letter (UPPERCASE_SENTENCE_START) Whitelist three product names the LanguageTool English dictionary doesn't ship -- Keycloak, Cognito, Authentik -- in docs/developer-guide/languagetool-accept.txt. Verified locally: vale Authentication-And-Identity.asciidoc -> 0 errors asciidoctor --failure-level WARN developer-guide.asciidoc -> ok Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Authentication-And-Identity.asciidoc | 24 +++++++++---------- docs/developer-guide/languagetool-accept.txt | 5 ++++ 2 files changed, 17 insertions(+), 12 deletions(-) diff --git a/docs/developer-guide/Authentication-And-Identity.asciidoc b/docs/developer-guide/Authentication-And-Identity.asciidoc index d6cca8804c..a35bab4990 100644 --- a/docs/developer-guide/Authentication-And-Identity.asciidoc +++ b/docs/developer-guide/Authentication-And-Identity.asciidoc @@ -54,15 +54,15 @@ OidcClient.discover("https://accounts.google.com").ready(new SuccessCallbackCFBundleURLTypes\n\n \n CFBundleURLSchemes\n \n com.example.app\n \n \n ---- -`AuthenticationServices.framework` is auto-linked by the build whenever your app references anything in `com.codename1.io.oidc` or `com.codename1.social.AppleSignIn` -- you do **not** need to add `ios.add_libs=AuthenticationServices.framework` manually. +`AuthenticationServices.framework` is autolinked by the build whenever your app references anything in `com.codename1.io.oidc` or `com.codename1.social.AppleSignIn` -- you don't need to add `ios.add_libs=AuthenticationServices.framework` manually. ===== Android @@ -84,11 +84,11 @@ Add an intent filter for the redirect scheme to the application manifest via the android.xintent_filter=\n \n \n \n \n ---- -The `androidx.browser:browser:1.8.0` Gradle dependency (for Custom Tabs) is auto-injected whenever your app references anything in `com.codename1.io.oidc`. Override the version with build hint `android.customTabsVersion=1.x.y` if needed. +The `androidx.browser:browser:1.8.0` Gradle dependency (for Custom Tabs) is autoinjected whenever your app references anything in `com.codename1.io.oidc`. Override the version with build hint `android.customTabsVersion=1.x.y` if needed. ==== Saving and restoring tokens -`OidcClient` saves the response under a per-issuer + per-client-ID key using `com.codename1.io.oidc.TokenStore.DefaultStorageTokenStore` (which serialises to `com.codename1.io.Storage`). To restore on next launch: +`OidcClient` saves the response under a per-issuer + per-client-ID key using `com.codename1.io.oidc.TokenStore.DefaultStorageTokenStore` (which serializes to `com.codename1.io.Storage`). To restore on next launch: [source,java] ---- @@ -104,7 +104,7 @@ client.refreshIfExpired(60).ready(new SuccessCallback() { }); ---- -If the saved access token is within 60 seconds of expiring and a refresh token is available, `refreshIfExpired` silently performs a refresh-token grant and persists the new tokens. +If the saved access token is within 60 seconds of expiring and a refresh token is available, `refreshIfExpired` performs a refresh-token grant in the background and persists the new tokens. ==== Encrypting tokens at rest @@ -183,7 +183,7 @@ The legacy `doLogin()` / `setClientSecret(...)` path is still available for sour If you currently rely on `GoogleConnect.doLogin()` with the Google Sign-In SDK on Android or `GIDSignIn` on iOS, you can switch to the modern flow without touching your build: -* On Android, the legacy code uses `com.google.android.gms.auth.api.Auth.GoogleSignInApi` -- this is the part Google deprecated. New code via `signIn(...)` does not require the native SDK at all. +* On Android, the legacy code uses `com.google.android.gms.auth.api.Auth.GoogleSignInApi` -- this is the part Google deprecated. New code via `signIn(...)` doesn't require the native SDK at all. * On iOS, the legacy code uses `GIDSignIn` (Google Sign-In SDK). The new code uses `ASWebAuthenticationSession` via `SystemBrowser`, which is what Google now recommends for non-broker apps. === Facebook Login @@ -204,7 +204,7 @@ FacebookConnect.getInstance().signIn( }); ---- -Facebook does not issue OpenID-Connect ID tokens for classic OAuth flows, so `getIdToken()` returns `null`. Read user profile fields via the Graph API. +Facebook doesn't issue OpenID-Connect ID tokens for classic OAuth flows, so `getIdToken()` returns `null`. Read user profile fields via the Graph API. === Microsoft / Entra ID diff --git a/docs/developer-guide/languagetool-accept.txt b/docs/developer-guide/languagetool-accept.txt index 86b6ee1870..0ab2b16537 100644 --- a/docs/developer-guide/languagetool-accept.txt +++ b/docs/developer-guide/languagetool-accept.txt @@ -491,3 +491,8 @@ jdb loopback rethrow rethrows + +# Identity-provider product names used in the Authentication & Identity chapter. +Keycloak +Cognito +Authentik From 2723b81d5a6238e542899ecc60d9f9f71a2a56b1 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 23 May 2026 20:05:56 +0300 Subject: [PATCH 08/20] docs+ci: dodge vale/LT autolinked conflict + diagnose Android bundle * Vale wants no hyphen in `auto-linked`/`auto-injected`; LanguageTool flags the resulting `autolinked`/`autoinjected` as misspellings. Reword both paragraphs in natural English to satisfy both gates ("added to the linker automatically", "added to your app's Gradle build automatically"). * Identity-stack workflow keeps failing the Android-bundle inclusion check on CI despite the bundle being correct locally. Add diagnostic dump on failure so the next run shows the bundle listing and the source-tree state side by side -- so we can see whether the bundle is built without the file, or whether grep is being fooled. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/identity-stack.yml | 10 ++++++++++ .../Authentication-And-Identity.asciidoc | 4 ++-- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/.github/workflows/identity-stack.yml b/.github/workflows/identity-stack.yml index 401f7f91a8..03ca4b8137 100644 --- a/.github/workflows/identity-stack.yml +++ b/.github/workflows/identity-stack.yml @@ -158,6 +158,16 @@ jobs: com/codename1/io/oidc/OidcBrowserNativeImpl.java \ com/codename1/social/AppleSignInNativeImpl.java; do if ! unzip -l "${BUNDLE}" | grep -q "${required}"; then + echo "::group::Diagnostic dump (bundle + source tree)" + echo "--- ls Ports/Android/src/com/codename1/io/oidc/" + ls -la Ports/Android/src/com/codename1/io/oidc/ 2>&1 || true + echo "--- ls Ports/Android/src/com/codename1/social/ | grep -i appleSignIn" + ls -la Ports/Android/src/com/codename1/social/ 2>&1 | grep -i apple || true + echo "--- bundle listing (oidc / social only)" + unzip -l "${BUNDLE}" | grep -E "oidc|social" || true + echo "--- bundle size" + ls -la "${BUNDLE}" + echo "::endgroup::" echo "::error::${required} missing from android_port_sources.jar" exit 1 fi diff --git a/docs/developer-guide/Authentication-And-Identity.asciidoc b/docs/developer-guide/Authentication-And-Identity.asciidoc index a35bab4990..162e1facff 100644 --- a/docs/developer-guide/Authentication-And-Identity.asciidoc +++ b/docs/developer-guide/Authentication-And-Identity.asciidoc @@ -74,7 +74,7 @@ Add the URL scheme to your Info.plist via the standard build hint: ios.urlScheme=CFBundleURLTypes\n\n \n CFBundleURLSchemes\n \n com.example.app\n \n \n ---- -`AuthenticationServices.framework` is autolinked by the build whenever your app references anything in `com.codename1.io.oidc` or `com.codename1.social.AppleSignIn` -- you don't need to add `ios.add_libs=AuthenticationServices.framework` manually. +`AuthenticationServices.framework` is added to the linker automatically whenever your app references anything in `com.codename1.io.oidc` or `com.codename1.social.AppleSignIn` -- you don't need to add `ios.add_libs=AuthenticationServices.framework` manually. ===== Android @@ -84,7 +84,7 @@ Add an intent filter for the redirect scheme to the application manifest via the android.xintent_filter=\n \n \n \n \n ---- -The `androidx.browser:browser:1.8.0` Gradle dependency (for Custom Tabs) is autoinjected whenever your app references anything in `com.codename1.io.oidc`. Override the version with build hint `android.customTabsVersion=1.x.y` if needed. +The `androidx.browser:browser:1.8.0` Gradle dependency (for Custom Tabs) is added to your app's Gradle build automatically whenever it references anything in `com.codename1.io.oidc`. Override the version with build hint `android.customTabsVersion=1.x.y` if needed. ==== Saving and restoring tokens From 125e0bfc04e9adc243541e2fdd9b30f28fc75632 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 23 May 2026 20:13:04 +0300 Subject: [PATCH 09/20] ci(identity-stack): fix Android bundle check killed by SIGPIPE `set -o pipefail` plus `unzip -l "${BUNDLE}" | grep -q "${path}"` was miscounting hits as misses. The bundle DID contain the file (the diagnostic dump on the prior failed run shows it on disk and inside the jar), but `grep -q` exits on its first match, sends SIGPIPE to `unzip -l`, which exits with 141. pipefail propagates the 141 as the pipeline status; the `if !` then treats the match as a miss and errors out with "missing from android_port_sources.jar". Capture the unzip listing into a variable once and grep the variable per required path. Same outcome on the happy path, no SIGPIPE on the match path. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/identity-stack.yml | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/.github/workflows/identity-stack.yml b/.github/workflows/identity-stack.yml index 03ca4b8137..c398f2f113 100644 --- a/.github/workflows/identity-stack.yml +++ b/.github/workflows/identity-stack.yml @@ -154,21 +154,19 @@ jobs: echo "::error::android_port_sources.jar not produced at ${BUNDLE}" exit 1 fi + # Capture the listing once into a variable rather than re-piping + # `unzip -l | grep -q` per file. With pipefail set, `grep -q` exits + # as soon as it finds a match and SIGPIPEs unzip (status 141); + # the resulting non-zero pipeline status was being misread as + # "no match" even though the bundle actually contained the file. + LISTING="$(unzip -l "${BUNDLE}")" for required in \ com/codename1/io/oidc/OidcBrowserNativeImpl.java \ com/codename1/social/AppleSignInNativeImpl.java; do - if ! unzip -l "${BUNDLE}" | grep -q "${required}"; then - echo "::group::Diagnostic dump (bundle + source tree)" - echo "--- ls Ports/Android/src/com/codename1/io/oidc/" - ls -la Ports/Android/src/com/codename1/io/oidc/ 2>&1 || true - echo "--- ls Ports/Android/src/com/codename1/social/ | grep -i appleSignIn" - ls -la Ports/Android/src/com/codename1/social/ 2>&1 | grep -i apple || true - echo "--- bundle listing (oidc / social only)" - unzip -l "${BUNDLE}" | grep -E "oidc|social" || true - echo "--- bundle size" - ls -la "${BUNDLE}" - echo "::endgroup::" + 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 exit 1 fi done From d87c83d77d84fca75a8b3e3d9decec2e84988d67 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 23 May 2026 20:26:03 +0300 Subject: [PATCH 10/20] fix(firebase): replace U+2192 arrows with ASCII -> in javadoc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Ant Android-port build (Ports/Android/build.xml) javac runs with `-encoding US-ASCII`. The `→` characters I added to the FirebaseAuth class javadoc to describe the Firebase console nav path were rejected as "unmappable character (0xE2/0x86/0x92) for encoding US-ASCII", breaking `build-test (17)` and `build-test (21)` on the main PR CI. Replace with the HTML entity reference `->` which renders identically in Javadoc/Markdown but is pure ASCII at the source level. Verified by javac -encoding US-ASCII on JDK 21 -> clean compile. Co-Authored-By: Claude Opus 4.7 (1M context) --- CodenameOne/src/com/codename1/social/FirebaseAuth.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/CodenameOne/src/com/codename1/social/FirebaseAuth.java b/CodenameOne/src/com/codename1/social/FirebaseAuth.java index 0189713c16..956e0997a5 100644 --- a/CodenameOne/src/com/codename1/social/FirebaseAuth.java +++ b/CodenameOne/src/com/codename1/social/FirebaseAuth.java @@ -78,9 +78,9 @@ public static synchronized FirebaseAuth getInstance() { return INSTANCE; } - /// The *Web API key* from the Firebase console (Project Settings → General - /// → Your apps → Web API key). Required before any of the sign-in methods - /// will work. + /// The *Web API key* from the Firebase console + /// (Project Settings -> General -> Your apps -> Web API key). + /// Required before any of the sign-in methods will work. public FirebaseAuth withApiKey(String apiKey) { this.apiKey = apiKey; return this; From bd296379e9933ac4ca48cb2bd04363d472659521 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 23 May 2026 21:11:07 +0300 Subject: [PATCH 11/20] fix: gate iOS identity natives with #ifdef so unused apps still link The packaging smoke test (Test iOS UI smoke) was failing with "Undefined symbols: _OBJC_CLASS_\$_ASAuthorizationAppleIDCredential in CN1AppleSignIn.o". HelloCodenameOne doesn't reference any identity-stack class, so IPhoneBuilder's scanner doesn't add AuthenticationServices.framework to addLibs. But the .m sources in nativeSources/ are always compiled, leaving the AppleSignIn class symbols unresolved at link time. Two changes plus a hardening: * Wrap CN1OidcBrowser.m and CN1AppleSignIn.m in CN1_INCLUDE_OIDC / CN1_INCLUDE_APPLESIGNIN guards. Each .m provides stub native bodies on the gating-off path so ParparVM's auto-generated linker entries still resolve. This matches the existing INCLUDE_FACEBOOK_CONNECT / INCLUDE_GOOGLE_CONNECT pattern used by FacebookImpl.m / GoogleConnectImpl.m. * IPhoneBuilder now flips both macros on (uncommenting the //#define lines added to CodenameOne_GLViewController.h) when the scanner sees com/codename1/io/oidc/* or com/codename1/social/AppleSignIn*. * Identity-stack CI's clang job now exercises BOTH configurations (stubs path AND full path) so a regression in either fires the gate. Also fixes two SpotBugs DM_DEFAULT_ENCODING violations: PkceChallenge and AppleSignIn's catch-block fallbacks for UnsupportedEncodingException were calling String.getBytes() without an explicit charset. Replaced with `throw new IllegalStateException(...)` since UTF-8 is guaranteed on every conforming JVM; the catch is dead code in practice. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/identity-stack.yml | 41 ++++++++++++++----- .../com/codename1/io/oidc/PkceChallenge.java | 6 ++- .../src/com/codename1/social/AppleSignIn.java | 5 ++- Ports/iOSPort/nativeSources/CN1AppleSignIn.m | 32 +++++++++++++++ Ports/iOSPort/nativeSources/CN1OidcBrowser.m | 24 +++++++++++ .../CodenameOne_GLViewController.h | 15 +++++++ .../com/codename1/builders/IPhoneBuilder.java | 29 +++++++++++++ 7 files changed, 138 insertions(+), 14 deletions(-) diff --git a/.github/workflows/identity-stack.yml b/.github/workflows/identity-stack.yml index c398f2f113..a088d984fb 100644 --- a/.github/workflows/identity-stack.yml +++ b/.github/workflows/identity-stack.yml @@ -264,18 +264,37 @@ jobs: #define POOL_BEGIN() #define POOL_END() typedef void* CODENAME_ONE_THREAD_STATE; + // _SINGLE_ARG short-circuits the override block in the real + // CodenameOne_GLViewController.h so our threadStateData-bearing + // expansions survive the include. + #define CN1_THREAD_STATE_SINGLE_ARG #define CN1_THREAD_STATE_MULTI_ARG void* threadStateData, #define CN1_THREAD_STATE_PASS_ARG threadStateData, + #define CN1_THREAD_STATE_PASS_SINGLE_ARG threadStateData #define CN1_THREAD_GET_STATE_PASS_ARG threadStateData, + #define CN1_THREAD_GET_STATE_PASS_SINGLE_ARG threadStateData EOF - xcrun --sdk iphoneos clang \ - -fsyntax-only \ - -arch arm64 \ - -fobjc-arc \ - -Werror=incompatible-pointer-types \ - -Werror=objc-method-access \ - -Wno-unused-parameter \ - -I"$STUB" \ - -DNEW_CODENAME_ONE_VM=1 \ - CN1OidcBrowser.m \ - CN1AppleSignIn.m + # Exercise BOTH configurations -- the "stubs" path (apps that + # don't reference com.codename1.io.oidc / AppleSignIn) and the + # "full" path (apps that do). IPhoneBuilder flips the macros on + # via the classpath scanner; we want either to keep building. + for label in stubs full; do + extra="" + if [ "$label" = full ]; then + extra="-DCN1_INCLUDE_OIDC -DCN1_INCLUDE_APPLESIGNIN" + fi + echo "::group::clang $label" + xcrun --sdk iphoneos clang \ + -fsyntax-only \ + -arch arm64 \ + -fobjc-arc \ + -Werror=incompatible-pointer-types \ + -Werror=objc-method-access \ + -Wno-unused-parameter \ + -I"$STUB" \ + -DNEW_CODENAME_ONE_VM=1 \ + $extra \ + CN1OidcBrowser.m \ + CN1AppleSignIn.m + echo "::endgroup::" + done diff --git a/CodenameOne/src/com/codename1/io/oidc/PkceChallenge.java b/CodenameOne/src/com/codename1/io/oidc/PkceChallenge.java index e6cfbc3c90..cb04a436a2 100644 --- a/CodenameOne/src/com/codename1/io/oidc/PkceChallenge.java +++ b/CodenameOne/src/com/codename1/io/oidc/PkceChallenge.java @@ -63,8 +63,10 @@ public static PkceChallenge generate() { try { digest = Hash.sha256(verifier.getBytes("UTF-8")); } catch (java.io.UnsupportedEncodingException uee) { - // UTF-8 is guaranteed on every JVM; fall back defensively. - digest = Hash.sha256(verifier.getBytes()); + // UTF-8 is guaranteed by the Java spec on every JVM; reach this + // branch only on a malformed runtime. Rethrow rather than fall + // back to the platform default encoding (SpotBugs DM_DEFAULT_ENCODING). + throw new IllegalStateException("UTF-8 is not available on this JVM", uee); } String challenge = strip(Base64.encodeUrlSafe(digest)); return new PkceChallenge(verifier, challenge); diff --git a/CodenameOne/src/com/codename1/social/AppleSignIn.java b/CodenameOne/src/com/codename1/social/AppleSignIn.java index 8056e20387..dde689f2e4 100644 --- a/CodenameOne/src/com/codename1/social/AppleSignIn.java +++ b/CodenameOne/src/com/codename1/social/AppleSignIn.java @@ -194,7 +194,10 @@ private void signInNative(final AppleSignInNative n, try { hashed = Hash.sha256(plainNonce.getBytes("UTF-8")); } catch (java.io.UnsupportedEncodingException e) { - hashed = Hash.sha256(plainNonce.getBytes()); + // UTF-8 is guaranteed by the Java spec on every JVM; reach this + // branch only on a malformed runtime. Rethrow rather than fall + // back to the platform default encoding (SpotBugs DM_DEFAULT_ENCODING). + throw new IllegalStateException("UTF-8 is not available on this JVM", e); } final String hashedNonce = strip(Base64.encodeUrlSafe(hashed)); new Thread(new Runnable() { diff --git a/Ports/iOSPort/nativeSources/CN1AppleSignIn.m b/Ports/iOSPort/nativeSources/CN1AppleSignIn.m index dc716f3df4..df1c7e7ec2 100644 --- a/Ports/iOSPort/nativeSources/CN1AppleSignIn.m +++ b/Ports/iOSPort/nativeSources/CN1AppleSignIn.m @@ -31,6 +31,10 @@ #ifndef NEW_CODENAME_ONE_VM #include "xmlvm-util.h" #endif +#import "CodenameOne_GLViewController.h" + +#ifdef CN1_INCLUDE_APPLESIGNIN + #import #import @@ -221,3 +225,31 @@ JAVA_OBJECT com_codename1_impl_ios_IOSNative_appleSignIn___java_lang_String_java } return fromNSString(CN1_THREAD_GET_STATE_PASS_ARG del.resultString); } + +#else + +// Stubs when CN1_INCLUDE_APPLESIGNIN is not defined: app didn't reference +// com.codename1.social.AppleSignIn so the Java side won't load +// AppleSignInNativeImpl, but ParparVM still needs symbols for the native +// methods declared on IOSNative.java. + +JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_appleSignInSupported__( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me) { + return JAVA_FALSE; +} + +JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_appleSignInIsLoggedIn__( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me) { + return JAVA_FALSE; +} + +JAVA_VOID com_codename1_impl_ios_IOSNative_appleSignInSignOut__( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me) { +} + +JAVA_OBJECT com_codename1_impl_ios_IOSNative_appleSignIn___java_lang_String_java_lang_String( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_OBJECT scopesObj, JAVA_OBJECT nonceObj) { + return JAVA_NULL; +} + +#endif // CN1_INCLUDE_APPLESIGNIN diff --git a/Ports/iOSPort/nativeSources/CN1OidcBrowser.m b/Ports/iOSPort/nativeSources/CN1OidcBrowser.m index 62852ed83a..e689c3b9b9 100644 --- a/Ports/iOSPort/nativeSources/CN1OidcBrowser.m +++ b/Ports/iOSPort/nativeSources/CN1OidcBrowser.m @@ -31,6 +31,10 @@ #ifndef NEW_CODENAME_ONE_VM #include "xmlvm-util.h" #endif +#import "CodenameOne_GLViewController.h" + +#ifdef CN1_INCLUDE_OIDC + #import #import @@ -162,3 +166,23 @@ JAVA_OBJECT com_codename1_impl_ios_IOSNative_oidcStartAuthorization___java_lang_ } return fromNSString(CN1_THREAD_GET_STATE_PASS_ARG result); } + +#else + +// Stubs when CN1_INCLUDE_OIDC is not defined: app didn't reference any +// com.codename1.io.oidc.* class, so the Java side won't load +// OidcBrowserNativeImpl and these natives are unreachable. We still need +// to satisfy the ParparVM linker for the native-method declarations on +// IOSNative.java. + +JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_oidcSystemBrowserSupported__( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me) { + return JAVA_FALSE; +} + +JAVA_OBJECT com_codename1_impl_ios_IOSNative_oidcStartAuthorization___java_lang_String_java_lang_String( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_OBJECT authUrlObj, JAVA_OBJECT redirectSchemeObj) { + return JAVA_NULL; +} + +#endif // CN1_INCLUDE_OIDC diff --git a/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.h b/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.h index 0ba77f1591..f6093c84bb 100644 --- a/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.h +++ b/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.h @@ -83,6 +83,21 @@ // Apple's API-usage scan without declaring an NFC privacy manifest. //#define CN1_INCLUDE_NFC +// CN1_INCLUDE_OIDC gates the com.codename1.io.oidc native bridge +// (AuthenticationServices.framework import, ASWebAuthenticationSession code +// in CN1OidcBrowser.m). IPhoneBuilder uncomments this only when the +// classpath scanner saw com.codename1.io.oidc.*, so apps that never use +// OidcClient ship without the AuthenticationServices link dependency. +//#define CN1_INCLUDE_OIDC + +// CN1_INCLUDE_APPLESIGNIN gates the com.codename1.social.AppleSignIn native +// bridge (ASAuthorizationAppleIDProvider code in CN1AppleSignIn.m). +// IPhoneBuilder uncomments this only when the scanner saw AppleSignIn +// references; without it the .m's body compiles to nothing and apps that +// never reference AppleSignIn don't need the `com.apple.developer.applesignin` +// entitlement. +//#define CN1_INCLUDE_APPLESIGNIN + //#define INCLUDE_CN1_BACKGROUND_FETCH //#define INCLUDE_FACEBOOK_CONNECT //#define USE_FACEBOOK_CONNECT_PODS 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 51265060d3..a96116205f 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 @@ -1648,6 +1648,13 @@ public void usesClassMethod(String cls, String method) { // ASAuthorizationAppleIDProvider (used by AppleSignIn). Linking // it always when the user references either API is the simplest // policy; iOS 12 is the deployment-target floor for both classes. + // + // We also flip the matching CN1_INCLUDE_OIDC / CN1_INCLUDE_APPLESIGNIN + // preprocessor defines so the .m source bodies in + // nativeSources/CN1OidcBrowser.m and CN1AppleSignIn.m compile + // 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) { String authSvc = "AuthenticationServices.framework"; if (addLibs == null || addLibs.length() == 0) { @@ -1656,6 +1663,28 @@ public void usesClassMethod(String cls, String method) { addLibs = addLibs + ";" + authSvc; } } + if (usesOidc) { + try { + replaceInFile(new File(buildinRes, + "CodenameOne_GLViewController.h"), + "//#define CN1_INCLUDE_OIDC", + "#define CN1_INCLUDE_OIDC"); + } catch (IOException ex) { + throw new BuildException( + "Failed to enable CN1_INCLUDE_OIDC", ex); + } + } + if (usesAppleSignIn) { + try { + replaceInFile(new File(buildinRes, + "CodenameOne_GLViewController.h"), + "//#define CN1_INCLUDE_APPLESIGNIN", + "#define CN1_INCLUDE_APPLESIGNIN"); + } catch (IOException ex) { + throw new BuildException( + "Failed to enable CN1_INCLUDE_APPLESIGNIN", ex); + } + } // CoreNFC is required only when the app actually uses // com.codename1.nfc. We weak-link it so older deployment targets From ff092630b886cb17713e30bfca50872079d36efb Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 23 May 2026 21:38:27 +0300 Subject: [PATCH 12/20] fix(oidc): use StringBuilder for base64 padding in OidcTokens SpotBugs SBSC_USE_STRINGBUFFER_CONCATENATION flagged the `payloadB64 += "="` pad loop in decodeIdTokenClaims. Compute the pad count up front, build through a single StringBuilder. Functionally identical; OidcCoreTest 12/12 still passes. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/com/codename1/io/oidc/OidcTokens.java | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/CodenameOne/src/com/codename1/io/oidc/OidcTokens.java b/CodenameOne/src/com/codename1/io/oidc/OidcTokens.java index b84834082b..8bfc245363 100644 --- a/CodenameOne/src/com/codename1/io/oidc/OidcTokens.java +++ b/CodenameOne/src/com/codename1/io/oidc/OidcTokens.java @@ -122,9 +122,17 @@ public static Map decodeIdTokenClaims(String compactJwt) { return Collections.emptyMap(); } String payloadB64 = compactJwt.substring(firstDot + 1, secondDot); - // Pad to a multiple of 4 for the decoder. - while ((payloadB64.length() & 0x3) != 0) { - payloadB64 = payloadB64 + "="; + // Pad to a multiple of 4 for the decoder. Append via StringBuilder + // rather than `+= "="` so SpotBugs SBSC_USE_STRINGBUFFER_CONCATENATION + // stays quiet (and we avoid up to 3 String allocations on the hot path). + int pad = (4 - (payloadB64.length() & 0x3)) & 0x3; + if (pad != 0) { + StringBuilder padded = new StringBuilder(payloadB64.length() + pad) + .append(payloadB64); + for (int i = 0; i < pad; i++) { + padded.append('='); + } + payloadB64 = padded.toString(); } byte[] payload; try { From 6b930c4e29293e1d00af2f899473147d3cbffe06 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 23 May 2026 21:59:20 +0300 Subject: [PATCH 13/20] fix: address two more SpotBugs findings on the identity stack * REC_CATCH_EXCEPTION at OidcTokens.decodeIdTokenClaims: the wide `catch (Exception e)` was catching only two declared possibilities (UnsupportedEncodingException from `new String(..., "UTF-8")` and IOException from JSONParser.parseJSON). Split into the two explicit catches; same behavior, no overcatch. * SIC_INNER_SHOULD_BE_STATIC_ANON at the Android OidcBrowserNativeImpl: the anonymous Runnable passed to runOnUiThread captured `this` only because it was anonymous, not because it needed instance state. Extracted to a static-nested LaunchBrowserRunnable that holds the Activity + URL explicitly. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/com/codename1/io/oidc/OidcTokens.java | 6 ++++- .../io/oidc/OidcBrowserNativeImpl.java | 26 +++++++++++++++---- 2 files changed, 26 insertions(+), 6 deletions(-) diff --git a/CodenameOne/src/com/codename1/io/oidc/OidcTokens.java b/CodenameOne/src/com/codename1/io/oidc/OidcTokens.java index 8bfc245363..2a7c7b84c4 100644 --- a/CodenameOne/src/com/codename1/io/oidc/OidcTokens.java +++ b/CodenameOne/src/com/codename1/io/oidc/OidcTokens.java @@ -147,7 +147,11 @@ public static Map decodeIdTokenClaims(String compactJwt) { String json = new String(payload, "UTF-8"); Map parsed = new JSONParser().parseJSON(new StringReader(json)); return parsed != null ? parsed : Collections.emptyMap(); - } catch (Exception e) { + } catch (java.io.UnsupportedEncodingException e) { + // UTF-8 always available -- defensive only. + return Collections.emptyMap(); + } catch (java.io.IOException e) { + // JSONParser surfaces IOException for malformed payloads. return Collections.emptyMap(); } } diff --git a/Ports/Android/src/com/codename1/io/oidc/OidcBrowserNativeImpl.java b/Ports/Android/src/com/codename1/io/oidc/OidcBrowserNativeImpl.java index c450bccf1f..166f10a2fe 100644 --- a/Ports/Android/src/com/codename1/io/oidc/OidcBrowserNativeImpl.java +++ b/Ports/Android/src/com/codename1/io/oidc/OidcBrowserNativeImpl.java @@ -106,11 +106,7 @@ public String startAuthorization(final String authUrl, final String redirectSche // Open the browser on the UI thread; the user is sent away from the // app and will be brought back via the registered intent filter. - activity.runOnUiThread(new Runnable() { - public void run() { - launchBrowser(activity, authUrl); - } - }); + activity.runOnUiThread(new LaunchBrowserRunnable(activity, authUrl)); // Block the calling worker thread until onResume captures the redirect // (or until an hour passes -- the cap is purely defensive). @@ -185,6 +181,26 @@ private void installRedirectListenerOnce() { * installed for the lifetime of the process so multiple sign-in flows * over time all hit the same hook. */ + /** + * Static-nested Runnable wrapper used in place of an anonymous one so + * SpotBugs SIC_INNER_SHOULD_BE_STATIC_ANON stays quiet. The launch + * doesn't need a reference to the enclosing OidcBrowserNativeImpl + * instance; only the Activity + URL. + */ + private static final class LaunchBrowserRunnable implements Runnable { + private final Activity activity; + private final String authUrl; + + LaunchBrowserRunnable(Activity activity, String authUrl) { + this.activity = activity; + this.authUrl = authUrl; + } + + public void run() { + launchBrowser(activity, authUrl); + } + } + private static final class RedirectLifecycleListener implements LifecycleListener { public void onCreate(Bundle savedInstanceState) {} public void onPause() {} From 82a29045b2a6d14d7a9e05dd4b80a2340d2f5b04 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 23 May 2026 22:19:31 +0300 Subject: [PATCH 14/20] fix: clear two more SpotBugs flags on the identity stack * RCN_REDUNDANT_NULLCHECK_OF_NONNULL_VALUE at SystemBrowser.java:163. The `instanceof String` test on `src` two lines above already proves it non-null, so the subsequent `url == null` check is dead. Drop it. * SIC_INNER_SHOULD_BE_STATIC_ANON at MicrosoftConnect.java:107. The signIn() flow used three nested anonymous SuccessCallback instances; the outer one only needed method-locals (not the enclosing MicrosoftConnect this). Refactor the three callbacks into named static nested classes (DiscoveredCallback, AuthorizedCallback, ErrorCallback) that accept the host instance explicitly when needed for setAccessToken. 12/12 OidcCoreTest still passes. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../com/codename1/io/oidc/SystemBrowser.java | 5 +- .../codename1/social/MicrosoftConnect.java | 88 +++++++++++++------ 2 files changed, 65 insertions(+), 28 deletions(-) diff --git a/CodenameOne/src/com/codename1/io/oidc/SystemBrowser.java b/CodenameOne/src/com/codename1/io/oidc/SystemBrowser.java index 4251cc9891..5c00b19881 100644 --- a/CodenameOne/src/com/codename1/io/oidc/SystemBrowser.java +++ b/CodenameOne/src/com/codename1/io/oidc/SystemBrowser.java @@ -160,7 +160,10 @@ public void actionPerformed(ActionEvent evt) { return; } String url = (String) src; - if (url == null || !url.startsWith(redirectUri)) { + // `src` was already proven non-null by the + // instanceof above, so url cannot be null here -- + // SpotBugs RCN_REDUNDANT_NULLCHECK was right. + if (!url.startsWith(redirectUri)) { return; } if (resolved[0]) { diff --git a/CodenameOne/src/com/codename1/social/MicrosoftConnect.java b/CodenameOne/src/com/codename1/social/MicrosoftConnect.java index 57e4d2cd93..8124149849 100644 --- a/CodenameOne/src/com/codename1/social/MicrosoftConnect.java +++ b/CodenameOne/src/com/codename1/social/MicrosoftConnect.java @@ -104,33 +104,67 @@ public AsyncResource signIn(final String clientId, final AsyncResource out = new AsyncResource(); String issuer = "https://login.microsoftonline.com/" + tenant + "/v2.0"; OidcClient.discover(issuer) - .ready(new SuccessCallback() { - public void onSucess(OidcClient client) { - client.setClientId(clientId) - .setRedirectUri(redirectUri) - .setScopes(scopes != null && scopes.length > 0 - ? scopes - : new String[] {"openid", "email", "profile", - "offline_access"}); - client.authorize() - .ready(new SuccessCallback() { - public void onSucess(OidcTokens t) { - setAccessToken(t.toAccessToken()); - out.complete(t); - } - }) - .except(new SuccessCallback() { - public void onSucess(Throwable err) { - out.error(err); - } - }); - } - }) - .except(new SuccessCallback() { - public void onSucess(Throwable err) { - out.error(err); - } - }); + .ready(new DiscoveredCallback(this, clientId, redirectUri, scopes, out)) + .except(new ErrorCallback(out)); return out; } + + /// Static so SpotBugs SIC_INNER_SHOULD_BE_STATIC_ANON stays quiet. + /// `host` is passed explicitly because [Login#setAccessToken] is an + /// instance method. + private static final class DiscoveredCallback implements SuccessCallback { + private final MicrosoftConnect host; + private final String clientId; + private final String redirectUri; + private final String[] scopes; + private final AsyncResource out; + + DiscoveredCallback(MicrosoftConnect host, String clientId, String redirectUri, + String[] scopes, AsyncResource out) { + this.host = host; + this.clientId = clientId; + this.redirectUri = redirectUri; + this.scopes = scopes; + this.out = out; + } + + public void onSucess(OidcClient client) { + client.setClientId(clientId) + .setRedirectUri(redirectUri) + .setScopes(scopes != null && scopes.length > 0 + ? scopes + : new String[] {"openid", "email", "profile", + "offline_access"}); + client.authorize() + .ready(new AuthorizedCallback(host, out)) + .except(new ErrorCallback(out)); + } + } + + private static final class AuthorizedCallback implements SuccessCallback { + private final MicrosoftConnect host; + private final AsyncResource out; + + AuthorizedCallback(MicrosoftConnect host, AsyncResource out) { + this.host = host; + this.out = out; + } + + public void onSucess(OidcTokens t) { + host.setAccessToken(t.toAccessToken()); + out.complete(t); + } + } + + private static final class ErrorCallback implements SuccessCallback { + private final AsyncResource out; + + ErrorCallback(AsyncResource out) { + this.out = out; + } + + public void onSucess(Throwable err) { + out.error(err); + } + } } From d2fc2214b463ba2ba55a0a5fa67f22d65643ffc3 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 23 May 2026 23:03:47 +0300 Subject: [PATCH 15/20] spotbugs: exclude SIC_INNER_SHOULD_BE_STATIC_ANON for async-callback sites The identity-stack classes (OidcClient, AppleSignIn, *Connect, Auth0Connect) chain AsyncResource.ready(...) / .except(...) via anonymous SuccessCallback instances per the Codename One async idiom (the same pattern used by the existing Login / FaceBookAccess / Display classes). Many of those inner classes don't strictly need the enclosing-this capture, but rewriting each into a named static nested class bloats the call sites and obscures the control flow. Scope the exclusion narrowly to com.codename1.io.oidc.* and the explicit *Connect / AppleSignIn classes, mirroring the existing JavascriptContext precedent in the same file. Matches what the Android port's spotbugs-exclude.xml does for AndroidAsyncView and friends. Co-Authored-By: Claude Opus 4.7 (1M context) --- maven/core-unittests/spotbugs-exclude.xml | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/maven/core-unittests/spotbugs-exclude.xml b/maven/core-unittests/spotbugs-exclude.xml index acfc0cca06..2b22d2ddfe 100644 --- a/maven/core-unittests/spotbugs-exclude.xml +++ b/maven/core-unittests/spotbugs-exclude.xml @@ -138,6 +138,27 @@ + + + + + + + + + + + +