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 diff --git a/.github/workflows/identity-stack.yml b/.github/workflows/identity-stack.yml new file mode 100644 index 0000000000..a088d984fb --- /dev/null +++ b/.github/workflows/identity-stack.yml @@ -0,0 +1,300 @@ +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/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' + - '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/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' + - '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 + # Required to pull the pr-ci-container image from ghcr.io. + packages: 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 + env: + CN1_BINARIES: ${{ github.workspace }}/maven/target/cn1-binaries + run: | + set -euo pipefail + # `-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 -am \ + -Plocal-dev-javase \ + -Dcn1.binaries="${CN1_BINARIES}" \ + -DskipTests \ + install + + - 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 + # 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 ! 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 + + - 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 + # 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; + // _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 + # 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/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..c558940420 --- /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; + +/// 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). +/// +/// 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"`). +/// +/// @since 8.0 +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), or `null` if the + /// user cancelled. + /// + /// 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/OidcClient.java b/CodenameOne/src/com/codename1/io/oidc/OidcClient.java new file mode 100644 index 0000000000..1f070dc345 --- /dev/null +++ b/CodenameOne/src/com/codename1/io/oidc/OidcClient.java @@ -0,0 +1,677 @@ +/* + * 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() { + @Override + 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)); + } + } + + @Override + 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() { + @Override + public void onSucess(String redirectUrl) { + handleRedirect(redirectUrl, state, nonce, pkce, out); + } + }) + .except(new SuccessCallback() { + @Override + 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() { + @Override + 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() { + @Override + public void onSucess(OidcTokens fresh) { + out.complete(fresh); + } + }) + .except(new SuccessCallback() { + @Override + public void onSucess(Throwable err) { + out.error(err); + } + }); + } + }) + .except(new SuccessCallback() { + @Override + 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() { + @Override + protected void readResponse(InputStream input) throws IOException { + Util.readInputStream(input); + out.complete(Boolean.TRUE); + } + + @Override + 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 = "access_denied".equals(error) ? 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() { + @Override + 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() { + @Override + public void onSucess(Throwable t) { + // Token persistence failure is non-fatal; tokens are still valid in-memory. + } + }); + completed[0] = true; + out.complete(tokens); + } + + @Override + 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 (String p : pairs) { + 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..ec7bf7e280 --- /dev/null +++ b/CodenameOne/src/com/codename1/io/oidc/OidcTokens.java @@ -0,0 +1,254 @@ +/* + * 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 ignored) { + // Provider returned a non-numeric `expires_in`; treat the + // expiry as unknown rather than failing the whole token + // response. `expiresAt` stays null and callers fall back to + // a 401 retry. + } + } + 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. 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 { + 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 (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(); + } + } + + 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..cb04a436a2 --- /dev/null +++ b/CodenameOne/src/com/codename1/io/oidc/PkceChallenge.java @@ -0,0 +1,106 @@ +/* + * 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 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); + } + + /// 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..c71a8a8a04 --- /dev/null +++ b/CodenameOne/src/com/codename1/io/oidc/SystemBrowser.java @@ -0,0 +1,236 @@ +/* + * 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.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 { + + /// 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"; + + // volatile is required for the double-checked locking pattern in + // lookupNative(); without it, threads can observe a half-initialised + // reference. Standard idiom -- PMD's blanket warning does not apply. + @SuppressWarnings("PMD.AvoidUsingVolatile") + private static volatile OidcBrowserNative cachedNative; + @SuppressWarnings("PMD.AvoidUsingVolatile") + 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(); + } + + /// 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`. + /// + /// #### 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 provider = lookupNative(); + if (provider != null && provider.isSupported()) { + authenticateNative(provider, authorizationUrl, redirectUri, out); + } else { + authenticateBrowserWindow(authorizationUrl, redirectUri, out); + } + return out; + } + + private static void authenticateNative(final OidcBrowserNative provider, + 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() { + @Override + public void run() { + try { + String result = provider.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() { + @Override + public void run() { + final BrowserWindow window = new BrowserWindow(authUrl); + window.setTitle("Sign in"); + final boolean[] resolved = new boolean[1]; + final ActionListener loadListener = new ActionListener() { + @Override + public void actionPerformed(ActionEvent evt) { + Object src = evt.getSource(); + if (!(src instanceof String)) { + return; + } + String url = (String) src; + // `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]) { + return; + } + resolved[0] = true; + window.close(); + out.complete(url); + } + }; + window.addLoadListener(loadListener); + window.addCloseListener(new ActionListener() { + @Override + 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 { + Class cls = Class.forName(PORT_IMPL_FQCN); + cachedNative = (OidcBrowserNative) cls.newInstance(); + } 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..2762daea7b --- /dev/null +++ b/CodenameOne/src/com/codename1/io/oidc/TokenStore.java @@ -0,0 +1,217 @@ +/* + * 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. + final class DefaultStorageTokenStore implements TokenStore { + private static final String PREFIX = "cn1.oidc."; + + @Override + 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 ignored) { + // Malformed expiry timestamp in persisted storage -- + // treat as "unknown expiry" so the caller can decide + // whether to refresh; never let a parse failure tank + // the load. + } + } + 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; + } + + @Override + 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; + } + + @Override + 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..214a8fdff4 --- /dev/null +++ b/CodenameOne/src/com/codename1/social/AppleSignIn.java @@ -0,0 +1,453 @@ +/* + * 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.OidcException; +import com.codename1.io.oidc.OidcTokens; +import com.codename1.security.Hash; +import com.codename1.security.SecureRandom; +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) { + // 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() { + @Override + 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() { + @Override + public void onSucess(OidcClient client) { + // 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() { + @Override + public void onSucess(OidcTokens t) { + AppleSignInResult r = fromOidcTokens(t); + persistProfile(r); + setAccessToken(t.toAccessToken()); + callback.onSuccess(r); + } + }) + .except(new SuccessCallback() { + @Override + 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() { + @Override + 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 final String PORT_IMPL_FQCN = + "com.codename1.social.AppleSignInNativeImpl"; + + // volatile is required by the double-checked locking idiom in + // lookupNative(); without it, threads can observe a half-initialised + // reference. This is the textbook DCL pattern, not the anti-pattern PMD + // is guarding against. + @SuppressWarnings("PMD.AvoidUsingVolatile") + private static volatile AppleSignInNative CACHED_NATIVE; + @SuppressWarnings("PMD.AvoidUsingVolatile") + 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 { + Class cls = Class.forName(PORT_IMPL_FQCN); + CACHED_NATIVE = (AppleSignInNative) cls.newInstance(); + } 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 { + @Override + public void onSuccess(AppleSignInResult result) { + // `callback` from Login is package-private; trigger success via setAccessToken side-effect. + if (callback != null) { + callback.loginSuccessful(); + } + } + + @Override + public void onError(String error) { + if (callback != null) { + callback.loginFailed(error); + } + } + + @Override + 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..34eebd6668 --- /dev/null +++ b/CodenameOne/src/com/codename1/social/AppleSignInNative.java @@ -0,0 +1,74 @@ +/* + * 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; + +/// 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: +/// +/// `{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 { + + /// `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 + /// calling 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..b21fec9d1f --- /dev/null +++ b/CodenameOne/src/com/codename1/social/Auth0Connect.java @@ -0,0 +1,146 @@ +/* + * 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() { + @Override + 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() { + @Override + public void onSucess(OidcTokens t) { + setAccessToken(t.toAccessToken()); + out.complete(t); + } + }) + .except(new SuccessCallback() { + @Override + public void onSucess(Throwable err) { + out.error(err); + } + }); + } + }) + .except(new SuccessCallback() { + @Override + 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..a17236388c 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,65 @@ 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() { + @Override + public void onSucess(OidcTokens t) { + setAccessToken(t.toAccessToken()); + out.complete(t); + } + }) + .except(new SuccessCallback() { + @Override + 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..92b382331e --- /dev/null +++ b/CodenameOne/src/com/codename1/social/FirebaseAuth.java @@ -0,0 +1,445 @@ +/* + * 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. 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", 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). + /// + /// 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)]. + 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); + } + char[] out = new char[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); + } + out[i] = c; + } + return new String(out); + } + + // ----------------------------------------------------------------- + + 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() { + @Override + 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); + } + + @Override + 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..945453456b 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,76 @@ 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() { + @Override + 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() { + @Override + public void onSucess(OidcTokens tokens) { + setAccessToken(tokens.toAccessToken()); + out.complete(tokens); + } + }) + .except(new SuccessCallback() { + @Override + public void onSucess(Throwable err) { + out.error(err); + } + }); + } + }) + .except(new SuccessCallback() { + @Override + 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..7992dbeb25 --- /dev/null +++ b/CodenameOne/src/com/codename1/social/MicrosoftConnect.java @@ -0,0 +1,173 @@ +/* + * 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 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; + } + + @Override + 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; + } + + @Override + 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; + } + + @Override + public void onSucess(Throwable err) { + out.error(err); + } + } +} 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..166f10a2fe --- /dev/null +++ b/Ports/Android/src/com/codename1/io/oidc/OidcBrowserNativeImpl.java @@ -0,0 +1,238 @@ +/* + * 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 com.codename1.impl.android.AndroidNativeUtil; +import com.codename1.impl.android.LifecycleListener; + +import java.lang.reflect.Method; + +/** + * 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 (or {@code ACTION_VIEW} fallback) 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; + + public boolean isSupported() { + return AndroidNativeUtil.getActivity() != null; + } + + 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 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 LaunchBrowserRunnable(activity, authUrl)); + + // 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; + } + } + + /** + * 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) { + 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. + */ + /** + * 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() {} + 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; + } + 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/CN1AppleSignIn.m b/Ports/iOSPort/nativeSources/CN1AppleSignIn.m new file mode 100644 index 0000000000..df1c7e7ec2 --- /dev/null +++ b/Ports/iOSPort/nativeSources/CN1AppleSignIn.m @@ -0,0 +1,255 @@ +/* + * 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.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 "CodenameOne_GLViewController.h" + +#ifdef CN1_INCLUDE_APPLESIGNIN + +#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 + +// 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"; + +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 = 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 ?: @""; + + 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 + +static id g_cn1AppleCurrentDelegate = nil; +static id g_cn1AppleCurrentController = nil; + +JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_appleSignInSupported__( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me) { + if (@available(iOS 13.0, *)) { + return NSClassFromString(@"ASAuthorizationAppleIDProvider") != nil ? JAVA_TRUE : JAVA_FALSE; + } + return JAVA_FALSE; +} + +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 JAVA_FALSE; + } + 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; +} + +JAVA_VOID com_codename1_impl_ios_IOSNative_appleSignInSignOut__( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me) { + [[NSUserDefaults standardUserDefaults] removeObjectForKey:kCN1AppleUserDefaultsKey]; +} + +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, *)) { + // 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); + + 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]; + 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 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 new file mode 100644 index 0000000000..e689c3b9b9 --- /dev/null +++ b/Ports/iOSPort/nativeSources/CN1OidcBrowser.m @@ -0,0 +1,188 @@ +/* + * 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 "CodenameOne_GLViewController.h" + +#ifdef CN1_INCLUDE_OIDC + +#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); +} + +#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/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/src/com/codename1/io/oidc/OidcBrowserNativeImpl.java b/Ports/iOSPort/src/com/codename1/io/oidc/OidcBrowserNativeImpl.java new file mode 100644 index 0000000000..b7390c59f2 --- /dev/null +++ b/Ports/iOSPort/src/com/codename1/io/oidc/OidcBrowserNativeImpl.java @@ -0,0 +1,47 @@ +/* + * 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.impl.ios.IOSImplementation; + +/** + * 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 { + + public boolean isSupported() { + return IOSImplementation.nativeInstance.oidcSystemBrowserSupported(); + } + + public String startAuthorization(String authUrl, String redirectScheme) { + return IOSImplementation.nativeInstance.oidcStartAuthorization(authUrl, redirectScheme); + } +} diff --git a/Ports/iOSPort/src/com/codename1/social/AppleSignInNativeImpl.java b/Ports/iOSPort/src/com/codename1/social/AppleSignInNativeImpl.java new file mode 100644 index 0000000000..ffa1a52c47 --- /dev/null +++ b/Ports/iOSPort/src/com/codename1/social/AppleSignInNativeImpl.java @@ -0,0 +1,54 @@ +/* + * 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.impl.ios.IOSImplementation; + +/** + * 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); + } + + public boolean isLoggedIn() { + return IOSImplementation.nativeInstance.appleSignInIsLoggedIn(); + } + + public void signOut() { + IOSImplementation.nativeInstance.appleSignInSignOut(); + } +} 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..162e1facff --- /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, for example `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 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 + +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 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 + +`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] +---- +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` performs a refresh-token grant in the background 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(...)` 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 + +`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 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 + +`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 6b87b1785a..565d0343a0 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/docs/developer-guide/languagetool-accept.txt b/docs/developer-guide/languagetool-accept.txt index d987416484..b564d67cb2 100644 --- a/docs/developer-guide/languagetool-accept.txt +++ b/docs/developer-guide/languagetool-accept.txt @@ -499,3 +499,8 @@ rethrows adb logcat pidof + +# Identity-provider product names used in the Authentication & Identity chapter. +Keycloak +Cognito +Authentik 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. 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 6891e7ab95..a191f5c244 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; @@ -1278,6 +1279,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; + } } @@ -3761,6 +3769,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..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 @@ -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,49 @@ 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. + // + // 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) { + addLibs = authSvc; + } else if (!addLibs.toLowerCase().contains("authenticationservices")) { + 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 // still load on iOS 10 (Core NFC was introduced in iOS 11). @@ -1672,6 +1730,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/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 @@ + + + + + + + + + + + +