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..c2c9a480fa --- /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 7.0.245 +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..b5f313fbb0 --- /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 7.0.245 +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..dc6d1088ab --- /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 7.0.245 +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..bfde26f9cf --- /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 7.0.245 +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..a84660cff0 --- /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 7.0.245 +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..556c299cd2 --- /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 7.0.245 +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..98c1f6937c --- /dev/null +++ b/CodenameOne/src/com/codename1/io/oidc/SystemBrowser.java @@ -0,0 +1,220 @@ +/* + * 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 7.0.245 +public final class SystemBrowser { + + /// Port-supplied implementation, registered at app startup. Reads / + /// writes are guarded by a synchronized block; no volatile required + /// because every observer goes through [#getProvider()] which performs + /// the synchronization itself. + private static OidcBrowserNative provider; + + 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 = getProvider(); + return n != null && n.isSupported(); + } + + /// Registers the native [OidcBrowserNative] implementation. Called at + /// app startup by the port (`OidcBrowserNativeImpl.init()`); cn1lib + /// authors can also call this to plug in their own implementation -- for + /// example to wrap a 3rd-party SDK that drives the OS sheet differently. + /// Pass `null` to revert to the [BrowserWindow] fallback. + /// + /// Class.forName-based lookup is intentionally avoided because + /// Codename One obfuscates class names; the port instead instantiates + /// the impl itself and passes the instance here. + public static void setProvider(OidcBrowserNative p) { + synchronized (SystemBrowser.class) { + provider = p; + } + } + + private static OidcBrowserNative getProvider() { + synchronized (SystemBrowser.class) { + return provider; + } + } + + /// 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 p = getProvider(); + if (p != null && p.isSupported()) { + authenticateNative(p, 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); + } + } + + /// 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..397d0cb56c --- /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 7.0.245 +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..176300166e --- /dev/null +++ b/CodenameOne/src/com/codename1/social/AppleSignIn.java @@ -0,0 +1,436 @@ +/* + * 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 for new code; the cn1lib continues to work +/// on its own (this class doesn't depend on it and doesn't forward to it +/// -- they're independent implementations). +/// +/// 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 7.0.245 +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 AppleSignInNative provider; + + /// Registers the native [AppleSignInNative] implementation. Called at + /// app startup by the port (`AppleSignInNativeImpl.init()`); cn1lib + /// authors can also call this to plug in their own implementation, e.g. + /// wrapping the `AuthenticationServices` framework differently. Pass + /// `null` to fall back to the [com.codename1.io.oidc.OidcClient] web + /// flow. + /// + /// We use an explicit setter rather than `Class.forName` because + /// Codename One obfuscates class names; the port instantiates the impl + /// itself and passes the instance here. + public static void setProvider(AppleSignInNative p) { + synchronized (AppleSignIn.class) { + provider = p; + } + } + + private static AppleSignInNative lookupNative() { + synchronized (AppleSignIn.class) { + return provider; + } + } + + /// 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..b44b22ac75 --- /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 7.0.245 +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..9185202b46 --- /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 7.0.245 +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..5f9092a4c4 --- /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 7.0.245 +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..60b30848e2 --- /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 7.0.245 +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..96410d9070 --- /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 7.0.245 +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..87e3dc180a --- /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 7.0.245 +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..8566627c3e --- /dev/null +++ b/Ports/Android/src/com/codename1/io/oidc/OidcBrowserNativeImpl.java @@ -0,0 +1,243 @@ +/* + * 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 { + + /** Invoked from the generated Android app stub at startup. */ + public static void init() { + SystemBrowser.setProvider(new OidcBrowserNativeImpl()); + } + + /** 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..24dcbc4f48 --- /dev/null +++ b/Ports/Android/src/com/codename1/social/AppleSignInNativeImpl.java @@ -0,0 +1,67 @@ +/* + * 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 { + + /** Invoked from the generated Android app stub at startup. */ + public static void init() { + AppleSignIn.setProvider(new AppleSignInNativeImpl()); + } + + @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 7408cee325..8776bef39f 100644 --- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java @@ -527,9 +527,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..d618c033d2 --- /dev/null +++ b/Ports/iOSPort/src/com/codename1/io/oidc/OidcBrowserNativeImpl.java @@ -0,0 +1,55 @@ +/* + * 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+). + * + *

{@link #init()} is invoked from the generated iOS app stub by + * {@code IPhoneBuilder} when the classpath scanner sees any reference to + * {@code com.codename1.io.oidc.*}. The Codename One build system obfuscates + * class names so {@code Class.forName} is unreliable; the port hands the + * instance directly to {@link SystemBrowser#setProvider}. + */ +public class OidcBrowserNativeImpl implements OidcBrowserNative { + + /** Invoked from the generated app stub at startup. */ + public static void init() { + SystemBrowser.setProvider(new OidcBrowserNativeImpl()); + } + + 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..0f14a88888 --- /dev/null +++ b/Ports/iOSPort/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; + +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+). + * + *

{@link #init()} is invoked from the generated iOS app stub by + * {@code IPhoneBuilder} when the scanner sees a reference to + * {@code com.codename1.social.AppleSignIn}. The build system obfuscates + * class names so {@code Class.forName} is unreliable; the port hands the + * instance directly to {@link AppleSignIn#setProvider}. + */ +public class AppleSignInNativeImpl implements AppleSignInNative { + + /** Invoked from the generated app stub at startup. */ + public static void init() { + AppleSignIn.setProvider(new AppleSignInNativeImpl()); + } + + 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..8af28a9965 --- /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: + +* `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**. 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..b463d23e25 100644 --- a/docs/developer-guide/Miscellaneous-Features.asciidoc +++ b/docs/developer-guide/Miscellaneous-Features.asciidoc @@ -1044,408 +1044,9 @@ AnalyticsService.init(new MyAnalyticsServiceSubclass()); Notice that this removes the need to invoke the other `init` method or `setAppsMode(boolean)`. -=== Native Facebook support - -// HTML_ONLY_START -TIP: Check out the https://www.codenameone.com/manual/components.html#sharebutton-section[ShareButton section] it might be enough for most of your needs. -// HTML_ONLY_END -//// -//PDF_ONLY -TIP: Check out the <> it might be enough for most of your needs. -//// - -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. - -==== Getting started - web setup - -To get started first you will need to create a facebook app on the Facebook developer portal -at https://developers.facebook.com/apps/ - -.Create New App -image::img/chat-app-tutorial-facebook-login-1.png[Create New App,scaledwidth=50%] - -You need to repeat the process for web, Android & iOS (web is used by the simulator): - -.Pick Platform -image::img/chat-app-tutorial-facebook-login-2.png[Pick Platform,scaledwidth=50%] - -For the first platform you need to enter the app name: - -.Pick app name -image::img/chat-app-tutorial-facebook-login-3.png[Pick Name,scaledwidth=50%] - -And provide some basic details: - -.Basic details for the app -image::img/chat-app-tutorial-facebook-login-4.png[Details,scaledwidth=35%] - -For iOS you need the bundle ID which is the exact same thing you used in the Google+ login to identify the iOS app -its effectively your package name: - -.iOS specific basic details -image::img/chat-app-tutorial-facebook-login-5.png[Details,scaledwidth=50%] - -You should end up with something that looks like this: - -.Finished Facebook app -image::img/chat-app-tutorial-facebook-login-6.png[Details,scaledwidth=50%] - -The Android process is pretty similar but in this case you need the activity name too. - -IMPORTANT: The activity name should match the main class name followed by the word `Stub` (uppercase s). For example: for the main class `SociallChat` you would use `SocialChatStub` as the activity name - -.Android Activity definition -image::img/chat-app-tutorial-facebook-login-7.png[Details,scaledwidth=50%] - -To build the native Android app you must make sure that you set up the keystore for your application. If you don't have -an Android certificate you can use the visual wizard (in the Android section in the project preferences the button labeled #Generate#) or use the command line: - -[source,bash] ----- -keytool -genkey -keystore Keystore.ks -alias [alias_name] -keyalg RSA -keysize 2048 -validity 15000 -dname "CN=[full name], OU=[ou], O=[comp], L=[City], S=[State], C=[Country Code]" -storepass [password] -keypass [password] ----- - -IMPORTANT: You can reuse the certificate in all your apps, some developers like having a different certificate for every app. This is like having one master key for all your doors, or a huge keyring filled with keys. - -With the certificate you need an SHA1 key to further authenticate you to Facebook and you do this using the keytool command line on Linux/Mac: - -[source,bash] ----- -keytool -exportcert -alias (your_keystore_alias) -keystore (path_to_your_keystore) | openssl sha1 -binary | openssl base64 ----- - -And on Windows: - ----- -keytool -exportcert -alias androiddebugkey -keystore %HOMEPATH%\.android\debug.keystore | openssl sha1 -binary | openssl base64 ----- - -You can read more about it on the https://developers.facebook.com/docs/android/getting-started[Facebook guide here]. - -.Hash generation process, notice the command lines are listed as part of the web wizard -image::img/chat-app-tutorial-facebook-login-8.png[Hash,scaledwidth=50%] - -Lastly you need to publish the Facebook app by flipping the switch in the apps "Status & Review" page as such: - -.Without flipping the switch the app won't "appear" -image::img/chat-app-tutorial-facebook-login-9.png[Enable The App,scaledwidth=50%] - -==== IDE setup - -You now need to set some important build hints in the project so it will work. To set the build hints right-click the project select project properties and in the Codename One section pick the second tab. Add this entry into the table: - -[source,bash] ----- -facebook.appId=... ----- - -The app ID will be visible in your Facebook app page in the top left position. - -==== The Code - -To bind your mobile app into the Facebook app you can use the following code: - -[source,java] ----- -Login fb = FacebookConnect.getInstance(); - -fb.setClientId("9999999"); -fb.setRedirectURI("http://www.youruri.com/"); -fb.setClientSecret("-------"); - -// Sets a LoginCallback listener -fb.setCallback(new LoginCallback() { - public void loginSuccessful() { - // we can now start fetching stuff from Facebook! - } - - public void loginFailed(String errorMessage) {} -}); - -// trigger the login if not already logged in -if(!fb.isUserLoggedIn()){ - fb.doLogin(); -} else { - // get the token and now you can query the Facebook API - String token = fb.getAccessToken().getToken(); - // ... -} ----- - -IMPORTANT: All these values are from the web version of the app! + -They're used in the simulator and on "unsupported" -platforms as a fallback. Android and iOS will use the -native login - -==== Facebook publish permissions - -To post something to Facebook you need to request a write permission, you can do write operations -within the callback which is invoked when the user approves the permission. - -You can prompt the user for publish permissions by using this code on a logged in https://www.codenameone.com/javadoc/com/codename1/social/FacebookConnect.html[FacebookConnect]: - -[source,java] ----- -FacebookConnect.getInstance()askPublishPermissions(new LoginCallback() { - public void loginSuccessful() { - // do something... - } - public void loginFailed(String errorMessage) { - // show error or just ignore - } -}); ----- - -TIP: Notice that this won't always prompt the user, but its required to verify that your token is valid for writing. - -[[google-login-section]] -=== Google Sign-In - -Google Login is a bit of a moving target, as they're creating new APIs and deprecating old ones. Codename One 3.7 and earlier used the Google+ API for sign-in, which is now deprecated. While this API still works, it's no longer useful on iOS as it redirects to Safari to perform login, and Apple no longer allows this practice. - -The new, approved API is called Google Sign-In. Rather than using Safari to handle login (on iOS), it uses an embedded web view, which *is* permitted by Apple. - -The process involves four parts: - -. <> -. <> -. <> -. <> - -*OAuth Setup* is required for using Google Sign-In in the simulator, and for accessing other Google APIs in Android. - - -[[ios-setup]] -==== iOS setup instructions - -**Short Version** - -Go to https://developers.google.com/mobile/add[the Google Developer Portal], follow the steps to create an App, and enable Google Sign-In, and download the GoogleService-Info.plist file. Then copy this file into your project's native/ios directory. - -**Long Version** - -Point your browser to https://developers.google.com/mobile/add[this page]. - -.Set up mobile app form on Google -image::img/google-signin-ios-setup.png[Google Setup Mobile App Form,scaledwidth=50%] - -Click on the "Getting Started" button. - -.Getting started button -image::img/google-signin-ios-getting-started-button.png[Getting started button,scaledwidth=15%] - -Then click "iOS App" - -.Pick a platform -image::img/google-signin-ios-pick-a-platform.png[Pick a platform,scaledwidth=50%] - -Now enter an app name and the bundle ID for your app on the form below. The app name doesn't necessary need to match your app's name, but the bundle ID should match the package name of your app. - -.Create or Choose App -image::img/google-signin-ios-create-or-choose-app.png[Create or Choose App,scaledwidth=50%] - -Select your country, and then click the "Choose and Configure Services" button. - -.Choose and Configure Services -image::img/google-signin-ios-choose-and-configure-services-btn.png[Choose and Configure Services,scaledwidth=20%] - -You'll be presented with the following screen - -.Choose and Configure Services form -image::img/google-signin-ios-choose-and-configure-services-form.png[Choose and Configure Services form,scaledwidth=50%] - -Click on `Google Sign-In`. - -Then press the "Enable Google Sign-In" button that appears. - -.Enable Google Sign-In -image::img/google-signin-ios-enable-google-signin-btn.png[Enable Google Sign-In,scaledwidth=50%] - -You should then be presented with another button to "Generate Configuration Files" as shown below - -.Generate Configuration Files -image::img/google-signin-ios-generate-configuration-files-button.png[Generate Configuration Files,scaledwidth=20%] - -You will be presented with a button to `Download GoogleServices-Info.plist`. - -.Download GoogleService-Info.plist file -image::img/google-signin-ios-download-googleservice-infoplist-btn.png[Download GoogleService-Info plist file,scaledwidth=20%] - -Press this button to download the GoogleService-Info.plist file. Then copy this into the "native/ios" directory of your Codename One project. - -.Project file structure after placing the GoogleService-Info.plist into the native/ios directory -image::img/google-signin-ios-google-service-info-plist-file-structure.png[Project structure,scaledwidth=15%] - -At this point, your app should be able to use Google Sign-In. Notice that you don't require any build hints. Only that the GoogleService-Info.plist file is added to the project's native/ios directory. - -[[android-setup]] -==== Android setup instructions - -**Short Version** - -Go to https://developers.google.com/mobile/add[the Google Developer Portal], follow the steps to create an App, and enable Google Sign-In, and download the google-services.json file. Then copy this file into your project's native/android directory. - -**Long Version** - -Point your browser to https://developers.google.com/mobile/add[this page]. - -.Set up mobile app form on Google -image::img/google-signin-ios-setup.png[Google Setup Mobile App Form,scaledwidth=30%] - -Click on the "Getting Started" button. - -image::img/google-signin-ios-getting-started-button.png[Getting started button,scaledwidth=15%] - -Then click "Android App" - -image::img/google-signin-ios-pick-a-platform.png[Pick a platform,scaledwidth=30%] - -Now enter an app name and the platform for your app on the form below. The app name doesn't necessary need to match your app's name, but the package name should match the package name of your app. - -.Create or Choose App -image::img/google-signin-android-create-or-choose-app.png[Create or Choose App,scaledwidth=40%] - -Select your country, and then click the "Choose and Configure Services" button. - -.Choose and Configure Services -image::img/google-signin-android-choose-and-configure-services-btn.png[Choose and Configure Services,scaledwidth=40%] - -Click on "Google Sign-In" - -Then you'll be presented with a field to enter the Android Signing Certificate SHA-1. - -.Android Signing Certificate SHA-1 -image::img/google-signin-android-signing-sha1.png[Android Signing Certificate SHA-1,scaledwidth=40%] - -The value that you enter here should be obtained from the certificate that you're using to build your app. You can use the *keytool* app that's distributed with the JDK to extract this value - -[source,bash] ----- -$ keytool -exportcert -alias myAlias -keystore /path/to/my-keystore.keystore -list -v ----- - -The snippet above assumes that your keystore is located at `/path/to/my-keystore.keystore`, and the certificate alias is "myAlias." You'll be prompted to enter the password for your keystore, then the output will look something like: - ----- -Alias name: myAlias -Creation date: 22-Jan-2014 -Entry type: PrivateKeyEntry -Certificate chain length: 1 -Certificate[1]: -Owner: CN=My Own Company Corp., OU=, O=, L=Vancouver, ST=British Columbia, C=CA -Issuer: CN=My Own Company Corp., OU=, O=, L=Vancouver, ST=British Columbia, C=CA -Serial number: 56b2fd42 -Valid from: Wed Jan 22 12:23:50 PST 2014 until: Tue Feb 16 12:23:50 PST 2055 -Certificate fingerprints: - MD5: 98:F9:34:5B:B5:1A:14:2D:3C:5D:F4:92:D2:73:30:6B - SHA1: 76:BA:AA:11:A9:22:42:24:93:82:6D:33:7E:48:BC:AF:45:4D:79:B0 - SHA256: 3D:04:33:67:6A:13:FF:4F:EE:E8:C9:7D:D2:CC:DF:70:33:E1:90:44:BF:22:B6:96:11:C7:00:67:8D:CD:53:BC - Signature algorithm name: SHA256withRSA - Version: 3 - -Extensions: - -#1: ObjectId: 2.5.29.14 Criticality=false -SubjectKeyIdentifier [ -KeyIdentifier [ -0000: C2 A0 48 AA 60 BA DD E3 0C 3F 00 B4 2C D5 92 A5 ..H.`.......D... -0010: 31 16 EF A2 1... -] -] ----- - -You will be interested in SHA1 fingerprint. In the snippet above, the SHA1 fingerprint is: - ----- -76:BA:AA:11:A9:22:42:24:93:82:6D:33:7E:48:BC:AF:45:4D:79:B0 ----- - -You would paste this value into the "Android Signing Certificate SHA-1" field in the web form. - -After pasting that in, you'll see a new button with label "Enable Google Sign-in" - -.Enable Google Sign-In -image::img/google-signin-ios-enable-google-signin-btn.png[Enable Google Sign-In,scaledwidth=40%] - -Press this button and you'll be presented with another button to "Generate Configuration Files" as shown below - -.Generate Configuration Files -image::img/google-signin-ios-generate-configuration-files-button.png[Generate Configuration Files,scaledwidth=20%] - -You will be presented with a button to `Download google-services.json`. - -.Download google-services.json file -image::img/google-signin-android-download-googleservices-json-btn.png[Download google-services json file,scaledwidth=20%] - -Press this button to download the google-services.json file. Then copy this into the "native/android" directory of your Codename One project. - -.Project file structure after placing the GoogleService-Info.plist into the native/android directory -image::img/google-signin-android-google-services-json-file-structure.png[Project structure,scaledwidth=15%] - -At this point, your app should be able to use Google Sign-In. Notice that you don't require any build hints. Only that the google-services.json file is added to the project's native/android directory. - -IMPORTANT: If you want to access more information about the logged in user using Google's REST APIs, you will require an OAuth2.0 client ID of type Web Application for this project as well. See <> for details. - -[[oauth-setup]] -==== OAuth setup (simulator and REST API access) - -Getting Google Sign-In to work in the Codename One simulator requires an extra step after you've set up iOS and/or Android apps. The Simulator can't use the native Google Sign-In APIs, so it uses the standard Web Application OAuth2.0 API. Also, the Android App requires a Web Application OAuth2.0 client ID to access more Google REST APIs. - -If you've set up the Google Sign-In API for either Android or iOS, then Google will have already automatically generated a Web Application OAuth2.0 client ID for you. You need to provide the ClientID and ClientSecret to the `GoogleConnect` instance (in your Java code). - -===== Client ID, client secret and redirect URL - -. Log into https://console.cloud.google.com/apis[the Google Cloud Platform API console]. -. Select your app from the drop-down-menu in the top bar -. Click on "Credentials" in the left menu. You'll see a screen like this -+ -image::img/google-sign-in-google-cloud-platform-credentials.png[Credentials,scaledwidth=20%] -. Under the "OAuth2.0 Client IDs", find the row with "Web application" listed in the type column -. Click the "Edit icon for that row. -. Make note of the "Client ID" and "Client Secret" on this page, as you'll need to add them to your Java source in the next step. -. In the "Authorized redirect URIs" section, you will need to enter the URL to the page that the user will be sent to after a successful login. This page will appear in the simulator for a split second, as Codename One's BrowserComponent will intercept this request to get the access token upon successful login. You can use any URL you like here, but it must match the value you give to `GoogleConnect.setRedirectURL()` in <>. -+ -image::img/google-sign-in-oauth-setup-redirect-url.png[Redirect URL,scaledwidth=30%] - -[[javascript-setup]] -==== JavaScript setup instructions - -The JavaScript port can use the same OAuth2.0 credentials as the simulator does. It doesn't require your Client Secret or redirect URL. It requires your Client ID, which you can specify using the `GoogleConnect.setClientID()` method. - -[[the-code]] -==== The Code - -[source,java] ----- -Login gc = GoogleConnect.getInstance(); -gc.setClientId("*****************.apps.googleusercontent.com"); -gc.setRedirectURI("https://yourURL.com/"); -gc.setClientSecret("-------------------"); - -// Sets a LoginCallback listener -gc.setCallback(new LoginCallback() { - public void loginSuccessful() { - // we can now start fetching stuff from Google+! - } - - public void loginFailed(String errorMessage) {} -}); - -// trigger the login if not already logged in -if(!gc.isUserLoggedIn()){ - gc.doLogin(); -} else { - // get the token and now you can query the Google API - String token = gc.getAccessToken().getToken(); - // NOTE: On Android, this token will be null unless you provide valid - // client ID and secrets. -} ----- - -NOTE: The client ID and client secret values here are the ones from your <>. - -IMPORTANT: The *Client ID* and *Client Secret* values are used on both the Simulator and on Android. On simulator these values are required for login to work at all. On Android these values are required to get an access token to query the Google API further using its various REST APIs. If you don't include these values on Android, login will still work, but `gc.getAccessToken().getToken()` will return `null`. +=== Social sign-in (Facebook, Google, ...) +The legacy Facebook and Google OAuth2 sign-in flows that used to live here drove an embedded `WebBrowser`, which the providers no longer permit. The full sign-in stack -- system-browser-based OpenID Connect for any provider, plus Facebook / Google / Microsoft / Apple / Auth0 / Firebase helpers -- now lives in the <> chapter. [[lead-component-section]] === Lead Component diff --git a/docs/developer-guide/developer-guide.asciidoc b/docs/developer-guide/developer-guide.asciidoc index 17b2136a4d..2a0c7bcbee 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::Network-Connectivity.asciidoc[] diff --git a/docs/developer-guide/img/chat-app-tutorial-facebook-login-1.png b/docs/developer-guide/img/chat-app-tutorial-facebook-login-1.png deleted file mode 100644 index aa141d9bcb..0000000000 Binary files a/docs/developer-guide/img/chat-app-tutorial-facebook-login-1.png and /dev/null differ diff --git a/docs/developer-guide/img/chat-app-tutorial-facebook-login-2.png b/docs/developer-guide/img/chat-app-tutorial-facebook-login-2.png deleted file mode 100644 index 6cbc3311c5..0000000000 Binary files a/docs/developer-guide/img/chat-app-tutorial-facebook-login-2.png and /dev/null differ diff --git a/docs/developer-guide/img/chat-app-tutorial-facebook-login-3.png b/docs/developer-guide/img/chat-app-tutorial-facebook-login-3.png deleted file mode 100644 index 9470c38331..0000000000 Binary files a/docs/developer-guide/img/chat-app-tutorial-facebook-login-3.png and /dev/null differ diff --git a/docs/developer-guide/img/chat-app-tutorial-facebook-login-4.png b/docs/developer-guide/img/chat-app-tutorial-facebook-login-4.png deleted file mode 100644 index d5ff2d266b..0000000000 Binary files a/docs/developer-guide/img/chat-app-tutorial-facebook-login-4.png and /dev/null differ diff --git a/docs/developer-guide/img/chat-app-tutorial-facebook-login-5.png b/docs/developer-guide/img/chat-app-tutorial-facebook-login-5.png deleted file mode 100644 index cdcc53a40c..0000000000 Binary files a/docs/developer-guide/img/chat-app-tutorial-facebook-login-5.png and /dev/null differ diff --git a/docs/developer-guide/img/chat-app-tutorial-facebook-login-6.png b/docs/developer-guide/img/chat-app-tutorial-facebook-login-6.png deleted file mode 100644 index cdc414cae3..0000000000 Binary files a/docs/developer-guide/img/chat-app-tutorial-facebook-login-6.png and /dev/null differ diff --git a/docs/developer-guide/img/chat-app-tutorial-facebook-login-7.png b/docs/developer-guide/img/chat-app-tutorial-facebook-login-7.png deleted file mode 100644 index b281005a89..0000000000 Binary files a/docs/developer-guide/img/chat-app-tutorial-facebook-login-7.png and /dev/null differ diff --git a/docs/developer-guide/img/chat-app-tutorial-facebook-login-8.png b/docs/developer-guide/img/chat-app-tutorial-facebook-login-8.png deleted file mode 100644 index 6627bdbe74..0000000000 Binary files a/docs/developer-guide/img/chat-app-tutorial-facebook-login-8.png and /dev/null differ diff --git a/docs/developer-guide/img/chat-app-tutorial-facebook-login-9.png b/docs/developer-guide/img/chat-app-tutorial-facebook-login-9.png deleted file mode 100644 index 5ec36e18bf..0000000000 Binary files a/docs/developer-guide/img/chat-app-tutorial-facebook-login-9.png and /dev/null differ diff --git a/docs/developer-guide/img/google-sign-in-google-cloud-platform-credentials.png b/docs/developer-guide/img/google-sign-in-google-cloud-platform-credentials.png deleted file mode 100644 index beffc2a31c..0000000000 Binary files a/docs/developer-guide/img/google-sign-in-google-cloud-platform-credentials.png and /dev/null differ diff --git a/docs/developer-guide/img/google-sign-in-oauth-setup-redirect-url.png b/docs/developer-guide/img/google-sign-in-oauth-setup-redirect-url.png deleted file mode 100644 index d603b7a9ca..0000000000 Binary files a/docs/developer-guide/img/google-sign-in-oauth-setup-redirect-url.png and /dev/null differ diff --git a/docs/developer-guide/img/google-signin-android-choose-and-configure-services-btn.png b/docs/developer-guide/img/google-signin-android-choose-and-configure-services-btn.png deleted file mode 100644 index b430172e94..0000000000 Binary files a/docs/developer-guide/img/google-signin-android-choose-and-configure-services-btn.png and /dev/null differ diff --git a/docs/developer-guide/img/google-signin-android-create-or-choose-app.png b/docs/developer-guide/img/google-signin-android-create-or-choose-app.png deleted file mode 100644 index 6d81f8b9d8..0000000000 Binary files a/docs/developer-guide/img/google-signin-android-create-or-choose-app.png and /dev/null differ diff --git a/docs/developer-guide/img/google-signin-android-download-googleservices-json-btn.png b/docs/developer-guide/img/google-signin-android-download-googleservices-json-btn.png deleted file mode 100644 index 335f9b8aee..0000000000 Binary files a/docs/developer-guide/img/google-signin-android-download-googleservices-json-btn.png and /dev/null differ diff --git a/docs/developer-guide/img/google-signin-android-google-services-json-file-structure.png b/docs/developer-guide/img/google-signin-android-google-services-json-file-structure.png deleted file mode 100644 index b209f3e1ce..0000000000 Binary files a/docs/developer-guide/img/google-signin-android-google-services-json-file-structure.png and /dev/null differ diff --git a/docs/developer-guide/img/google-signin-android-signing-sha1.png b/docs/developer-guide/img/google-signin-android-signing-sha1.png deleted file mode 100644 index dc3b08e171..0000000000 Binary files a/docs/developer-guide/img/google-signin-android-signing-sha1.png and /dev/null differ diff --git a/docs/developer-guide/img/google-signin-ios-choose-and-configure-services-btn.png b/docs/developer-guide/img/google-signin-ios-choose-and-configure-services-btn.png deleted file mode 100644 index 16e042bcbd..0000000000 Binary files a/docs/developer-guide/img/google-signin-ios-choose-and-configure-services-btn.png and /dev/null differ diff --git a/docs/developer-guide/img/google-signin-ios-choose-and-configure-services-form.png b/docs/developer-guide/img/google-signin-ios-choose-and-configure-services-form.png deleted file mode 100644 index 26cdfdf8e6..0000000000 Binary files a/docs/developer-guide/img/google-signin-ios-choose-and-configure-services-form.png and /dev/null differ diff --git a/docs/developer-guide/img/google-signin-ios-create-or-choose-app.png b/docs/developer-guide/img/google-signin-ios-create-or-choose-app.png deleted file mode 100644 index 3bb2e05a83..0000000000 Binary files a/docs/developer-guide/img/google-signin-ios-create-or-choose-app.png and /dev/null differ diff --git a/docs/developer-guide/img/google-signin-ios-download-googleservice-infoplist-btn.png b/docs/developer-guide/img/google-signin-ios-download-googleservice-infoplist-btn.png deleted file mode 100644 index 37c182ab91..0000000000 Binary files a/docs/developer-guide/img/google-signin-ios-download-googleservice-infoplist-btn.png and /dev/null differ diff --git a/docs/developer-guide/img/google-signin-ios-enable-google-signin-btn.png b/docs/developer-guide/img/google-signin-ios-enable-google-signin-btn.png deleted file mode 100644 index 5202a89753..0000000000 Binary files a/docs/developer-guide/img/google-signin-ios-enable-google-signin-btn.png and /dev/null differ diff --git a/docs/developer-guide/img/google-signin-ios-generate-configuration-files-button.png b/docs/developer-guide/img/google-signin-ios-generate-configuration-files-button.png deleted file mode 100644 index d3189196e6..0000000000 Binary files a/docs/developer-guide/img/google-signin-ios-generate-configuration-files-button.png and /dev/null differ diff --git a/docs/developer-guide/img/google-signin-ios-getting-started-button.png b/docs/developer-guide/img/google-signin-ios-getting-started-button.png deleted file mode 100644 index 33b912057f..0000000000 Binary files a/docs/developer-guide/img/google-signin-ios-getting-started-button.png and /dev/null differ diff --git a/docs/developer-guide/img/google-signin-ios-google-service-info-plist-file-structure.png b/docs/developer-guide/img/google-signin-ios-google-service-info-plist-file-structure.png deleted file mode 100644 index 0134cf09b6..0000000000 Binary files a/docs/developer-guide/img/google-signin-ios-google-service-info-plist-file-structure.png and /dev/null differ diff --git a/docs/developer-guide/img/google-signin-ios-pick-a-platform.png b/docs/developer-guide/img/google-signin-ios-pick-a-platform.png deleted file mode 100644 index 33bbe4e762..0000000000 Binary files a/docs/developer-guide/img/google-signin-ios-pick-a-platform.png and /dev/null differ diff --git a/docs/developer-guide/img/google-signin-ios-setup.png b/docs/developer-guide/img/google-signin-ios-setup.png deleted file mode 100644 index 21390430ce..0000000000 Binary files a/docs/developer-guide/img/google-signin-ios-setup.png and /dev/null differ 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..20c8aedf20 100644 --- a/docs/developer-guide/security.asciidoc +++ b/docs/developer-guide/security.asciidoc @@ -180,7 +180,7 @@ Notice that this isn't right, you can't be 100% sure as there are no official wa === Strong Android certificates -When Android launched RSA1024 with SHA1 was considered strong enough for the foreseeable future, this hasn't changed completely but the recommendation today is to use stronger cyphers for signing & encrypting as those can be compromised. +When Android launched RSA1024 with SHA1 was considered strong enough for the foreseeable future, this hasn't changed completely but the recommendation today is to use stronger ciphers for signing & encrypting as those can be compromised. APKs are signed as part of the build process when you upload an app to the Google Play Store. This process seems redundant as you generate the signature/certificate ourselves (unlike Apple which generates it for you). But, this is a crucial step as it allows the device to verify upgrades and make sure a new update is from the same original author! @@ -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 2da7599d96..e7f2a58f96 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,8 @@ public File getGradleProjectDirectory() { private boolean usesBiometrics; private boolean usesNfc; private boolean usesNfcHce; + private boolean usesOidc; + private boolean usesAppleSignIn; private boolean vibratePermission; private boolean smsPermission; private boolean gpsPermission; @@ -1292,6 +1294,16 @@ public void usesClass(String cls) { } } + // 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; + } + if (!usesAppleSignIn + && cls.indexOf("com/codename1/social/AppleSignIn") == 0) { + usesAppleSignIn = true; + } // Deeper-network connectivity: each subpackage maps to a // distinct permission set. The scanner sets booleans; the // injection block below builds the manifest fragments only @@ -3864,6 +3876,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"; @@ -4412,6 +4437,20 @@ private String createOnCreateCode(BuildRequest request) { } + // OidcClient / SystemBrowser bootstrap on Android: register the + // Custom-Tabs-backed provider so the core SystemBrowser can route + // through it without falling back to BrowserWindow. + if (usesOidc) { + retVal += "com.codename1.io.oidc.OidcBrowserNativeImpl.init();\n"; + } + // AppleSignIn bootstrap. Android's impl is a no-op stub that reports + // isSupported() = false, which makes AppleSignIn fall through to its + // OidcClient-backed web flow. We still register so the lookup is + // deterministic rather than relying on Class.forName. + if (usesAppleSignIn) { + retVal += "com.codename1.social.AppleSignInNativeImpl.init();\n"; + } + if (request.getArg("android.web_loading_hidden", "false").equalsIgnoreCase("true")) { retVal += "Display.getInstance().setProperty(\"WebLoadingHidden\", \"true\");\n"; } diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java index 64dbc5ec64..b135813508 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; // Deeper-network connectivity flags. Set by the classpath scanner when @@ -684,6 +686,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; + } if (cls.indexOf("com/codename1/io/wifi/WiFi") == 0 && !cls.equals("com/codename1/io/wifi/WiFiDirect")) { // WiFi info or scan/connect. iOS has no scan API so @@ -890,6 +905,22 @@ public void usesClassMethod(String cls, String method) { } + // OidcClient + SystemBrowser bootstrap: when the scanner saw any + // com.codename1.io.oidc.* reference, the port's + // OidcBrowserNativeImpl.init() must run before the app starts so + // SystemBrowser.getProvider() returns the iOS native bridge. + String integrateOidcBrowser = ""; + if (usesOidc) { + integrateOidcBrowser = + " com.codename1.io.oidc.OidcBrowserNativeImpl.init();\n"; + } + // AppleSignIn bootstrap -- same mechanism, separate gate. + String integrateAppleSignIn = ""; + if (usesAppleSignIn) { + integrateAppleSignIn = + " com.codename1.social.AppleSignInNativeImpl.init();\n"; + } + String integrateGoogleConnect = ""; if (useGoogleSignIn) { try { @@ -1117,6 +1148,8 @@ public void usesClassMethod(String cls, String method) { + adPadding + integrateFacebook + integrateGoogleConnect + + integrateOidcBrowser + + integrateAppleSignIn + " if(!initialized) {\n" + " initialized = true;\n" @@ -1656,6 +1689,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). @@ -1700,6 +1776,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"); + } + } + // Deeper-network connectivity (WiFi info / NEHotspotConfiguration // / Bonjour). Each block is gated on a scanner flag so apps that // never touch the API see no entitlement or plist changes -- this 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 @@ + + + + + + + + + + + +