From c6bd42585f51b754038ed6f9858348ba294f7cfb Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 1 Nov 2026 18:05:31 +0200 Subject: [PATCH 1/4] cn1-app-kit: the client and server plumbing the full-stack samples share A cn1lib with a server half, built in the framework reactor and released with it at the framework's version: Net, Skeleton and Chart on the client; Caller, Text, Grants, AccountSecurity and the push senders on the server. The samples are not moved onto it here; that follows once a release carries it. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/pr.yml | 2 +- .github/workflows/release.yml | 3 +- docs/developer-guide/Backend-Beans.asciidoc | 23 ++ maven/cn1-app-kit/backend/pom.xml | 24 ++ .../com/codename1/appkit/server/Caller.java | 67 ++++ .../com/codename1/appkit/server/Grants.java | 103 +++++ .../com/codename1/appkit/server/Text.java | 87 ++++ .../appkit/server/push/CloudPushSender.java | 84 ++++ .../appkit/server/push/ConsolePushSender.java | 47 +++ .../server/push/MisconfiguredPushSender.java | 51 +++ .../appkit/server/push/PushSender.java | 46 +++ .../server/push/RecordingPushSender.java | 127 ++++++ .../server/security/AccountSecurity.java | 372 ++++++++++++++++++ .../META-INF/codenameone/backend-library | 0 .../codenameone_library_appended.properties | 2 + .../codenameone_library_required.properties | 1 + maven/cn1-app-kit/common/pom.xml | 40 ++ .../main/java/com/codename1/appkit/Chart.java | 202 ++++++++++ .../main/java/com/codename1/appkit/Net.java | 177 +++++++++ .../java/com/codename1/appkit/Skeleton.java | 96 +++++ maven/cn1-app-kit/lib/pom.xml | 24 ++ maven/cn1-app-kit/pom.xml | 65 +++ maven/cn1-app-kit/spotbugs-exclude.xml | 23 ++ maven/pom.xml | 3 + 24 files changed, 1667 insertions(+), 2 deletions(-) create mode 100644 maven/cn1-app-kit/backend/pom.xml create mode 100644 maven/cn1-app-kit/backend/src/main/java/com/codename1/appkit/server/Caller.java create mode 100644 maven/cn1-app-kit/backend/src/main/java/com/codename1/appkit/server/Grants.java create mode 100644 maven/cn1-app-kit/backend/src/main/java/com/codename1/appkit/server/Text.java create mode 100644 maven/cn1-app-kit/backend/src/main/java/com/codename1/appkit/server/push/CloudPushSender.java create mode 100644 maven/cn1-app-kit/backend/src/main/java/com/codename1/appkit/server/push/ConsolePushSender.java create mode 100644 maven/cn1-app-kit/backend/src/main/java/com/codename1/appkit/server/push/MisconfiguredPushSender.java create mode 100644 maven/cn1-app-kit/backend/src/main/java/com/codename1/appkit/server/push/PushSender.java create mode 100644 maven/cn1-app-kit/backend/src/main/java/com/codename1/appkit/server/push/RecordingPushSender.java create mode 100644 maven/cn1-app-kit/backend/src/main/java/com/codename1/appkit/server/security/AccountSecurity.java create mode 100644 maven/cn1-app-kit/backend/src/main/resources/META-INF/codenameone/backend-library create mode 100644 maven/cn1-app-kit/common/codenameone_library_appended.properties create mode 100644 maven/cn1-app-kit/common/codenameone_library_required.properties create mode 100644 maven/cn1-app-kit/common/pom.xml create mode 100644 maven/cn1-app-kit/common/src/main/java/com/codename1/appkit/Chart.java create mode 100644 maven/cn1-app-kit/common/src/main/java/com/codename1/appkit/Net.java create mode 100644 maven/cn1-app-kit/common/src/main/java/com/codename1/appkit/Skeleton.java create mode 100644 maven/cn1-app-kit/lib/pom.xml create mode 100644 maven/cn1-app-kit/pom.xml create mode 100644 maven/cn1-app-kit/spotbugs-exclude.xml diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 0efe70b9db7..4381a6e8c50 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -402,7 +402,7 @@ jobs: -Dcn1.binaries="${CN1_BINARIES}" \ -Pcompile-android \ -DbackendAnalysis \ - -pl android,ios,project-model,build-engine,codenameone-maven-plugin,codenameone-gradle-plugin,backend,backend-test,backend-parparvm,javac,android-res-compiler,android-compat,swing-compat,javafx-compat,compose-compat,fxml-compiler,desktop-compat-runtime -am verify + -pl android,ios,project-model,build-engine,codenameone-maven-plugin,codenameone-gradle-plugin,backend,backend-test,backend-parparvm,javac,android-res-compiler,android-compat,swing-compat,javafx-compat,compose-compat,fxml-compiler,desktop-compat-runtime,cn1-app-kit/backend,cn1-app-kit/common -am verify - name: Type-check the SQLCipher-backed database package if: ${{ matrix.java-version == 8 }} env: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f7ec3a71614..97d67af4250 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -202,7 +202,8 @@ jobs: for artifact in codenameone-core codenameone-maven-plugin codenameone-platform-feature-catalog \ codenameone-build-hint-catalog codenameone-build-hint-tools \ codenameone-cil-translator codenameone-unity-compat \ - cn1app-archetype cn1lib-archetype; do + cn1app-archetype cn1lib-archetype \ + cn1-app-kit-common cn1-app-kit-backend cn1-app-kit-lib; do url="${R2_BASE_URL}/com/codenameone/${artifact}/${GITHUB_REF_NAME}/${artifact}-${GITHUB_REF_NAME}.pom?cb=${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" code=$(curl -s -o /dev/null -w "%{http_code}" "$url") if [ "$code" != "200" ]; then diff --git a/docs/developer-guide/Backend-Beans.asciidoc b/docs/developer-guide/Backend-Beans.asciidoc index bc9e526719c..5139e7efce4 100644 --- a/docs/developer-guide/Backend-Beans.asciidoc +++ b/docs/developer-guide/Backend-Beans.asciidoc @@ -430,6 +430,29 @@ The library itself is never wired at its own build; only the server that uses it generates the routes and beans. When the server defines a class with the same name as one in a library, the server's class is used. +[[app-kit]] +==== The app kit + +The app kit is a cn1lib built this way, published with Codename One under the +same version. It holds the plumbing a client and server application usually +needs, so a project needs no copy of its own: + +* On the client, `com.codename1.appkit.Net` adapts a generated client call to a + pair of success and failure callbacks, `Skeleton` is a loading placeholder, + and `Chart` draws a small bar chart with its `Meter`. +* On the server, `com.codename1.appkit.server.Caller` answers who is signed in + and which roles they hold, `Text` validates and trims request fields, and + `Grants` ends a user's other sessions. `AccountSecurity` builds the password + encoder, the user store and the registered client a sign-in flow needs. +* The `push` package sends notifications through `PushSender`. It ships with a + sender for the push service, one that prints to the console in development, + one that records what was sent for tests, and one that fails with a clear + message when push has no configuration. + +A project adds `cn1-app-kit-lib` to its client dependencies and +`cn1-app-kit-backend` to its server, both at the Codename One version it builds +with. + === What the build refuses Everything a container would discover at start-up is checked while the module diff --git a/maven/cn1-app-kit/backend/pom.xml b/maven/cn1-app-kit/backend/pom.xml new file mode 100644 index 00000000000..7ccb5d56ad8 --- /dev/null +++ b/maven/cn1-app-kit/backend/pom.xml @@ -0,0 +1,24 @@ + + + 4.0.0 + + com.codenameone + cn1-app-kit + 8.0-SNAPSHOT + + cn1-app-kit-backend + jar + Codename One App Kit: server + + + + com.codenameone + codenameone-backend + ${project.version} + provided + + + diff --git a/maven/cn1-app-kit/backend/src/main/java/com/codename1/appkit/server/Caller.java b/maven/cn1-app-kit/backend/src/main/java/com/codename1/appkit/server/Caller.java new file mode 100644 index 00000000000..c8b7f08b131 --- /dev/null +++ b/maven/cn1-app-kit/backend/src/main/java/com/codename1/appkit/server/Caller.java @@ -0,0 +1,67 @@ +/* + * Copyright (c) 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.appkit.server; + +import com.codename1.backend.ResponseStatusException; +import com.codename1.backend.security.Authentication; +import com.codename1.backend.security.GrantedAuthority; +import com.codename1.backend.security.SecurityContextHolder; + +import java.util.Iterator; + +/// Who is making the request being served. +/// +/// The endpoints implement interfaces generated from the shared contract, whose +/// methods take what the app sends and nothing else, so the caller is read from +/// the security context of the request and not from a parameter. +public final class Caller { + private Caller() { + } + + /// The signed-in user's name, which is the e-mail address of the account. + /// Answers 401 when nobody is signed in; the filter chain has normally done + /// so already, and this is what stands behind it. + public static String name() { + Authentication who = SecurityContextHolder.getContext().getAuthentication(); + if (who == null || !who.isAuthenticated() || who.getName() == null) { + throw new ResponseStatusException(401, "Sign in first"); + } + return who.getName(); + } + + /// Whether the caller holds `role`, named without its `ROLE_` prefix. + public static boolean hasRole(String role) { + Authentication who = SecurityContextHolder.getContext().getAuthentication(); + if (who == null || who.getAuthorities() == null) { + return false; + } + String wanted = "ROLE_" + role; + Iterator it = who.getAuthorities().iterator(); + while (it.hasNext()) { + if (wanted.equals(it.next().getAuthority())) { + return true; + } + } + return false; + } +} diff --git a/maven/cn1-app-kit/backend/src/main/java/com/codename1/appkit/server/Grants.java b/maven/cn1-app-kit/backend/src/main/java/com/codename1/appkit/server/Grants.java new file mode 100644 index 00000000000..b9a84e0dd54 --- /dev/null +++ b/maven/cn1-app-kit/backend/src/main/java/com/codename1/appkit/server/Grants.java @@ -0,0 +1,103 @@ +/* + * Copyright (c) 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.appkit.server; + +import com.codename1.backend.DataSource; +import com.codename1.backend.annotations.Component; +import com.codename1.backend.security.Authentication; +import com.codename1.backend.security.SecurityContextHolder; +import com.codename1.backend.security.oauth2.server.authorization.OAuth2AuthorizationService; +import com.codename1.backend.security.oauth2.server.resource.JwtAuthenticationToken; + +import java.io.IOException; +import java.util.List; +import java.util.Map; + +/// The sign-ins an account has open, and ending them. +/// +/// Signing in leaves a grant in the authorization server's own table, and a +/// refresh token is good for as long as its grant is there: refreshing asks +/// for no password. So a password changed because a phone was lost changes +/// nothing for whoever holds that phone, unless the grants go too. +/// +/// An access token already issued is not recalled by this. It is a signed +/// token that is checked without the table, and it runs out on its own within +/// its few minutes; what ends here is the means of getting another. +@Component +public class Grants { + private final DataSource db; + private final OAuth2AuthorizationService authorizations; + + public Grants(DataSource db, OAuth2AuthorizationService authorizations) { + this.db = db; + this.authorizations = authorizations; + } + + /// Ends every sign-in of an account but the one this request was made + /// with, so the device a password was changed on stays signed in and every + /// other has to give the new password. + public void endOthers(String username) throws IOException { + end(username, current()); + } + + /// Ends every sign-in of an account. + public void endAll(String username) throws IOException { + end(username, ""); + } + + /// Ends one sign-in, by the grant id [#current()] answered when it was + /// made. Its refresh token goes with it. + public void endGrant(String id) throws IOException { + if (id != null && id.length() > 0) { + authorizations.remove(id); + } + } + + private void end(String username, String kept) throws IOException { + // The table is the authorization server's, which offers a grant by its + // id and no list of an account's: this reads the ids and leaves the + // removing, which takes the grant's tokens with it, to the service. + List rows = db.query("SELECT id FROM cn1_oauth2_authorization WHERE principal_name = ?", + new Object[] {username}); + for (int iter = 0; rows != null && iter < rows.size(); iter++) { + Object row = rows.get(iter); + Object id = row instanceof Map ? ((Map) row).get("id") : null; + if (id != null && !kept.equals(String.valueOf(id))) { + authorizations.remove(String.valueOf(id)); + } + } + } + + /// The id of the grant the request's token was issued under, or empty for + /// a request that carried no such token. The server writes the grant's id + /// before the first dot of the token's own. + public static String current() { + Authentication who = SecurityContextHolder.getContext().getAuthentication(); + if (!(who instanceof JwtAuthenticationToken)) { + return ""; + } + String id = ((JwtAuthenticationToken) who).getToken().getId(); + int dot = id == null ? -1 : id.indexOf('.'); + return dot > 0 ? id.substring(0, dot) : ""; + } +} diff --git a/maven/cn1-app-kit/backend/src/main/java/com/codename1/appkit/server/Text.java b/maven/cn1-app-kit/backend/src/main/java/com/codename1/appkit/server/Text.java new file mode 100644 index 00000000000..1d6a1151846 --- /dev/null +++ b/maven/cn1-app-kit/backend/src/main/java/com/codename1/appkit/server/Text.java @@ -0,0 +1,87 @@ +/* + * Copyright (c) 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.appkit.server; + +import com.codename1.backend.ResponseStatusException; + +/// What the app sends, made safe to store: trimmed, bounded, and one of the +/// words a field allows. +public final class Text { + private Text() { + } + + /// `value` trimmed, or empty for null; answers 400 when longer than `max`. + public static String optional(String value, int max, String what) { + String text = value == null ? "" : value.trim(); + if (text.length() > max) { + throw new ResponseStatusException(400, "That is too long for " + what); + } + return text; + } + + /// `value` trimmed; answers 400 when it is empty or longer than `max`. + public static String required(String value, int max, String what) { + String text = optional(value, max, what); + if (text.length() == 0) { + throw new ResponseStatusException(400, "Tell us " + what); + } + return text; + } + + /// `value` when it is one of `allowed`, and `fallback` when it is empty; + /// answers 400 for anything else. The words are compared as written: they + /// are the app's own constants, not something a person types. + public static String oneOf(String value, String[] allowed, String fallback, String what) { + String text = value == null ? "" : value.trim(); + if (text.length() == 0) { + return fallback; + } + for (int iter = 0; iter < allowed.length; iter++) { + if (allowed[iter].equals(text)) { + return text; + } + } + throw new ResponseStatusException(400, "That is not a value for " + what); + } + + /// The first letters of the first and last words of a name, in upper case + /// when they are ASCII letters: "Dana Doe" is "DD". + public static String initials(String name) { + String text = name == null ? "" : name.trim(); + if (text.length() == 0) { + return ""; + } + int last = text.lastIndexOf(' '); + StringBuilder out = new StringBuilder(2); + out.append(upper(text.charAt(0))); + if (last > 0 && last + 1 < text.length()) { + out.append(upper(text.charAt(last + 1))); + } + return out.toString(); + } + + private static char upper(char c) { + // By hand: toUpperCase() follows the server's locale. + return c >= 'a' && c <= 'z' ? (char) (c - ('a' - 'A')) : c; + } +} diff --git a/maven/cn1-app-kit/backend/src/main/java/com/codename1/appkit/server/push/CloudPushSender.java b/maven/cn1-app-kit/backend/src/main/java/com/codename1/appkit/server/push/CloudPushSender.java new file mode 100644 index 00000000000..b6f73a5aba0 --- /dev/null +++ b/maven/cn1-app-kit/backend/src/main/java/com/codename1/appkit/server/push/CloudPushSender.java @@ -0,0 +1,84 @@ +/* + * Copyright (c) 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.appkit.server.push; + +import com.codename1.backend.Tasks; +import com.codename1.backend.push.CloudPush; +import com.codename1.backend.push.PushNotification; +import com.codename1.backend.push.PushResult; +import com.codename1.backend.push.PushTarget; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +// tag::appkit-push-cloud[] +/// Sends through the Codename One push service, which delivers to Firebase, +/// Apple, Huawei and the browsers on the app's behalf. +/// +/// The call to the service is made on a thread of its own, so the request that +/// changed something answers without waiting for it, and a refusal is logged and +/// not thrown: the user still learns of the change the next time the app +/// reads it. +public class CloudPushSender implements PushSender { + private final CloudPush push; + + /// @param push the client for the service, holding the app's id and the + /// server key + public CloudPushSender(CloudPush push) { + this.push = push; + } + + @Override + public void send(List targets, final PushNotification message, + final Runnable delivered) { + if (targets == null || targets.isEmpty()) { + return; + } + final List to = new ArrayList(targets); + Tasks.platform(() -> deliver(to, message, delivered)); + } + + private void deliver(List to, PushNotification message, Runnable delivered) { + boolean accepted = false; + try { + PushResult result = push.send(message, to); + accepted = result.isAccepted(); + if (!accepted) { + System.err.println("[push] the push service refused a notification: " + + result.getStatus() + " " + result.getError()); + } + } catch (IOException | RuntimeException err) { + System.err.println("[push] a notification was not sent: " + err); + } + if (accepted && delivered != null) { + try { + delivered.run(); + } catch (RuntimeException err) { + System.err.println("[push] a notification was sent, and what follows it failed: " + + err); + } + } + } +} +// end::appkit-push-cloud[] diff --git a/maven/cn1-app-kit/backend/src/main/java/com/codename1/appkit/server/push/ConsolePushSender.java b/maven/cn1-app-kit/backend/src/main/java/com/codename1/appkit/server/push/ConsolePushSender.java new file mode 100644 index 00000000000..b1d2102c865 --- /dev/null +++ b/maven/cn1-app-kit/backend/src/main/java/com/codename1/appkit/server/push/ConsolePushSender.java @@ -0,0 +1,47 @@ +/* + * Copyright (c) 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.appkit.server.push; + +import com.codename1.backend.push.PushNotification; +import com.codename1.backend.push.PushTarget; + +import java.util.List; + +/// Prints each notification on the server's console instead of sending it. +/// +/// For development only: the application hands it +/// out on a development profile alone. +public class ConsolePushSender implements PushSender { + @Override + public void send(List targets, PushNotification message, Runnable delivered) { + if (targets == null || targets.isEmpty()) { + return; + } + System.out.println("[push] to " + targets.size() + " device(s): " + + message.toJson()); + // Printed is as delivered as this sender gets. + if (delivered != null) { + delivered.run(); + } + } +} diff --git a/maven/cn1-app-kit/backend/src/main/java/com/codename1/appkit/server/push/MisconfiguredPushSender.java b/maven/cn1-app-kit/backend/src/main/java/com/codename1/appkit/server/push/MisconfiguredPushSender.java new file mode 100644 index 00000000000..9b87459c075 --- /dev/null +++ b/maven/cn1-app-kit/backend/src/main/java/com/codename1/appkit/server/push/MisconfiguredPushSender.java @@ -0,0 +1,51 @@ +/* + * Copyright (c) 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.appkit.server.push; + +import com.codename1.backend.push.PushNotification; +import com.codename1.backend.push.PushTarget; + +import java.util.List; + +/// What the server sends with when the push settings are incomplete: nothing +/// goes out, and the log says why. +/// +/// The problem is logged once when the server starts and again on every send, +/// naming the settings to fix. Unlike a text message, a notification that does +/// not go out costs nobody a sign-in, so it is not an error to the caller. +public class MisconfiguredPushSender implements PushSender { + private final String problem; + + /// @param problem what is wrong, naming the settings to fix + public MisconfiguredPushSender(String problem) { + this.problem = problem; + System.err.println("[config] " + problem); + } + + @Override + public void send(List targets, PushNotification message, Runnable delivered) { + if (targets != null && !targets.isEmpty()) { + System.err.println("[config] a push notification was not sent: " + problem); + } + } +} diff --git a/maven/cn1-app-kit/backend/src/main/java/com/codename1/appkit/server/push/PushSender.java b/maven/cn1-app-kit/backend/src/main/java/com/codename1/appkit/server/push/PushSender.java new file mode 100644 index 00000000000..1bd32e34c66 --- /dev/null +++ b/maven/cn1-app-kit/backend/src/main/java/com/codename1/appkit/server/push/PushSender.java @@ -0,0 +1,46 @@ +/* + * Copyright (c) 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.appkit.server.push; + +import com.codename1.backend.push.PushNotification; +import com.codename1.backend.push.PushTarget; + +import java.util.List; + +// tag::appkit-push-sender[] +/// Sends push notifications to devices. +/// +/// The application chooses the implementation, normally from a property, when +/// it builds its beans. A notification is a courtesy on top of what the +/// app reads from the server, so sending one never fails the change it is about: +/// an implementation logs what went wrong and returns. +public interface PushSender { + /// Sends `message` to every one of `targets`. Returns at once; the sending + /// itself may happen later, on another thread. + /// + /// @param delivered run once the push service has taken the message, on + /// whatever thread learnt it; never when it was refused or could not be + /// sent. Null when nobody needs to know. + void send(List targets, PushNotification message, Runnable delivered); +} +// end::appkit-push-sender[] diff --git a/maven/cn1-app-kit/backend/src/main/java/com/codename1/appkit/server/push/RecordingPushSender.java b/maven/cn1-app-kit/backend/src/main/java/com/codename1/appkit/server/push/RecordingPushSender.java new file mode 100644 index 00000000000..4f26b8125e4 --- /dev/null +++ b/maven/cn1-app-kit/backend/src/main/java/com/codename1/appkit/server/push/RecordingPushSender.java @@ -0,0 +1,127 @@ +/* + * Copyright (c) 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.appkit.server.push; + +import com.codename1.backend.push.PushNotification; +import com.codename1.backend.push.PushTarget; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/// Keeps every notification instead of sending it, for the tests to read. +/// +/// What the `test` profile gets: a test signs a phone in, changes something, and +/// asks this what each device was sent. Refused on any profile that is not a +/// development one. +public class RecordingPushSender implements PushSender { + private final List sent = new ArrayList(); + private int refusals; + private int unconfirmed; + + /// One notification as one device was sent it. + public static final class Sent { + private final String provider; + private final String token; + private final Map message; + + Sent(String provider, String token, Map message) { + this.provider = provider; + this.token = token; + this.message = message; + } + + public String provider() { + return provider; + } + + public String token() { + return token; + } + + /// The notification as it would go on the wire: `title`, `body`, + /// `data`, `surface` and the rest. + public Map message() { + return message; + } + } + + @Override + public void send(List targets, PushNotification message, Runnable delivered) { + // Outside the lock: what follows a delivery writes to the database. + if (record(targets, message) && delivered != null) { + delivered.run(); + } + } + + /// Records a send, and answers whether the push service would confirm it. + private synchronized boolean record(List targets, PushNotification message) { + if (refusals > 0) { + refusals--; + throw new IllegalStateException("The push provider is unavailable"); + } + if (targets != null) { + for (PushTarget target : targets) { + sent.add(new Sent(target.getProvider(), target.getToken(), message.toMap())); + } + } + if (unconfirmed > 0) { + unconfirmed--; + return false; + } + return true; + } + + /// What the device with `token` was sent, oldest first. + public synchronized List sentTo(String token) { + List out = new ArrayList(); + for (Sent each : sent) { + if (each.token.equals(token)) { + out.add(each); + } + } + return out; + } + + /// Everything sent so far, oldest first. + public synchronized List all() { + return new ArrayList(sent); + } + + public synchronized void clear() { + sent.clear(); + refusals = 0; + unconfirmed = 0; + } + + /// Makes the next `count` sends go out and never be confirmed, as a + /// service that refuses them later, on its own thread, does. + public synchronized void leaveUnconfirmed(int count) { + unconfirmed = count; + } + + /// Makes the next `count` sends throw, as a provider that is down does. + public synchronized void refuse(int count) { + refusals = count; + } +} diff --git a/maven/cn1-app-kit/backend/src/main/java/com/codename1/appkit/server/security/AccountSecurity.java b/maven/cn1-app-kit/backend/src/main/java/com/codename1/appkit/server/security/AccountSecurity.java new file mode 100644 index 00000000000..b0ac106e10a --- /dev/null +++ b/maven/cn1-app-kit/backend/src/main/java/com/codename1/appkit/server/security/AccountSecurity.java @@ -0,0 +1,372 @@ +/* + * Copyright (c) 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.appkit.server.security; + +import com.codename1.backend.Config; +import com.codename1.backend.DataSource; +import com.codename1.backend.HttpServer; +import com.codename1.backend.security.AntPathRequestMatcher; +import com.codename1.backend.security.DisabledException; +import com.codename1.backend.security.GrantedAuthority; +import com.codename1.backend.security.HttpSecurity; +import com.codename1.backend.security.HttpStatusReturningLogoutSuccessHandler; +import com.codename1.backend.security.LogoutFilter; +import com.codename1.backend.security.SecurityExchange; +import com.codename1.backend.security.SecurityFilter; +import com.codename1.backend.security.SecurityFilterChain; +import com.codename1.backend.security.SessionCreationPolicy; +import com.codename1.backend.security.core.userdetails.JdbcUserDetailsManager; +import com.codename1.backend.security.core.userdetails.UserDetails; +import com.codename1.backend.security.crypto.JwkSource; +import com.codename1.backend.security.crypto.PasswordEncoder; +import com.codename1.backend.security.crypto.PasswordEncoderFactories; +import com.codename1.backend.security.oauth2.core.AuthorizationGrantType; +import com.codename1.backend.security.oauth2.core.ClientAuthenticationMethod; +import com.codename1.backend.security.oauth2.jwt.DefaultJwtDecoder; +import com.codename1.backend.security.oauth2.jwt.JwtAudienceValidator; +import com.codename1.backend.security.oauth2.jwt.JwtClaimValidator; +import com.codename1.backend.security.oauth2.jwt.JwtIssuerValidator; +import com.codename1.backend.security.oauth2.jwt.JwtValidators; +import com.codename1.backend.security.oauth2.server.authorization.AuthorizationServerKeys; +import com.codename1.backend.security.oauth2.server.authorization.AuthorizationServerSettings; +import com.codename1.backend.security.oauth2.server.authorization.JdbcOAuth2AuthorizationService; +import com.codename1.backend.security.oauth2.server.authorization.JdbcRegisteredClientRepository; +import com.codename1.backend.security.oauth2.server.authorization.OAuth2AuthorizationService; +import com.codename1.backend.security.oauth2.server.authorization.RegisteredClient; +import com.codename1.backend.security.oauth2.server.authorization.RegisteredClientRepository; +import com.codename1.backend.security.oauth2.server.resource.JwtAuthenticationConverter; +import com.codename1.backend.security.ratelimit.InMemoryRateLimiter; +import com.codename1.backend.security.ratelimit.RateLimitKeys; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collection; + +/// The sign-in half of an app's server: accounts, the app as an OAuth client, +/// the authorization server, and how the API reads the token it issues. +/// +/// The server is two things at once, with a filter chain for each: +/// +/// - **An authorization server.** The app signs a user in here -- e-mail and +/// password, then the authorization-code flow with PKCE -- and gets a +/// short-lived access token and a refresh token. Nothing but this server ever +/// sees the password. [#signIn] builds its chain. +/// - **An API.** Everything under `/api` takes that access token as a bearer +/// token, keeps no session, and decides from the account's roles what the +/// caller may do. The application writes that chain, since its routes and +/// roles are its own, from [#tokenDecoder] and [#accountRoles]. +/// +/// Nothing here is a bean by itself. The application's `@Configuration` returns +/// these from its own `@Bean` methods, so it names its client and audience in +/// one place and replaces any piece by writing that method differently. +public final class AccountSecurity { + /// Where the authorization code is sent back to. The app makes the requests + /// of the flow itself and reads the code out of the redirect, so this is + /// never opened; it is the loopback address because that is the one address + /// nobody else can register a listener on. An app that signs in through the + /// system browser adds its own address scheme here with `redirectUri`. + public static final String LOOPBACK_REDIRECT = "http://127.0.0.1/callback"; + /// Where the code is sent back to when the app runs in a browser: a path on + /// this server, which answers with the code it was given. See + /// [#withWebRedirect(RegisteredClient, String, String)]. + public static final String WEB_REDIRECT_PATH = "/signin/code"; + /// The key of the issuer setting: the address tokens name as their issuer. + /// Empty on a development profile, where the server answers on whatever + /// address it was reached by. + public static final String ISSUER_KEY = "cn1.security.authorizationserver.issuer"; + + private AccountSecurity() { + } + + /// How passwords are stored: the delegating encoder, which records the + /// scheme with each hash so a stronger one can be adopted later. + public static PasswordEncoder passwordEncoder() { + return PasswordEncoderFactories.createDelegatingPasswordEncoder(); + } + + /// The accounts, in the framework's own user tables. + public static JdbcUserDetailsManager users(DataSource db) { + return new JdbcUserDetailsManager(db); + } + + /// The app as the authorization server's one client, registered on first + /// start. + /// + /// @param id the row id of the registration + /// @param clientId the id the app signs in as. A public client: it holds no + /// secret, since anything shipped inside an app can be read out of it, + /// and proves itself with PKCE instead. + /// @param clientName what the sign-in pages call the app + /// @param issuer the [#ISSUER_KEY] setting + public static RegisteredClientRepository clients(DataSource db, final String id, + final String clientId, String clientName, final String issuer) throws IOException { + final JdbcRegisteredClientRepository clients = new JdbcRegisteredClientRepository(db); + if (clients.findByClientId(clientId) == null) { + clients.save(RegisteredClient.withId(id).clientId(clientId) + .clientName(clientName) + .clientAuthenticationMethod(ClientAuthenticationMethod.NONE) + .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) + .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN) + .redirectUri(LOOPBACK_REDIRECT) + .scope("openid").scope("profile") + .build()); + } + // The app in a browser is the same client with one more address: this + // server's own. It is worked out and not stored, because on a + // development profile the server has no one address to store -- and a + // row written by an earlier version would not have it either. + return new RegisteredClientRepository() { + @Override + public void save(RegisteredClient client) { + clients.save(client); + } + + @Override + public RegisteredClient findByClientId(String wanted) { + return withWebRedirect(clients.findByClientId(wanted), clientId, issuer); + } + + @Override + public RegisteredClient findById(String wanted) { + return withWebRedirect(clients.findById(wanted), clientId, issuer); + } + }; + } + + /// The app's registration with [#WEB_REDIRECT_PATH] on this server added + /// to the addresses a code may be sent back to. + /// + /// A browser will not show a page the answer to a request that was + /// redirected to another origin, so the app running in one cannot read its + /// code off a redirect to the loopback address the way the installed app + /// does. It asks for the code to be sent to this server instead, which + /// hands it straight back from a page the application serves there. + /// + /// "This server" is the configured issuer where there is one, and is then + /// the only web address accepted. With none -- a development profile -- it + /// is the address the request arrived at, the same rule + /// [#issuedAtThisAddress] holds a token to: a code is only ever sent back + /// to the server the browser was already talking to. + public static RegisteredClient withWebRedirect(RegisteredClient client, String clientId, + String issuer) { + if (client == null || !clientId.equals(client.getClientId())) { + return client; + } + String here = here(issuer); + if (here == null) { + return client; + } + RegisteredClient.Builder copy = RegisteredClient.withId(client.getId()) + .clientId(client.getClientId()) + .clientName(client.getClientName()) + .clientSettings(client.getClientSettings()) + .tokenSettings(client.getTokenSettings()) + .redirectUri(here + WEB_REDIRECT_PATH); + for (ClientAuthenticationMethod method : client.getClientAuthenticationMethods()) { + copy.clientAuthenticationMethod(method); + } + for (AuthorizationGrantType grant : client.getAuthorizationGrantTypes()) { + copy.authorizationGrantType(grant); + } + for (String uri : client.getRedirectUris()) { + copy.redirectUri(uri); + } + for (String scope : client.getScopes()) { + copy.scope(scope); + } + for (String resource : client.getResources()) { + copy.resource(resource); + } + return copy.build(); + } + + /// Refuses a request that changes something and was sent by a page of + /// another site. + /// + /// A browser names the site a request came from in `Origin` on every + /// request that is not a plain read, and a page cannot change or drop it. + /// The installed app is not a browser and sends none, so a request without + /// one is let through: there is no cookie jar of somebody else's for it to + /// be riding on. + public static SecurityFilter sameOrigin(final String issuer) { + return (request, chain) -> { + String method = request.getMethod(); + if ("GET".equals(method) || "HEAD".equals(method) || "OPTIONS".equals(method)) { + return chain.doFilter(request); + } + String origin = request.getHeader("Origin"); + if (origin == null || origin.length() == 0) { + return chain.doFilter(request); + } + String here = here(issuer); + if (here != null && sameOrigin(origin, here)) { + return chain.doFilter(request); + } + return new HttpServer.Response(403, "application/json", + "{\"error\":\"cross_origin\"}".getBytes("UTF-8")) + .header("Cache-Control", "no-store"); + }; + } + + /// This server's address: the configured issuer, and with none -- a + /// development profile -- the address the request in hand arrived at. Null + /// outside a request that says. + public static String here(String issuer) { + if (issuer != null && issuer.length() > 0) { + return issuer; + } + SecurityExchange exchange = SecurityExchange.current(); + String host = exchange == null ? null : exchange.getRequest().getHeader("Host"); + if (host == null || host.length() == 0) { + return null; + } + return (exchange.isSecure() ? "https://" : "http://") + host; + } + + /// Whether `origin` is the origin of `address`: the same scheme, host and + /// port, whatever path the address goes on to name. Scheme and host are + /// compared without regard to case, and without folding it -- that would + /// depend on the language the server runs in. + public static boolean sameOrigin(String origin, String address) { + int path = address.indexOf('/', address.indexOf("://") + 3); + String root = path < 0 ? address : address.substring(0, path); + return origin.equalsIgnoreCase(root); + } + + /// The grants signed-in devices hold, in the authorization server's table. + public static OAuth2AuthorizationService authorizations(DataSource db) { + return new JdbcOAuth2AuthorizationService(db); + } + + /// The keys tokens are signed with, from the server's configuration. + public static JwkSource signingKeys(Config config) throws IOException { + return AuthorizationServerKeys.load(config); + } + + /// The authorization server's chain: the sign-in form, signing out, and + /// the OAuth endpoints. Return it from an `@Order(1)` bean, ahead of the + /// API's chain. + /// + /// @param issuer the [#ISSUER_KEY] setting + /// @param signInsPerMinute how many sign-in attempts one address may make a + /// minute; ten is right for people, tests signing many in raise it + /// @param audience whom the access tokens are for; the API accepts no token + /// issued for anything else + public static SecurityFilterChain signIn(HttpSecurity http, final String issuer, + int signInsPerMinute, String audience) throws IOException { + http.securityMatcher("/oauth2/**", "/.well-known/**", "/userinfo", "/login", "/login/**", + "/logout") + .authorizeHttpRequests(auth -> auth.anyRequest().authenticated()) + // No CSRF token: the app posts the sign-in form itself, from its own + // screen, and would have to fetch a token first to send one. What a + // token is for still has to be done, because the app also runs in a + // browser, where the session is a cookie the browser keeps and would + // send along with a form another site submitted -- signing its + // visitor in as somebody else. Two things stand in for it. The + // session cookie is `SameSite=Lax`, the server's default, so it does + // not travel with another site's request; and [#sameOrigin] below + // refuses any request that changes something and says it came from + // another site, which is the one a forged sign-in is. A server that + // signs people in through a page it serves keeps CSRF on. + .csrf(csrf -> csrf.disable()) + .addFilterBefore(sameOrigin(issuer), LogoutFilter.class) + // Signing out is a POST and nothing else -- with CSRF off the + // default takes a GET too, which an image tag on any page can send + // -- and is answered with a status, for the reason signing in is. + .logout(logout -> logout + .logoutRequestMatcher(AntPathRequestMatcher.antMatcher("POST", "/logout")) + .logoutSuccessHandler(new HttpStatusReturningLogoutSuccessHandler(204))) + // And it is answered with a status, not a redirect to a page: + // the app in a browser cannot see a redirect or the cookie, only + // how the request ended, so that has to be the answer. The session + // cookie is set either way; the installed app reads it, a browser + // keeps it. + .formLogin(form -> form + .successHandler((request, authentication) -> new HttpServer.Response(200, + "application/json", "{\"signedIn\":true}".getBytes("UTF-8")) + .header("Cache-Control", "no-store")) + .failureHandler((request, refused) -> new HttpServer.Response(401, + "application/json", "{\"signedIn\":false}".getBytes("UTF-8")) + .header("Cache-Control", "no-store"))) + // Guessing passwords is the attack on this chain. Ten tries a minute + // from one address, unless the application's setting says otherwise: + // the end-to-end tests are many people signing in from one address, + // and their profile raises it. + .rateLimit("/login", RateLimitKeys.clientAddress(), + new InMemoryRateLimiter(signInsPerMinute, 60)) + .authorizationServer(server -> server + .settings(AuthorizationServerSettings.builder() + .defaultAudience(audience).build())); + return http.build(); + } + + /// Reads and checks an access token this server issued for `audience`. + public static DefaultJwtDecoder tokenDecoder(JwkSource signingKeys, String issuer, + String audience) { + DefaultJwtDecoder decoder = DefaultJwtDecoder.withJwkSource(signingKeys).build(); + decoder.setJwtValidator(JwtValidators.createDefaultWithValidators( + new JwtAudienceValidator(audience), + issuer.length() > 0 ? new JwtIssuerValidator(issuer) + : new JwtClaimValidator("iss", AccountSecurity::issuedAtThisAddress))); + return decoder; + } + + /// Turns a token into the account's roles, read from the account on every + /// request rather than from the token. + public static JwtAuthenticationConverter accountRoles(final JdbcUserDetailsManager users) { + // The token says who the caller is and nothing about what they may do. + // The roles are read from the account on every request instead of being + // copied into the token, for one reason: an admin who withdraws a role + // or suspends an account means now, not when the token runs out. It + // costs a lookup by primary key per request. + JwtAuthenticationConverter roles = new JwtAuthenticationConverter(); + roles.setJwtGrantedAuthoritiesConverter(jwt -> { + String name = jwt.getSubject(); + if (name == null || !users.userExists(name)) { + throw new DisabledException("No such account"); + } + UserDetails account = users.loadUserByUsername(name); + if (!account.isEnabled() || !account.isAccountNonLocked()) { + throw new DisabledException("This account is suspended"); + } + Collection held = new ArrayList(); + held.addAll(account.getAuthorities()); + return held; + }); + return roles; + } + + /// Whether a token's issuer is the address this request came to. + /// + /// Used only when no issuer is configured, which the server allows only on + /// a development profile: it then issues tokens in the name of whatever + /// address each request arrived at, so that is the name to hold a token to. + public static boolean issuedAtThisAddress(Object issuer) { + SecurityExchange exchange = SecurityExchange.current(); + if (exchange == null || !(issuer instanceof String)) { + return false; + } + String host = exchange.getRequest().getHeader("Host"); + return host != null && ((exchange.isSecure() ? "https://" : "http://") + host) + .equals(issuer); + } +} diff --git a/maven/cn1-app-kit/backend/src/main/resources/META-INF/codenameone/backend-library b/maven/cn1-app-kit/backend/src/main/resources/META-INF/codenameone/backend-library new file mode 100644 index 00000000000..e69de29bb2d diff --git a/maven/cn1-app-kit/common/codenameone_library_appended.properties b/maven/cn1-app-kit/common/codenameone_library_appended.properties new file mode 100644 index 00000000000..3417a0e1ba9 --- /dev/null +++ b/maven/cn1-app-kit/common/codenameone_library_appended.properties @@ -0,0 +1,2 @@ +# Build hints the app kit adds to an application that uses it: none. The file +# is what marks this module as a cn1lib's common module to the cn1lib goal. diff --git a/maven/cn1-app-kit/common/codenameone_library_required.properties b/maven/cn1-app-kit/common/codenameone_library_required.properties new file mode 100644 index 00000000000..7d5d6259770 --- /dev/null +++ b/maven/cn1-app-kit/common/codenameone_library_required.properties @@ -0,0 +1 @@ +# Build hints an application using the app kit must have: none. diff --git a/maven/cn1-app-kit/common/pom.xml b/maven/cn1-app-kit/common/pom.xml new file mode 100644 index 00000000000..b9dde9bec05 --- /dev/null +++ b/maven/cn1-app-kit/common/pom.xml @@ -0,0 +1,40 @@ + + + 4.0.0 + + com.codenameone + cn1-app-kit + 8.0-SNAPSHOT + + cn1-app-kit-common + jar + Codename One App Kit: client + + + com.codenameone + codenameone-core + ${project.version} + provided + + + + + + com.codenameone + codenameone-maven-plugin + ${project.version} + + + build-legacy-cn1lib + package + + cn1lib + + + + + + + diff --git a/maven/cn1-app-kit/common/src/main/java/com/codename1/appkit/Chart.java b/maven/cn1-app-kit/common/src/main/java/com/codename1/appkit/Chart.java new file mode 100644 index 00000000000..30f7b6d60ed --- /dev/null +++ b/maven/cn1-app-kit/common/src/main/java/com/codename1/appkit/Chart.java @@ -0,0 +1,202 @@ +/* + * Copyright (c) 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.appkit; + +import com.codename1.ui.CN; +import com.codename1.ui.Component; +import com.codename1.ui.Container; +import com.codename1.ui.Graphics; +import com.codename1.ui.Label; +import com.codename1.ui.geom.Dimension; +import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.plaf.Style; +import com.codename1.ui.plaf.UIManager; + +/// A row of bars: orders a day, takings a day, requests by the hour. +/// +/// Drawn here, in a few lines, because that is all a bar chart is and because +/// it then takes its colours from `theme.css` like everything else: the bars +/// are the `Chart` style's colour, the highest of them `ChartPeak`'s, and +/// the lines behind them `ChartGrid`'s, and the captions under it +/// `ChartAxis`. The prefix is the application's own, so each app styles +/// its charts in its own theme. Nothing here names a colour. +public final class Chart extends Component { + private static final float HEIGHT_MM = 24f; + + private final double[] values; + private final String prefix; + + private Chart(String prefix, double[] values) { + this.values = values; + this.prefix = prefix; + setUIID(prefix + "Chart"); + setFocusable(false); + } + + /// The chart, with what its two ends are written under it. + /// + /// @param prefix what the chart's styles are named after: `Wl` styles it + /// with `WlChart`, `WlChartPeak`, `WlChartGrid` and `WlChartAxis` + /// @param name what a test finds the chart by + /// @param from the caption under the first bar, already translated + /// @param to the caption under the last + public static Container of(String prefix, String name, double[] values, String from, + String to) { + Chart chart = new Chart(prefix, values); + chart.setName(name); + Container box = new Container(new BorderLayout()); + box.add(BorderLayout.CENTER, chart); + Container axis = new Container(new BorderLayout()); + axis.add(BorderLayout.WEST, caption(from, prefix)); + axis.add(BorderLayout.EAST, caption(to, prefix)); + box.add(BorderLayout.SOUTH, axis); + return box; + } + + /// A caption, shown as given: it arrives translated. + private static Label caption(String text, String prefix) { + Label label = new Label("", prefix + "ChartAxis"); + label.setShouldLocalize(false); + label.setText(text == null ? "" : text); + return label; + } + + /// How many bars there are, for a test. + public int bars() { + return values.length; + } + + @Override + protected Dimension calcPreferredSize() { + Style style = getStyle(); + return new Dimension(CN.convertToPixels(40f), + CN.convertToPixels(HEIGHT_MM) + style.getVerticalPadding()); + } + + @Override + public void paint(Graphics g) { + Style style = getStyle(); + int left = getX() + style.getPaddingLeftNoRTL(); + int top = getY() + style.getPaddingTop(); + int width = getWidth() - style.getHorizontalPadding(); + int height = getHeight() - style.getVerticalPadding(); + if (width <= 0 || height <= 0) { + return; + } + UIManager styles = getUIManager(); + g.setColor(styles.getComponentStyle(prefix + "ChartGrid").getFgColor()); + // Three lines to read the heights against: the foot, half, the top. + for (int line = 0; line <= 2; line++) { + int y = top + (height - 1) * line / 2; + g.drawLine(left, y, left + width, y); + } + int count = values.length; + if (count == 0) { + return; + } + double most = 0; + int peak = -1; + for (int iter = 0; iter < count; iter++) { + if (values[iter] > most) { + most = values[iter]; + peak = iter; + } + } + if (most <= 0) { + return; + } + // The space is shared out in whole pixels, so every bar is as wide as + // every other and what is left over goes to the two ends. + int slot = Math.max(1, width / count); + int gap = Math.max(1, slot / 4); + int bar = Math.max(1, slot - gap); + int start = left + (width - slot * count + gap) / 2; + int round = Math.min(bar, CN.convertToPixels(1.2f)); + int normal = style.getFgColor(); + int highest = styles.getComponentStyle(prefix + "ChartPeak").getFgColor(); + boolean rtl = isRTL(); + g.setAntiAliased(true); + for (int iter = 0; iter < count; iter++) { + int tall = (int) Math.round(values[iter] / most * (height - 2)); + if (values[iter] > 0 && tall < round) { + tall = round; + } + if (tall <= 0) { + continue; + } + // Time runs the way the language is read. + int at = rtl ? count - 1 - iter : iter; + g.setColor(iter == peak ? highest : normal); + g.fillRoundRect(start + at * slot, top + height - tall, bar, tall, round, round); + } + } + + /// A share of something, as a bar part of the way along a track: the bar + /// is the `Meter` style's colour and the track a faint wash of it. + public static final class Meter extends Component { + private static final int TRACK_ALPHA = 36; + private final double share; + + /// @param prefix what the style is named after: `Wl` reads `WlMeter` + /// @param share from 0 to 1 + public Meter(String prefix, double share) { + this.share = share < 0 ? 0 : share > 1 ? 1 : share; + setUIID(prefix + "Meter"); + setFocusable(false); + } + + @Override + protected Dimension calcPreferredSize() { + Style style = getStyle(); + return new Dimension(CN.convertToPixels(20f), + CN.convertToPixels(1.6f) + style.getVerticalPadding()); + } + + @Override + public void paint(Graphics g) { + Style style = getStyle(); + int left = getX() + style.getPaddingLeftNoRTL(); + int top = getY() + style.getPaddingTop(); + int width = getWidth() - style.getHorizontalPadding(); + int height = getHeight() - style.getVerticalPadding(); + if (width <= 0 || height <= 0) { + return; + } + g.setAntiAliased(true); + // The track is the bar's own colour, faint: a background colour + // would be painted square behind the rounded ends. + int alpha = g.getAlpha(); + g.setColor(style.getFgColor()); + g.setAlpha(TRACK_ALPHA); + g.fillRoundRect(left, top, width, height, height, height); + g.setAlpha(alpha); + int filled = (int) Math.round(width * share); + if (filled > 0) { + filled = Math.max(filled, height); + g.setColor(style.getFgColor()); + g.fillRoundRect(isRTL() ? left + width - filled : left, top, filled, height, + height, height); + } + } + } +} diff --git a/maven/cn1-app-kit/common/src/main/java/com/codename1/appkit/Net.java b/maven/cn1-app-kit/common/src/main/java/com/codename1/appkit/Net.java new file mode 100644 index 00000000000..6a122e773b2 --- /dev/null +++ b/maven/cn1-app-kit/common/src/main/java/com/codename1/appkit/Net.java @@ -0,0 +1,177 @@ +/* + * Copyright (c) 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.appkit; + +import com.codename1.io.rest.Response; +import com.codename1.util.OnComplete; +import com.codename1.ui.plaf.UIManager; + +/// Makes a call through a generated client and sorts out what came back. +/// +/// A generated method answers with a `Response` whatever happened: null when +/// the server could not be reached, and otherwise a status with either the +/// typed result or, for a refusal, the server's reason as text. This turns that +/// into one of two callbacks, so a screen writes what to do with the result and +/// what to do with a failure, and nothing else. +/// +/// ```java +/// Api.orders().order(id, Net.to(order -> show(order), Ui::fail)); +/// ``` +/// +/// Every message it makes up is passed through the theme's localization +/// bundle with the English text as its key, so an app translates them in its +/// bundles like any other text. Two hooks fit it to an app that needs more: +/// [#setEpoch(Epoch)] drops answers that belong to a session no longer on +/// screen, and [#setReasons(Reasons)] turns a server's refusal into the words +/// shown. +public final class Net { + /// A call to a generated client method, waiting for its callback. + public interface Call { + void run(OnComplete> callback); + } + + public interface Ok { + void got(T value); + } + + public interface Failed { + /// @param status the HTTP status, or 0 when the server was not reached + /// @param message something to show the user + void failed(int status, String message); + } + + /// The session an answer belongs to. A call remembers the value when it + /// is made, and its answer is dropped, neither callback running, when the + /// value has moved on by the time it arrives: the app locked, or the user + /// signed out or in again while it was out. + public interface Epoch { + int current(); + } + + /// What a failure says to the user. Asked about every failure, so an app + /// that keeps state about the last one (a refusal code it reads in its + /// [Failed] callback) can clear it when the next failure has none. + public interface Reasons { + /// @param status the HTTP status, or 0 when the server was not reached + /// @param body the text the server refused with, when it answered with + /// a short line of text; otherwise null + /// @return what to show, or null for what this class would show + String reason(int status, String body); + } + + private static Epoch epoch; + private static Reasons reasons; + + private Net() { + } + + /// Sets the session counter answers are checked against; null, the + /// default, delivers every answer. + public static void setEpoch(Epoch epoch) { + Net.epoch = epoch; + } + + /// Sets what a refusal is shown as; null, the default, shows the server's + /// text. + public static void setReasons(Reasons reasons) { + Net.reasons = reasons; + } + + private static int epochNow() { + Epoch e = epoch; + return e == null ? 0 : e.current(); + } + + private static String custom(int status, String body) { + Reasons r = reasons; + return r == null ? null : r.reason(status, body); + } + + private static String tr(String text) { + return UIManager.getInstance().localize(text, text); + } + + /// The callback to hand a generated method: `ok` gets the result, `failed` + /// everything else. + public static OnComplete> to(final Ok ok, final Failed failed) { + final int asked = epochNow(); + return new OnComplete>() { + @Override + public void completed(Response response) { + if (asked != epochNow()) { + return; + } + if (response == null) { + String shown = custom(0, null); + failed.failed(0, shown != null ? shown + : tr("Could not reach the server. Check your connection.")); + return; + } + int status = response.getResponseCode(); + if (status >= 200 && status < 300) { + ok.got(response.getResponseData()); + return; + } + failed.failed(status, reason(response, status)); + } + }; + } + + /// Makes a call that was handed over as a value, which is how one screen + /// serves several calls of the same shape. + public static void send(Call call, Ok ok, Failed failed) { + call.run(to(ok, failed)); + } + + /// What the server said when it refused. Read as an `Object` and tested, + /// never cast to the type the call was declared with: on a refusal the body + /// is text whatever that type is. + private static String reason(Response response, int status) { + Object data = response.getResponseData(); + String body = null; + if (data instanceof String && ((String) data).length() > 0 + && ((String) data).length() < 300 && ((String) data).indexOf('<') < 0 + && ((String) data).indexOf('{') < 0) { + body = (String) data; + } + String shown = custom(status, body); + if (shown != null) { + return shown; + } + if (body != null) { + return body; + } + if (status == 401) { + return tr("Please sign in again."); + } + if (status == 403) { + return tr("Your account is not allowed to do that."); + } + if (status == 429) { + return tr("Too many attempts. Wait a moment and try again."); + } + String text = tr("Something went wrong ({0}). Try again."); + int at = text.indexOf("{0}"); + return at < 0 ? text : text.substring(0, at) + status + text.substring(at + 3); + } +} diff --git a/maven/cn1-app-kit/common/src/main/java/com/codename1/appkit/Skeleton.java b/maven/cn1-app-kit/common/src/main/java/com/codename1/appkit/Skeleton.java new file mode 100644 index 00000000000..c18b8309cd9 --- /dev/null +++ b/maven/cn1-app-kit/common/src/main/java/com/codename1/appkit/Skeleton.java @@ -0,0 +1,96 @@ +/* + * Copyright (c) 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.appkit; + +import com.codename1.ui.CN; +import com.codename1.ui.Component; +import com.codename1.ui.Graphics; +import com.codename1.ui.geom.Dimension; +import com.codename1.ui.plaf.Style; + +/// What a list shows while what is in it is on its way: the outline of a few +/// rows, drawn as bars where the icon, the two lines and the value will be. +/// +/// It says "this is loading" without a word and without moving, and it keeps +/// the screen from jumping when the rows arrive, because they land where the +/// outline was. The colour is the foreground of the style it is given. +public final class Skeleton extends Component { + /// How tall one row is drawn, in millimetres: a row of two lines. + private static final float ROW_MM = 9.6f; + + private final int rows; + + /// @param rows how many rows to outline + /// @param uiid the style whose foreground the bars are drawn in + public Skeleton(int rows, String uiid) { + this.rows = rows; + setUIID(uiid); + setFocusable(false); + } + + @Override + protected Dimension calcPreferredSize() { + Style style = getStyle(); + return new Dimension(CN.convertToPixels(40f) + style.getHorizontalPadding(), + rows * CN.convertToPixels(ROW_MM) + style.getVerticalPadding()); + } + + @Override + public void paint(Graphics g) { + Style style = getStyle(); + int left = getX() + style.getPaddingLeftNoRTL(); + int width = getWidth() - style.getHorizontalPadding(); + int row = CN.convertToPixels(ROW_MM); + int dot = CN.convertToPixels(4.4f); + int tall = CN.convertToPixels(1.9f); + int thin = CN.convertToPixels(1.5f); + int gap = CN.convertToPixels(2.6f); + if (width <= dot + gap) { + return; + } + boolean rtl = isRTL(); + g.setAntiAliased(true); + g.setColor(style.getFgColor()); + int lines = width - dot - gap; + for (int iter = 0; iter < rows; iter++) { + int top = getY() + style.getPaddingTop() + iter * row; + int middle = top + row / 2; + // Each row a little shorter than the one before: a list of + // identical bars reads as a pattern, not as a list. + int first = lines * (48 - iter % 3 * 7) / 100; + int second = lines * (30 + iter % 2 * 9) / 100; + int value = lines * 14 / 100; + bar(g, rtl, left, width, 0, middle - dot / 2, dot, dot); + bar(g, rtl, left, width, dot + gap, middle - tall - thin / 3, first, tall); + bar(g, rtl, left, width, dot + gap, middle + thin * 2 / 3, second, thin); + bar(g, rtl, left, width, width - value, middle - tall / 2, value, tall); + } + } + + /// One bar, `from` pixels in from the side a line of text starts at. + private static void bar(Graphics g, boolean rtl, int left, int width, int from, int y, + int w, int h) { + int x = rtl ? left + width - from - w : left + from; + g.fillRoundRect(x, y, w, h, h, h); + } +} diff --git a/maven/cn1-app-kit/lib/pom.xml b/maven/cn1-app-kit/lib/pom.xml new file mode 100644 index 00000000000..40400224bd2 --- /dev/null +++ b/maven/cn1-app-kit/lib/pom.xml @@ -0,0 +1,24 @@ + + + 4.0.0 + + com.codenameone + cn1-app-kit + 8.0-SNAPSHOT + + cn1-app-kit-lib + pom + Codename One App Kit: library + + + + com.codenameone + cn1-app-kit-common + ${project.version} + + + diff --git a/maven/cn1-app-kit/pom.xml b/maven/cn1-app-kit/pom.xml new file mode 100644 index 00000000000..d730bbd4197 --- /dev/null +++ b/maven/cn1-app-kit/pom.xml @@ -0,0 +1,65 @@ + + + 4.0.0 + + com.codenameone + codenameone + 8.0-SNAPSHOT + + cn1-app-kit + pom + Codename One App Kit + The account, networking and notification plumbing the full-stack samples share, client and server + + + cn1-app-kit + 1.8 + 1.8 + 1.8 + UTF-8 + + + + + + com.github.spotbugs + spotbugs-maven-plugin + 4.7.3.4 + + Max + Low + true + true + ${project.build.directory} + ${maven.multiModuleProjectDirectory}/cn1-app-kit/spotbugs-exclude.xml + + + + spotbugs + verify + + spotbugs + check + + + + + + + + backend + common + lib + + diff --git a/maven/cn1-app-kit/spotbugs-exclude.xml b/maven/cn1-app-kit/spotbugs-exclude.xml new file mode 100644 index 00000000000..7552968b166 --- /dev/null +++ b/maven/cn1-app-kit/spotbugs-exclude.xml @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/maven/pom.xml b/maven/pom.xml index 7c3bea5cf4c..b5c0b8f9967 100644 --- a/maven/pom.xml +++ b/maven/pom.xml @@ -150,6 +150,9 @@ cn1-applovin cn1-unity-levelplay cn1-ads-mock + + cn1-app-kit From 90aabd9bb03e45745c2ca6e47d2b4f8d5ccfce80 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 1 Nov 2026 18:23:09 +0200 Subject: [PATCH 2/4] cn1-app-kit: wire nothing a server did not ask for Grants was a component, so every server depending on the kit got it wired and needed an authorization server. It is now built from a @Bean method like AccountSecurity's pieces. A null issuer no longer throws in tokenDecoder, the issuer requirement for production is documented, and the lib pom says what adding CSS or natives would need. Co-Authored-By: Claude Opus 5.5 --- docs/developer-guide/Backend-Beans.asciidoc | 3 +++ .../src/main/java/com/codename1/appkit/server/Grants.java | 7 +++++-- .../codename1/appkit/server/security/AccountSecurity.java | 6 +++++- maven/cn1-app-kit/lib/pom.xml | 3 +++ 4 files changed, 16 insertions(+), 3 deletions(-) diff --git a/docs/developer-guide/Backend-Beans.asciidoc b/docs/developer-guide/Backend-Beans.asciidoc index 5139e7efce4..999c6a161e7 100644 --- a/docs/developer-guide/Backend-Beans.asciidoc +++ b/docs/developer-guide/Backend-Beans.asciidoc @@ -444,6 +444,9 @@ needs, so a project needs no copy of its own: and which roles they hold, `Text` validates and trims request fields, and `Grants` ends a user's other sessions. `AccountSecurity` builds the password encoder, the user store and the registered client a sign-in flow needs. + None of these is a bean of its own: the kit wires nothing into a server + until the server declares it, so a server that takes only `Text` needs no + authorization server. * The `push` package sends notifications through `PushSender`. It ships with a sender for the push service, one that prints to the console in development, one that records what was sent for tests, and one that fails with a clear diff --git a/maven/cn1-app-kit/backend/src/main/java/com/codename1/appkit/server/Grants.java b/maven/cn1-app-kit/backend/src/main/java/com/codename1/appkit/server/Grants.java index b9a84e0dd54..e1800680655 100644 --- a/maven/cn1-app-kit/backend/src/main/java/com/codename1/appkit/server/Grants.java +++ b/maven/cn1-app-kit/backend/src/main/java/com/codename1/appkit/server/Grants.java @@ -23,7 +23,6 @@ package com.codename1.appkit.server; import com.codename1.backend.DataSource; -import com.codename1.backend.annotations.Component; import com.codename1.backend.security.Authentication; import com.codename1.backend.security.SecurityContextHolder; import com.codename1.backend.security.oauth2.server.authorization.OAuth2AuthorizationService; @@ -43,7 +42,11 @@ /// An access token already issued is not recalled by this. It is a signed /// token that is checked without the table, and it runs out on its own within /// its few minutes; what ends here is the means of getting another. -@Component +/// +/// Not a component: a library's components are wired into every server that +/// depends on it, and only a server running the authorization server has the +/// [OAuth2AuthorizationService] this needs. Such a server declares it from a +/// `@Bean` method. public class Grants { private final DataSource db; private final OAuth2AuthorizationService authorizations; diff --git a/maven/cn1-app-kit/backend/src/main/java/com/codename1/appkit/server/security/AccountSecurity.java b/maven/cn1-app-kit/backend/src/main/java/com/codename1/appkit/server/security/AccountSecurity.java index b0ac106e10a..2d431de16a3 100644 --- a/maven/cn1-app-kit/backend/src/main/java/com/codename1/appkit/server/security/AccountSecurity.java +++ b/maven/cn1-app-kit/backend/src/main/java/com/codename1/appkit/server/security/AccountSecurity.java @@ -230,6 +230,10 @@ public static SecurityFilter sameOrigin(final String issuer) { /// This server's address: the configured issuer, and with none -- a /// development profile -- the address the request in hand arrived at. Null /// outside a request that says. + /// + /// Configure the issuer in production. Without one, the trusted origin and + /// the web redirect follow the request's `Host` header, which a proxy that + /// forwards it unchanged lets the client choose. public static String here(String issuer) { if (issuer != null && issuer.length() > 0) { return issuer; @@ -325,7 +329,7 @@ public static DefaultJwtDecoder tokenDecoder(JwkSource signingKeys, String issue DefaultJwtDecoder decoder = DefaultJwtDecoder.withJwkSource(signingKeys).build(); decoder.setJwtValidator(JwtValidators.createDefaultWithValidators( new JwtAudienceValidator(audience), - issuer.length() > 0 ? new JwtIssuerValidator(issuer) + issuer != null && issuer.length() > 0 ? new JwtIssuerValidator(issuer) : new JwtClaimValidator("iss", AccountSecurity::issuedAtThisAddress))); return decoder; } diff --git a/maven/cn1-app-kit/lib/pom.xml b/maven/cn1-app-kit/lib/pom.xml index 40400224bd2..cbcfdeac297 100644 --- a/maven/cn1-app-kit/lib/pom.xml +++ b/maven/cn1-app-kit/lib/pom.xml @@ -11,6 +11,9 @@ cn1-app-kit-lib pom Codename One App Kit: library + From e383d93511159f72feb8c7c58503d44a4dd87782 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 1 Nov 2026 18:23:59 +0200 Subject: [PATCH 3/4] cn1-app-kit: an issuer with a trailing slash names the same server Co-Authored-By: Claude Opus 5.5 --- .../com/codename1/appkit/server/security/AccountSecurity.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/maven/cn1-app-kit/backend/src/main/java/com/codename1/appkit/server/security/AccountSecurity.java b/maven/cn1-app-kit/backend/src/main/java/com/codename1/appkit/server/security/AccountSecurity.java index 2d431de16a3..81fffca801f 100644 --- a/maven/cn1-app-kit/backend/src/main/java/com/codename1/appkit/server/security/AccountSecurity.java +++ b/maven/cn1-app-kit/backend/src/main/java/com/codename1/appkit/server/security/AccountSecurity.java @@ -236,7 +236,8 @@ public static SecurityFilter sameOrigin(final String issuer) { /// forwards it unchanged lets the client choose. public static String here(String issuer) { if (issuer != null && issuer.length() > 0) { - return issuer; + // "https://x/" and "https://x" are one server; paths are appended. + return issuer.endsWith("/") ? issuer.substring(0, issuer.length() - 1) : issuer; } SecurityExchange exchange = SecurityExchange.current(); String host = exchange == null ? null : exchange.getRequest().getHeader("Host"); From 4144fc4bb9caa33520f99552c6890f047d4b09dc Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 2 Nov 2026 02:27:13 +0200 Subject: [PATCH 4/4] cn1-app-kit: reword the Caller sentence LanguageTool misparsed Co-Authored-By: Claude Opus 5.5 --- docs/developer-guide/Backend-Beans.asciidoc | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/developer-guide/Backend-Beans.asciidoc b/docs/developer-guide/Backend-Beans.asciidoc index 999c6a161e7..b72cb5ff1ac 100644 --- a/docs/developer-guide/Backend-Beans.asciidoc +++ b/docs/developer-guide/Backend-Beans.asciidoc @@ -440,8 +440,8 @@ needs, so a project needs no copy of its own: * On the client, `com.codename1.appkit.Net` adapts a generated client call to a pair of success and failure callbacks, `Skeleton` is a loading placeholder, and `Chart` draws a small bar chart with its `Meter`. -* On the server, `com.codename1.appkit.server.Caller` answers who is signed in - and which roles they hold, `Text` validates and trims request fields, and +* On the server, `com.codename1.appkit.server.Caller` reports the signed-in + user and their roles, `Text` validates and trims request fields, and `Grants` ends a user's other sessions. `AccountSecurity` builds the password encoder, the user store and the registered client a sign-in flow needs. None of these is a bean of its own: the kit wires nothing into a server