diff --git a/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/security/IdentityImport.java b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/security/IdentityImport.java new file mode 100644 index 00000000000..9ebdfbc6cd5 --- /dev/null +++ b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/security/IdentityImport.java @@ -0,0 +1,97 @@ +/* + * 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.codenameone.developerguide.backend.security; + +import com.codename1.backend.security.core.userdetails.UserDetailsManager; +import com.codename1.backend.security.crypto.BCryptPasswordEncoder; +import com.codename1.backend.security.crypto.DelegatingPasswordEncoder; +import com.codename1.backend.security.crypto.ModularPbkdf2PasswordEncoder; +import com.codename1.backend.security.crypto.PasswordEncoder; +import com.codename1.backend.security.crypto.Pbkdf2Sha256PasswordEncoder; +import com.codename1.backend.security.migration.IdentityMigration; +import com.codename1.backend.security.migration.LegacyPasskey; +import com.codename1.backend.security.migration.MigrationReport; +import com.codename1.backend.security.oauth2.client.FederatedIdentityRepository; +import com.codename1.backend.security.webauthn.PublicKeyCredentialUserEntityRepository; +import com.codename1.backend.security.webauthn.UserCredentialRepository; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** The Backend security chapter's migration examples. */ +final class IdentityImport { + private IdentityImport() { + } + + /** One account as the old store exported it. */ + static final class OldUser { + String email; + String passwordHash; + String roles; + boolean active; + String googleSubject; + byte[] passkeyId; + byte[] passkeyHandle; + byte[] passkeyCoseKey; + long passkeyCount; + } + +// tag::backend-security-migration-encoders[] + static PasswordEncoder passwords() { + Map encoders = new LinkedHashMap<>(); + encoders.put("pbkdf2-sha256", new Pbkdf2Sha256PasswordEncoder()); + encoders.put("bcrypt", new BCryptPasswordEncoder()); + encoders.put("django", new ModularPbkdf2PasswordEncoder( + ModularPbkdf2PasswordEncoder.Format.DJANGO)); + return new DelegatingPasswordEncoder("pbkdf2-sha256", encoders); + } +// end::backend-security-migration-encoders[] + +// tag::backend-security-migration-run[] + static MigrationReport migrate(List rows, UserDetailsManager users, + DelegatingPasswordEncoder passwords, FederatedIdentityRepository links, + PublicKeyCredentialUserEntityRepository handles, + UserCredentialRepository credentials, boolean dryRun) { + IdentityMigration migration = new IdentityMigration(users, passwords); + migration.setDryRun(dryRun); + migration.setPasswordPrefix("django"); // the old rows carry no {id} + migration.setFederatedIdentities(links); + migration.setPasskeys(handles, credentials); + for (OldUser row : rows) { + migration.user(row.email, row.passwordHash, + Arrays.asList(row.roles.split(",")), row.active); + if (row.googleSubject != null) { + migration.federatedIdentity("google", row.googleSubject, row.email); + } + if (row.passkeyId != null) { + migration.passkey(new LegacyPasskey(row.email, row.passkeyHandle, + row.passkeyId, row.passkeyCoseKey).signatureCount(row.passkeyCount)); + } + } + MigrationReport report = migration.finish(); + System.out.println(report); // every row that needed more than a copy + return report; + } +// end::backend-security-migration-run[] +} diff --git a/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/security/OutboundTokens.java b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/security/OutboundTokens.java new file mode 100644 index 00000000000..9f20d973364 --- /dev/null +++ b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/security/OutboundTokens.java @@ -0,0 +1,72 @@ +/* + * 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.codenameone.developerguide.backend.security; + +import com.codename1.backend.Config; +import com.codename1.backend.annotations.Bean; +import com.codename1.backend.annotations.Configuration; +import com.codename1.backend.security.oauth2.client.credentials.AccessTokenProvider; +import com.codename1.backend.security.oauth2.client.credentials.AccessTokenProviders; +import com.codename1.backend.security.oauth2.client.credentials.ClientCredentialsGrant; +import com.codename1.backend.security.oauth2.client.credentials.GoogleServiceAccountCredentials; +import java.io.IOException; + +/** The Backend security chapter's examples of calling other services. */ +@Configuration +public class OutboundTokens { +// tag::backend-security-outbound-google[] + @Bean + AccessTokenProvider firestore() throws IOException { + GoogleServiceAccountCredentials account = + GoogleServiceAccountCredentials.fromFile("/run/secrets/service-account.json"); + return new AccessTokenProvider( + account.grant("https://www.googleapis.com/auth/datastore")); + } +// end::backend-security-outbound-google[] + +// tag::backend-security-outbound-client[] + @Bean + AccessTokenProvider billing(Config config) throws IOException { + return new AccessTokenProvider(ClientCredentialsGrant + .builder("https://auth.example.com/oauth/token", "orders-service") + .clientSecret(config.get("billing.client-secret")) + .scopes("invoices:write") + .build()); + } +// end::backend-security-outbound-client[] + +// tag::backend-security-outbound-config[] + @Bean + AccessTokenProvider payments(Config config) throws IOException { + // Reads cn1.security.oauth2.client.credentials.payments.* + return AccessTokenProviders.fromConfig(config, "payments"); + } +// end::backend-security-outbound-config[] + +// tag::backend-security-outbound-use[] + static String authorization(AccessTokenProvider tokens) { + // Cached until a minute before it expires; one refresh for every caller. + return tokens.getToken().toAuthorizationHeader(); + } +// end::backend-security-outbound-use[] +} diff --git a/docs/developer-guide/Backend-Security.asciidoc b/docs/developer-guide/Backend-Security.asciidoc index 8b1143e0290..5963788ec96 100644 --- a/docs/developer-guide/Backend-Security.asciidoc +++ b/docs/developer-guide/Backend-Security.asciidoc @@ -86,6 +86,7 @@ The context belongs to the thread that serves the request, for the length of that request. It doesn't follow work handed to another thread: an `@Async` method or a `@Scheduled` job starts with none. +[[backend-security-users]] === Users and passwords A chain that takes a password needs to know its users. It finds them in the @@ -886,6 +887,69 @@ provider until they set one. The identities are kept in a `FederatedIdentityRepository`, in memory or in the `cn1_federated_identity` table. +[[backend-security-outbound]] +=== Calling other services + +A server that calls another API as itself, not on behalf of a user, needs an +access token of its own. `AccessTokenProvider` gets one, keeps it until a +minute before it expires, and gets the next one. Two grants are supported: + +* **Client credentials** (RFC 6749), with the client secret in an HTTP Basic + header, in the form, or replaced by a signed `private_key_jwt` assertion. +* **JWT bearer** (RFC 7523), where the server signs an assertion with its own + key and trades it for a token. A Google service account works this way. + +A Google service account is read from the JSON file Google gives you. Its +`private_key_id` becomes the assertion's `kid`: + +[source,java,role=cn1-no-try] +---- +include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/security/OutboundTokens.java[tag=backend-security-outbound-google,indent=0] +---- + +Any other provider's client credentials grant: + +[source,java,role=cn1-no-try] +---- +include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/security/OutboundTokens.java[tag=backend-security-outbound-client,indent=0] +---- + +Or the whole definition can come from configuration, under +`cn1.security.oauth2.client.credentials.NAME.` (see +<>): + +[source,java,role=cn1-no-try] +---- +include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/security/OutboundTokens.java[tag=backend-security-outbound-config,indent=0] +---- + +Each call then asks the provider for the header: + +[source,java,role=cn1-no-try] +---- +include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/security/OutboundTokens.java[tag=backend-security-outbound-use,indent=0] +---- + +What the provider promises: + +* **One refresh at a time.** Callers that arrive while a refresh is running + get the current token if it's still valid, and otherwise wait for that + refresh instead of starting their own. +* **Never an expired token.** If a refresh fails after the token expired, + `getToken()` throws `TokenAcquisitionException`. +* **A cooldown, not retries in place.** A failure the endpoint may recover from + (a 5xx, a 429, an unreachable host) puts the provider in a cooldown that + doubles with each failure, up to a minute, with jitter. Calls during it fail + at once rather than holding a request thread. A refused grant + (`invalid_client`, `invalid_grant`) is never retried, and the exception says + so. +* **No secrets in errors.** An exception names the endpoint, the HTTP status + and the OAuth error code. It never includes the client secret, the + assertion, the token or the response body. + +Call `invalidate()` when the other service rejects a token before its +expiry, for example after the provider revoked it. + [[backend-security-authorization-server]] === The authorization server @@ -1570,6 +1634,136 @@ recovery code and a rate limit's last permit. `OAuth2AuthorizationService.purgeExpired` remove rows nobody will present again. The first two are yours to call from a scheduled job. +[[backend-security-migration]] +=== Migrating an existing identity store + +Users move to a new server without resetting anything they sign in with, as +long as the new server can verify what the old one stored. Part of the work +is configuration the server already reads, and the rest is +`IdentityMigration`, which imports the records. + +==== Password hashes + +`DelegatingPasswordEncoder` picks an encoder by the `{id}` in front of a +stored hash, so the old store's hashes can stay as they are: + +* `{bcrypt}` reads `$2a$`, `$2b$` and `$2y$` hashes. +* `{pbkdf2-sha256}` reads Spring Security's PBKDF2 format. +* `ModularPbkdf2PasswordEncoder` reads Django + (`pbkdf2_sha256$...`), passlib (`$pbkdf2-sha256$...`) and Werkzeug + (`pbkdf2:sha256:...`) hashes, with the digest and the round count taken from + each stored value. Register it under an id of your choosing. +* Any other algorithm is a `PasswordEncoder` you write and register under its + own id. + +[source,java,role=cn1-no-try] +---- +include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/security/IdentityImport.java[tag=backend-security-migration-encoders,indent=0] +---- + +An imported hash is re-encoded with the default encoder the first time its +owner signs in, as <> describes, so the old formats +fade out without a reset. + +==== Importing the records + +The application reads its old store and passes each row to +`IdentityMigration`, which writes it through the server's own stores: + +[source,java,role=cn1-no-try] +---- +include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/security/IdentityImport.java[tag=backend-security-migration-run,indent=0] +---- + +Usernames, authorities, federated subjects, credential ids and user handles +are written exactly as given, so a token's `sub` and every link other systems +hold stay the same. Nothing that already exists is overwritten, so a run can +be repeated after fixing the rows it skipped. A dry run writes nothing and +returns the same report. + +The `MigrationReport` lists every record with one outcome: + +* `MIGRATED`: carried over, and its owner signs in as before. +* `NEEDS_ACTION`: carried over, but its owner has to act. A hash in a format + the encoder can't verify isn't imported; the user is created with a random + password and has to reset it. A passkey whose public key can't be read + has to be registered again. +* `SKIPPED`: not carried over, with the reason. Examples are a user that + already exists, a credential id that's taken, or a user handle that belongs + to someone else. +* `NOT_CARRIED`: state no migration moves, listed in the table below. + +A passkey works on the new server only if it has the same relying-party ID +(the domain) as the old one. That's a rule of WebAuthn, not of this +migration. + +==== Signing keys + +Tokens the old server issued keep verifying if the new server publishes the +same keys under the same `kid`: + +* `cn1.security.authorizationserver.jwk.key-ids` gives each key in + `cn1.security.authorizationserver.jwk.keys` the `kid` it had, in the same + order. An empty entry keeps the derived ID. +* `cn1.security.authorizationserver.jwk.verification-keys` lists keys that + only verify: a public key PEM, or a `.json` JWK Set, which keeps each key's + `kid`. Use it for an old key whose private half stays with the old + provider, and remove it when the last token it signed has expired. A private + key there is refused. + +==== What's kept and what isn't + +[cols="2,1,3", options="header"] +|=== +| State | Kept | Why + +| Password hashes +| Yes +| Verified as stored, re-encoded on the next sign-in. A format no encoder + reads needs a reset. + +| Usernames, subjects, authorities +| Yes +| Written verbatim. + +| Federated identities +| Yes +| Linked by provider and subject. + +| Passkeys +| Yes +| Credential ID, public key, counter and user handle. Same relying-party ID + only. + +| TOTP secrets +| Yes +| Imported confirmed, so authenticator apps keep working. + +| Signing keys +| Yes +| Through `key-ids` and `verification-keys`. + +| Sessions +| No +| Bound to the old server. Every user signs in once. + +| Remembered sign-ins +| No +| Hashed or signed with the old server's secret. + +| Authorization codes +| No +| Single use and short lived. A sign-in in progress starts again. + +| Refresh tokens from the old server +| No +| The old server issued them. Its clients sign the user in again. + +| Recovery codes +| No +| Stored as hashes of the old server's choosing. Users generate new ones. +|=== + [[backend-security-pki]] === Certificates, key stores and key agreement @@ -1727,6 +1921,7 @@ That keeps the binary small, and it has consequences you can see: `cn1.security.user.*` keys are read by `formLogin`, `httpBasic` and `rememberMe` only. +[[backend-security-config-keys]] === Configuration keys Every key here is read when the server starts, from wherever the server's @@ -1788,6 +1983,22 @@ read by the build, because it decides what's linked into the server. | The PEM files of the signing keys, the signing one first. Required outside a development profile. +| `cn1.security.authorizationserver.jwk.key-ids` +| The `kid` of each key in `jwk.keys`, in the same order. An empty entry keeps + the ID derived from the public key. See <>. + +| `cn1.security.authorizationserver.jwk.verification-keys` +| Public keys that verify tokens and never sign: PEM files, or `.json` JWK + Sets that keep each key's `kid`. + +| `cn1.security.oauth2.client.credentials.NAME.grant` +| `client_credentials`, `jwt_bearer` or `google_service_account`, for + `AccessTokenProviders.fromConfig(config, "NAME")`. The other keys under the + same prefix are `token-uri`, `client-id`, `client-secret`, + `client-authentication` (`basic`, `post` or `private_key_jwt`), `key-file`, + `key-id`, `issuer`, `subject`, `audience`, `google-json`, `scopes`, + `refresh-skew` and `max-backoff`. See <>. + | `cn1.security.authorizationserver.device.verificationAttempts`, `cn1.security.authorizationserver.device.verificationWindowSeconds` | How many device codes one user may try, and in how many seconds. 10 and 300 unless set. diff --git a/docs/developer-guide/languagetool-accept.txt b/docs/developer-guide/languagetool-accept.txt index e7ce1f0c8f6..c756b55b4cf 100644 --- a/docs/developer-guide/languagetool-accept.txt +++ b/docs/developer-guide/languagetool-accept.txt @@ -838,3 +838,6 @@ coturn unconfigured Sepolia ntfy +# Identity migration: password hash formats read by ModularPbkdf2PasswordEncoder. +passlib +Werkzeug diff --git a/maven/backend/src/test/java/com/codename1/backend/security/IdentityMigrationTest.java b/maven/backend/src/test/java/com/codename1/backend/security/IdentityMigrationTest.java new file mode 100644 index 00000000000..c0c2d046f85 --- /dev/null +++ b/maven/backend/src/test/java/com/codename1/backend/security/IdentityMigrationTest.java @@ -0,0 +1,323 @@ +/* + * 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.backend.security; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.codename1.backend.Config; +import com.codename1.backend.security.core.userdetails.InMemoryUserDetailsManager; +import com.codename1.backend.security.crypto.BCryptPasswordEncoder; +import com.codename1.backend.security.crypto.DelegatingPasswordEncoder; +import com.codename1.backend.security.crypto.Jwk; +import com.codename1.backend.security.crypto.JwkSet; +import com.codename1.backend.security.crypto.KeyFiles; +import com.codename1.backend.security.crypto.KeyFixtures; +import com.codename1.backend.security.crypto.ModularPbkdf2PasswordEncoder; +import com.codename1.backend.security.crypto.PasswordEncoder; +import com.codename1.backend.security.crypto.Pbkdf2Sha256PasswordEncoder; +import com.codename1.backend.security.mfa.InMemoryTotpRepository; +import com.codename1.backend.security.migration.IdentityMigration; +import com.codename1.backend.security.migration.MigrationReport; +import com.codename1.backend.security.oauth2.client.InMemoryFederatedIdentityRepository; +import com.codename1.backend.security.oauth2.jwt.DefaultJwtDecoder; +import com.codename1.backend.security.oauth2.jwt.DefaultJwtEncoder; +import com.codename1.backend.security.oauth2.jwt.Jwt; +import com.codename1.backend.security.oauth2.jwt.JwtClaimsSet; +import com.codename1.backend.security.oauth2.jwt.JwtEncoderParameters; +import com.codename1.backend.security.oauth2.server.authorization.AuthorizationServerKeys; +import java.io.File; +import java.nio.file.Files; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Properties; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/// Moving another identity store in (#6000): hashes that verify as they were +/// written, subjects and kids that do not change, and a report of the rest. +class IdentityMigrationTest { + // Vectors computed with Python's hashlib, an implementation independent of ours. + private static final String PASSWORD = "correct horse"; + private static final String DJANGO = + "pbkdf2_sha256$1000$seasalt$mQnueSakb748zqBAC1tmWVZsZbi2zPGZarEzTGdfmso="; + private static final String DJANGO_SHA1 = + "pbkdf2_sha1$1000$seasalt$iQvkNOF1wEL4Khh8eogJ8rUhipM="; + private static final String PASSLIB = "$pbkdf2-sha256$1000$AAECAwQFBgcICQoLDA0ODw$" + + "yRTMTwbMbo9G0VfjobWqerzuuxe7BETNTErBbKKumGQ"; + private static final String PASSLIB_512 = "$pbkdf2-sha512$1000$AAECAwQFBgcICQoLDA0ODw$" + + "Xpx07WjVx4vCIvrmBRj8uOoVVtGqJqtUv2J5bhizSQs7osCteF7W4A61dZDqSIqQjO.dxO6p5FT/Uy7QRB" + + "XSXA"; + private static final String WERKZEUG = "pbkdf2:sha256:1000$NaCl1234$09648d88c553b8723dc823d" + + "15e17163d3110a5d75b040381a3557783e98e82ef"; + + @TempDir + File dir; + + private static DelegatingPasswordEncoder passwords() { + Map encoders = new LinkedHashMap(); + encoders.put("pbkdf2-sha256", new Pbkdf2Sha256PasswordEncoder()); + encoders.put("bcrypt", new BCryptPasswordEncoder()); + encoders.put("django", new ModularPbkdf2PasswordEncoder( + ModularPbkdf2PasswordEncoder.Format.DJANGO)); + encoders.put("passlib", new ModularPbkdf2PasswordEncoder( + ModularPbkdf2PasswordEncoder.Format.PASSLIB)); + encoders.put("werkzeug", new ModularPbkdf2PasswordEncoder( + ModularPbkdf2PasswordEncoder.Format.WERKZEUG)); + return new DelegatingPasswordEncoder("pbkdf2-sha256", encoders); + } + + @Test + @DisplayName("Django, passlib and Werkzeug hashes verify as those frameworks wrote them") + void modularFormats() { + String[][] cases = {{"DJANGO", DJANGO}, {"DJANGO", DJANGO_SHA1}, {"PASSLIB", PASSLIB}, + {"PASSLIB", PASSLIB_512}, {"WERKZEUG", WERKZEUG}}; + for (String[] c : cases) { + ModularPbkdf2PasswordEncoder encoder = new ModularPbkdf2PasswordEncoder( + ModularPbkdf2PasswordEncoder.Format.valueOf(c[0])); + assertTrue(encoder.matches(PASSWORD, c[1]), c[1]); + assertFalse(encoder.matches("correct horsE", c[1]), c[1]); + assertTrue(encoder.isReadable(c[1])); + assertTrue(encoder.upgradeEncoding(c[1])); + // Its own output reads back. + encoder.setEncodeRounds(1000); + String fresh = encoder.encode(PASSWORD); + assertTrue(encoder.matches(PASSWORD, fresh), fresh); + } + ModularPbkdf2PasswordEncoder django = new ModularPbkdf2PasswordEncoder( + ModularPbkdf2PasswordEncoder.Format.DJANGO); + // Another format, an unknown digest, a work factor past the bound. + assertFalse(django.isReadable(WERKZEUG)); + assertFalse(django.matches(PASSWORD, "pbkdf2_md5$1000$seasalt$AAAA")); + django.setMaxRounds(999); + assertFalse(django.matches(PASSWORD, DJANGO)); + assertFalse(new ModularPbkdf2PasswordEncoder(ModularPbkdf2PasswordEncoder.Format.WERKZEUG) + .matches(PASSWORD, "pbkdf2:sha256$NaCl1234$09648d")); + } + + @Test + @DisplayName("Imported users sign in with their old passwords, are re-hashed, and keep their names") + void usersEndToEnd() throws Exception { + InMemoryUserDetailsManager users = new InMemoryUserDetailsManager(); + DelegatingPasswordEncoder passwords = passwords(); + InMemoryFederatedIdentityRepository links = new InMemoryFederatedIdentityRepository(); + IdentityMigration migration = new IdentityMigration(users, passwords); + migration.setFederatedIdentities(links); + migration.setPasswordPrefix("django"); + migration.user("Ada.Lovelace@Example.com", DJANGO, Arrays.asList("ROLE_ADMIN", "billing"), + true); + migration.user("grace", "{werkzeug}" + WERKZEUG, Arrays.asList("ROLE_USER"), true); + migration.user("old", "{md4}0123", Arrays.asList("ROLE_USER"), true); + migration.user("disabled", DJANGO, null, false); + migration.user("grace", DJANGO, null, true); + migration.user("social", null, null, true); + migration.federatedIdentity("google", "1093847561", "social"); + migration.federatedIdentity("google", "1093847561", "grace"); + migration.federatedIdentity("github", "77", "nobody"); + MigrationReport report = migration.finish(); + + assertEquals(4, report.count(MigrationReport.Outcome.MIGRATED), report.toString()); + assertEquals(2, report.count(MigrationReport.Outcome.NEEDS_ACTION), report.toString()); + assertEquals(3, report.count(MigrationReport.Outcome.SKIPPED), report.toString()); + assertEquals(5, report.count(MigrationReport.Outcome.NOT_CARRIED)); + assertFalse(report.isComplete()); + assertTrue(report.toString().contains("old: the password hash is in a format"), + report.toString()); + // Nothing secret is in the report. + assertFalse(report.toString().contains("seasalt")); + + DaoAuthenticationProvider provider = new DaoAuthenticationProvider(users); + provider.setPasswordEncoder(passwords); + provider.setUserDetailsPasswordService(users); + ProviderManager manager = new ProviderManager(provider); + Authentication ada = manager.authenticate(UsernamePasswordAuthenticationToken + .unauthenticated("Ada.Lovelace@Example.com", PASSWORD)); + assertEquals("Ada.Lovelace@Example.com", ada.getName()); + assertTrue(ada.getAuthorities().toString().contains("ROLE_ADMIN")); + assertTrue(ada.getAuthorities().toString().contains("billing")); + assertTrue(users.loadUserByUsername("Ada.Lovelace@Example.com").getPassword() + .startsWith("{pbkdf2-sha256}")); + manager.authenticate(UsernamePasswordAuthenticationToken.unauthenticated("grace", PASSWORD)); + assertThrows(AuthenticationException.class, () -> manager.authenticate( + UsernamePasswordAuthenticationToken.unauthenticated("disabled", PASSWORD))); + assertThrows(AuthenticationException.class, () -> manager.authenticate( + UsernamePasswordAuthenticationToken.unauthenticated("old", "0123"))); + assertEquals("social", links.findUsername("google", "1093847561")); + + // A second run over the same rows writes nothing and skips them all. + IdentityMigration again = new IdentityMigration(users, passwords); + again.user("grace", DJANGO, null, true); + assertEquals(1, again.finish().count(MigrationReport.Outcome.SKIPPED)); + } + + @Test + @DisplayName("A dry run reports what a run would do and writes nothing") + void dryRun() { + InMemoryUserDetailsManager users = new InMemoryUserDetailsManager(); + IdentityMigration migration = new IdentityMigration(users, passwords()); + migration.setDryRun(true); + InMemoryTotpRepository totp = new InMemoryTotpRepository(); + migration.setTotp(totp); + migration.user("ada", "{django}" + DJANGO, null, true); + migration.totp("ada", new byte[20]); + migration.totp("ada", new byte[4]); + MigrationReport report = migration.finish(); + assertTrue(report.isDryRun()); + assertEquals(2, report.count(MigrationReport.Outcome.MIGRATED), report.toString()); + assertEquals(1, report.count(MigrationReport.Outcome.NEEDS_ACTION)); + assertFalse(users.userExists("ada")); + assertEquals(null, totp.find("ada")); + assertThrows(IllegalStateException.class, () -> { + IdentityMigration late = new IdentityMigration(users, passwords()); + late.user("x", null, null, true); + late.setDryRun(true); + }); + } + + @Test + @DisplayName("TOTP secrets move confirmed, so the authenticator app keeps working") + void totp() { + InMemoryUserDetailsManager users = new InMemoryUserDetailsManager(); + InMemoryTotpRepository totp = new InMemoryTotpRepository(); + IdentityMigration migration = new IdentityMigration(users, passwords()); + migration.setTotp(totp); + migration.user("ada", "{django}" + DJANGO, null, true); + byte[] secret = "12345678901234567890".getBytes(); + migration.totp("ada", secret); + migration.totp("nobody", secret); + MigrationReport report = migration.finish(); + assertEquals(1, report.count(MigrationReport.Outcome.SKIPPED)); + assertNotNull(totp.find("ada")); + assertTrue(totp.find("ada").isConfirmed()); + assertTrue(Arrays.equals(secret, totp.find("ada").getSecret())); + } + + @Test + @DisplayName("Signing keys keep their kid, and a retired key still verifies what it signed") + void signingKeysKeepTheirKid() throws Exception { + // The old server signed with this key, under its own kid. + Jwk oldKey = Jwk.ofPrivateKey(KeyFiles.privateKey(KeyFixtures.RSA_PKCS8_PEM)) + .withKeyId("legacy-2024"); + long now = System.currentTimeMillis() / 1000L; + String oldToken = new DefaultJwtEncoder(JwkSet.of(new Jwk[] {oldKey})).encode( + JwtEncoderParameters.from(JwtClaimsSet.builder().subject("ada").expiresAt(now + 300) + .build())).getTokenValue(); + + File rsa = new File(dir, "rsa.pem"); + Files.write(rsa.toPath(), KeyFixtures.RSA_PKCS8_PEM.getBytes("US-ASCII")); + Properties p = new Properties(); + p.setProperty(AuthorizationServerKeys.KEYS, rsa.getPath()); + p.setProperty(AuthorizationServerKeys.KEY_IDS, "legacy-2024"); + List keys = AuthorizationServerKeys.load(Config.of(p, "prod")).getKeys(); + assertEquals("legacy-2024", keys.get(0).getKeyId()); + Jwt decoded = DefaultJwtDecoder.withJwkSource(AuthorizationServerKeys.of(keys)).build() + .decode(oldToken); + assertEquals("ada", decoded.getSubject()); + + // Rotated: a new key signs, the old one's public JWK Set only verifies. + File ec = new File(dir, "ec.pem"); + Files.write(ec.toPath(), KeyFixtures.EC256_PKCS8_PEM.getBytes("US-ASCII")); + File oldSet = new File(dir, "old-jwks.json"); + Files.write(oldSet.toPath(), ("{\"keys\":[" + publicJwk(oldKey) + "]}").getBytes("UTF-8")); + Properties rotated = new Properties(); + rotated.setProperty(AuthorizationServerKeys.KEYS, ec.getPath()); + rotated.setProperty(AuthorizationServerKeys.VERIFICATION_KEYS, oldSet.getPath()); + List after = AuthorizationServerKeys.load(Config.of(rotated, "prod")).getKeys(); + assertEquals(2, after.size()); + assertTrue(after.get(0).isPrivate()); + assertFalse(after.get(1).isPrivate()); + assertEquals("legacy-2024", after.get(1).getKeyId()); + assertEquals("ada", DefaultJwtDecoder.withJwkSource(AuthorizationServerKeys.of(after)) + .jwsAlgorithms(new com.codename1.backend.security.oauth2.jose.jws.JwsAlgorithm[] { + com.codename1.backend.security.oauth2.jose.jws.SignatureAlgorithm.RS256, + com.codename1.backend.security.oauth2.jose.jws.SignatureAlgorithm.ES256}) + .build().decode(oldToken).getSubject()); + // A private key is not a verification key. + rotated.setProperty(AuthorizationServerKeys.VERIFICATION_KEYS, rsa.getPath()); + assertThrows(IllegalStateException.class, + () -> AuthorizationServerKeys.load(Config.of(rotated, "prod"))); + // A key the set declares for encryption never verifies. + Files.write(oldSet.toPath(), ("{\"keys\":[" + publicJwk(oldKey.withUse("enc")) + "]}") + .getBytes("UTF-8")); + rotated.setProperty(AuthorizationServerKeys.VERIFICATION_KEYS, oldSet.getPath()); + assertEquals(1, AuthorizationServerKeys.load(Config.of(rotated, "prod")).getKeys().size()); + } + + @Test + @DisplayName("A name that differs only in case attaches nothing to the other account") + void caseCollision() { + InMemoryUserDetailsManager users = new InMemoryUserDetailsManager(); + InMemoryFederatedIdentityRepository links = new InMemoryFederatedIdentityRepository(); + InMemoryTotpRepository totp = new InMemoryTotpRepository(); + IdentityMigration migration = new IdentityMigration(users, passwords()); + migration.setFederatedIdentities(links); + migration.setTotp(totp); + migration.user("admin", "{django}" + DJANGO, Arrays.asList("ROLE_ADMIN"), true); + migration.user("Admin", "{django}" + DJANGO, Arrays.asList("ROLE_USER"), true); + migration.federatedIdentity("google", "attacker", "Admin"); + migration.totp("Admin", "12345678901234567890".getBytes()); + MigrationReport report = migration.finish(); + assertEquals(1, report.count(MigrationReport.Outcome.MIGRATED), report.toString()); + assertEquals(3, report.count(MigrationReport.Outcome.SKIPPED), report.toString()); + assertTrue(report.toString().contains("differs only in case"), report.toString()); + assertEquals(null, links.findUsername("google", "attacker")); + assertEquals(null, totp.find("admin")); + + // The same against a user that was already there. + IdentityMigration later = new IdentityMigration(users, passwords()); + later.setFederatedIdentities(links); + later.federatedIdentity("google", "attacker", "ADMIN"); + assertEquals(1, later.finish().count(MigrationReport.Outcome.SKIPPED)); + assertEquals(null, links.findUsername("google", "attacker")); + + // And in a dry run, where nothing is created to collide with. + IdentityMigration dry = new IdentityMigration(new InMemoryUserDetailsManager(), passwords()); + dry.setDryRun(true); + dry.user("admin", null, null, true); + dry.user("Admin", null, null, true); + assertEquals(1, dry.finish().count(MigrationReport.Outcome.SKIPPED)); + } + + @Test + @DisplayName("A hash in a format the id's encoder cannot read needs a reset, not a silent import") + void unreadableFormatUnderAKnownId() { + InMemoryUserDetailsManager users = new InMemoryUserDetailsManager(); + IdentityMigration migration = new IdentityMigration(users, passwords()); + migration.setPasswordPrefix("werkzeug"); + migration.user("flask", "scrypt:32768:8:1$salt$00ff", null, true); + migration.user("django", "{django}argon2$argon2id$v=19$m=102400,t=2,p=8$c2FsdA$aGFzaA", null, + true); + MigrationReport report = migration.finish(); + assertEquals(2, report.count(MigrationReport.Outcome.NEEDS_ACTION), report.toString()); + } + + private static String publicJwk(Jwk key) { + return com.codename1.backend.Json.write(key.toPublicJson()); + } +} diff --git a/maven/backend/src/test/java/com/codename1/backend/security/oauth2/client/credentials/AccessTokenProviderTest.java b/maven/backend/src/test/java/com/codename1/backend/security/oauth2/client/credentials/AccessTokenProviderTest.java new file mode 100644 index 00000000000..9e6d29ea2f4 --- /dev/null +++ b/maven/backend/src/test/java/com/codename1/backend/security/oauth2/client/credentials/AccessTokenProviderTest.java @@ -0,0 +1,470 @@ +/* + * 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.backend.security.oauth2.client.credentials; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.codename1.backend.Base64; +import com.codename1.backend.Config; +import com.codename1.backend.Json; +import com.codename1.backend.security.Clock; +import com.codename1.backend.security.crypto.Jwk; +import com.codename1.backend.security.crypto.JwkSet; +import com.codename1.backend.security.crypto.KeyFiles; +import com.codename1.backend.security.crypto.KeyFixtures; +import com.codename1.backend.security.oauth2.core.OAuth2Parameters; +import com.codename1.backend.security.oauth2.jwt.DefaultJwtDecoder; +import com.codename1.backend.security.oauth2.jwt.Jwt; +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Properties; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class AccessTokenProviderTest { + private static final String TOKEN_URI = "https://auth.example.com/oauth/token"; + private static final String SECRET = "s3cr3t-never-printed"; + + /// A token endpoint in process: records what was posted, answers what it is told. + static final class FakeEndpoint implements TokenEndpoint.Transport { + final List forms = Collections.synchronizedList(new ArrayList()); + final List> headers = Collections.synchronizedList(new ArrayList>()); + final AtomicInteger calls = new AtomicInteger(); + final AtomicReference answer = new AtomicReference(); + volatile CountDownLatch gate; + volatile IOException unreachable; + + FakeEndpoint answering(int status, String body) { + answer.set(new TokenEndpoint.Response(status, body)); + return this; + } + + @Override + public TokenEndpoint.Response post(String uri, List h, String form) throws IOException { + int n = calls.incrementAndGet(); + forms.add(form); + headers.add(h); + CountDownLatch g = gate; + if (g != null) { + try { + g.await(10, TimeUnit.SECONDS); + } catch (InterruptedException err) { + Thread.currentThread().interrupt(); + } + } + if (unreachable != null) { + throw unreachable; + } + TokenEndpoint.Response r = answer.get(); + if (r == null) { + return new TokenEndpoint.Response(200, "{\"access_token\":\"tok-" + n + + "\",\"token_type\":\"Bearer\",\"expires_in\":3600}"); + } + return r; + } + } + + static final class FakeClock implements Clock { + final AtomicLong now = new AtomicLong(System.currentTimeMillis()); + + @Override + public long currentTimeMillis() { + return now.get(); + } + + void advanceSeconds(long seconds) { + now.addAndGet(seconds * 1000L); + } + } + + private static Jwk rsaKey() throws IOException { + return Jwk.ofPrivateKey(KeyFiles.privateKey(KeyFixtures.RSA_PKCS8_PEM)).withKeyId("key-1"); + } + + /// A decoder that knows the public key under the id the assertion names. + static DefaultJwtDecoder verifier(String publicPem, String keyId) throws IOException { + final List keys = JwkSet.of(Jwk.ofPublicKey(KeyFiles.publicKey(publicPem)).withKeyId(keyId)) + .getKeys(); + return DefaultJwtDecoder.withJwkSource(() -> keys).build(); + } + + private static Map form(String body) { + return OAuth2Parameters.parse(body); + } + + @Test + @DisplayName("client_secret_basic sends the secret in a header, form-encoded, and never in the form") + void clientCredentialsBasic() { + FakeEndpoint endpoint = new FakeEndpoint(); + AccessToken token = ClientCredentialsGrant.builder(TOKEN_URI, "orders svc") + .clientSecret("p@ss:word").scopes("a", "b").audience("https://api.example.com") + .transport(endpoint).build().fetch(); + assertEquals("tok-1", token.getTokenValue()); + Map sent = form(endpoint.forms.get(0)); + assertEquals("client_credentials", sent.get("grant_type")); + assertEquals("a b", sent.get("scope")); + assertEquals("https://api.example.com", sent.get("audience")); + assertFalse(sent.containsKey("client_secret")); + String auth = null; + for (String h : endpoint.headers.get(0)) { + if (h.startsWith("Authorization: Basic ")) { + auth = new String(Base64.decode(h.substring(21)), StandardCharsets.UTF_8); + } + } + // RFC 6749 2.3.1: each half form-encoded before joining. + assertEquals("orders%20svc:p%40ss%3Aword", auth); + assertTrue(endpoint.headers.get(0).contains("Content-Type: application/x-www-form-urlencoded")); + } + + @Test + @DisplayName("client_secret_post puts the id and secret in the form") + void clientCredentialsPost() { + FakeEndpoint endpoint = new FakeEndpoint(); + ClientCredentialsGrant.builder(TOKEN_URI, "orders").clientSecret(SECRET, + ClientCredentialsGrant.Authentication.CLIENT_SECRET_POST).transport(endpoint) + .build().fetch(); + Map sent = form(endpoint.forms.get(0)); + assertEquals("orders", sent.get("client_id")); + assertEquals(SECRET, sent.get("client_secret")); + for (String h : endpoint.headers.get(0)) { + assertFalse(h.startsWith("Authorization"), h); + } + } + + @Test + @DisplayName("private_key_jwt sends a client assertion that verifies against the public key") + void privateKeyJwt() throws IOException { + FakeEndpoint endpoint = new FakeEndpoint(); + FakeClock clock = new FakeClock(); + ClientCredentialsGrant.builder(TOKEN_URI, "orders").privateKeyJwt(rsaKey()) + .transport(endpoint).clock(clock).build().fetch(); + Map sent = form(endpoint.forms.get(0)); + assertEquals(ClientCredentialsGrant.JWT_BEARER_ASSERTION_TYPE, sent.get("client_assertion_type")); + Jwt assertion = verifier(KeyFixtures.RSA_PUBLIC_PEM, "key-1").decode(sent.get("client_assertion")); + assertEquals("orders", assertion.getIssuer()); + assertEquals("orders", assertion.getSubject()); + assertEquals(Arrays.asList(TOKEN_URI), assertion.getClaimAsStringList("aud")); + assertEquals("key-1", assertion.getHeaders().get("kid")); + assertEquals("RS256", assertion.getHeaders().get("alg")); + assertNotNull(assertion.getClaimAsString("jti")); + } + + @Test + @DisplayName("the JWT bearer assertion carries the claims, scopes and lifetime, signed with ES256 or RS256") + void jwtBearerAssertion() throws IOException { + for (String[] key : new String[][] {{KeyFixtures.RSA_PKCS8_PEM, KeyFixtures.RSA_PUBLIC_PEM, "RS256"}, + {KeyFixtures.EC256_PKCS8_PEM, KeyFixtures.EC256_PUBLIC_PEM, "ES256"}}) { + FakeEndpoint endpoint = new FakeEndpoint(); + FakeClock clock = new FakeClock(); + JwtBearerGrant.builder(TOKEN_URI, "svc@example.iam", Jwk.ofPrivateKey(KeyFiles.privateKey(key[0]))) + .subject("ada@example.com").scopes("s1", "s2").scopesInAssertion(true) + .transport(endpoint).clock(clock).build().fetch(); + Map sent = form(endpoint.forms.get(0)); + assertEquals(JwtBearerGrant.GRANT_TYPE, sent.get("grant_type")); + assertFalse(sent.containsKey("scope"), "scopes asked to go in the assertion"); + Jwt jwt = DefaultJwtDecoder.withPublicKey(KeyFiles.publicKey(key[1])).build() + .decode(sent.get("assertion")); + assertEquals(key[2], jwt.getHeaders().get("alg")); + assertEquals("svc@example.iam", jwt.getIssuer()); + assertEquals("ada@example.com", jwt.getSubject()); + assertEquals("s1 s2", jwt.getClaimAsString("scope")); + long iat = jwt.getClaimAsLong("iat").longValue(); + assertEquals(clock.now.get() / 1000L, iat); + assertEquals(iat + 3600, jwt.getClaimAsLong("exp").longValue()); + } + FakeEndpoint endpoint = new FakeEndpoint(); + JwtBearerGrant.builder(TOKEN_URI, "svc", rsaKey()).scopes("s1") + .transport(endpoint).build().fetch(); + // RFC 7521 section 4.1: by default the scope is a form parameter. + assertEquals("s1", form(endpoint.forms.get(0)).get("scope")); + assertEquals(null, verifier(KeyFixtures.RSA_PUBLIC_PEM, "key-1").decode( + form(endpoint.forms.get(0)).get("assertion")).getClaimAsString("scope")); + } + + @Test + @DisplayName("a Google service-account file becomes a JWT bearer grant against its token_uri, with its key id") + void googleServiceAccount() throws IOException { + Map file = new LinkedHashMap(); + file.put("type", "service_account"); + file.put("project_id", "demo-project"); + file.put("private_key_id", "abc123"); + file.put("private_key", KeyFixtures.RSA_PKCS8_PEM); + file.put("client_email", "svc@demo-project.iam.gserviceaccount.com"); + file.put("token_uri", "https://oauth2.googleapis.com/token"); + GoogleServiceAccountCredentials account = GoogleServiceAccountCredentials.fromJson(Json.write(file)); + assertEquals("demo-project", account.getProjectId()); + assertFalse(account.toString().contains("PRIVATE")); + FakeEndpoint endpoint = new FakeEndpoint(); + account.builder().scopes("https://www.googleapis.com/auth/cloud-platform").transport(endpoint) + .build().fetch(); + Jwt jwt = verifier(KeyFixtures.RSA_PUBLIC_PEM, "abc123") + .decode(form(endpoint.forms.get(0)).get("assertion")); + assertEquals("abc123", jwt.getHeaders().get("kid")); + assertEquals("svc@demo-project.iam.gserviceaccount.com", jwt.getIssuer()); + assertEquals(Arrays.asList("https://oauth2.googleapis.com/token"), jwt.getClaimAsStringList("aud")); + + file.put("type", "authorized_user"); + IOException refused = assertThrows(IOException.class, + () -> GoogleServiceAccountCredentials.fromJson(Json.write(file))); + assertTrue(refused.getMessage().contains("authorized_user")); + file.put("type", "service_account"); + file.put("private_key", "-----BEGIN PRIVATE KEY-----\nnot a key\n-----END PRIVATE KEY-----\n"); + refused = assertThrows(IOException.class, + () -> GoogleServiceAccountCredentials.fromJson(Json.write(file))); + assertFalse(refused.getMessage().contains("not a key"), refused.getMessage()); + } + + @Test + @DisplayName("the token is cached until the skew before expiry, then refreshed once") + void refreshTiming() { + FakeEndpoint endpoint = new FakeEndpoint(); + FakeClock clock = new FakeClock(); + AccessTokenProvider provider = new AccessTokenProvider(ClientCredentialsGrant.builder(TOKEN_URI, "c") + .clientSecret(SECRET).transport(endpoint).clock(clock).build()); + provider.setClock(clock); + assertEquals("tok-1", provider.getTokenValue()); + clock.advanceSeconds(3600 - 61); + assertEquals("tok-1", provider.getTokenValue()); + assertEquals(1, endpoint.calls.get()); + clock.advanceSeconds(2); + assertEquals("tok-2", provider.getTokenValue()); + assertEquals(2, endpoint.calls.get()); + provider.invalidate(); + assertEquals("tok-3", provider.getTokenValue()); + + // No expires_in: the conservative default, not forever. + endpoint.answering(200, "{\"access_token\":\"short\",\"token_type\":\"Bearer\"}"); + provider.invalidate(); + AccessToken t = provider.getToken(); + assertEquals(clock.now.get() + TokenEndpoint.DEFAULT_LIFETIME_SECONDS * 1000L, t.getExpiresAtMillis()); + } + + @Test + @DisplayName("concurrent callers share one exchange") + void concurrentCallersShareOneRefresh() throws Exception { + FakeEndpoint endpoint = new FakeEndpoint(); + endpoint.gate = new CountDownLatch(1); + final AccessTokenProvider provider = new AccessTokenProvider(ClientCredentialsGrant + .builder(TOKEN_URI, "c").clientSecret(SECRET).transport(endpoint).build()); + final List seen = Collections.synchronizedList(new ArrayList()); + List threads = new ArrayList(); + for (int i = 0; i < 16; i++) { + Thread t = new Thread(() -> seen.add(provider.getTokenValue())); + threads.add(t); + t.start(); + } + Thread.sleep(200); + endpoint.gate.countDown(); + for (Thread t : threads) { + t.join(10000); + } + assertEquals(1, endpoint.calls.get()); + assertEquals(16, seen.size()); + for (String s : seen) { + assertEquals("tok-1", s); + } + } + + @Test + @DisplayName("a failed refresh never hands out an expired token, and backs off before trying again") + void failedRefreshAndBackoff() { + FakeEndpoint endpoint = new FakeEndpoint(); + FakeClock clock = new FakeClock(); + AccessTokenProvider provider = new AccessTokenProvider(ClientCredentialsGrant.builder(TOKEN_URI, "c") + .clientSecret(SECRET).transport(endpoint).clock(clock).build()); + provider.setClock(clock); + assertEquals("tok-1", provider.getTokenValue()); + + // Inside the skew, the endpoint down: the token still valid is served. + endpoint.answering(503, "{\"error\":\"temporarily_unavailable\"}"); + clock.advanceSeconds(3600 - 30); + assertEquals("tok-1", provider.getTokenValue()); + assertEquals(2, endpoint.calls.get()); + assertEquals(1, provider.getConsecutiveFailures()); + // Backing off: no new exchange. + assertEquals("tok-1", provider.getTokenValue()); + assertEquals(2, endpoint.calls.get()); + + // Expired: an exception, never tok-1. + clock.advanceSeconds(31); + TokenAcquisitionException err = assertThrows(TokenAcquisitionException.class, provider::getToken); + assertTrue(err.isRetryable()); + assertEquals(503, err.getStatus()); + + // Backoff grows; the first success resets it. + assertEquals(2, provider.getConsecutiveFailures()); + int before = endpoint.calls.get(); + assertThrows(TokenAcquisitionException.class, provider::getToken); + assertEquals(before, endpoint.calls.get(), "inside the backoff no exchange is made"); + endpoint.answer.set(null); + clock.advanceSeconds(120); + assertNotNull(provider.getToken()); + assertEquals(0, provider.getConsecutiveFailures()); + } + + @Test + @DisplayName("a refused credential is not retryable, and a 429 or an unreachable endpoint is") + void retryClassification() { + String[][] cases = {{"400", "{\"error\":\"invalid_grant\"}", "false", "invalid_grant"}, + {"401", "{\"error\":\"invalid_client\"}", "false", "invalid_client"}, + {"429", "{}", "true", null}, {"500", "oops", "true", null}}; + for (String[] c : cases) { + FakeEndpoint endpoint = new FakeEndpoint().answering(Integer.parseInt(c[0]), c[1]); + TokenAcquisitionException err = assertThrows(TokenAcquisitionException.class, + () -> ClientCredentialsGrant.builder(TOKEN_URI, "c").clientSecret(SECRET) + .transport(endpoint).build().fetch()); + assertEquals(Boolean.parseBoolean(c[2]), err.isRetryable(), c[0]); + assertEquals(c[3], err.getErrorCode()); + } + FakeEndpoint down = new FakeEndpoint(); + down.unreachable = new IOException("connection refused"); + TokenAcquisitionException err = assertThrows(TokenAcquisitionException.class, + () -> ClientCredentialsGrant.builder(TOKEN_URI, "c").clientSecret(SECRET) + .transport(down).build().fetch()); + assertTrue(err.isRetryable()); + assertEquals(-1, err.getStatus()); + } + + @Test + @DisplayName("no secret, assertion or token body appears in an exception or a toString") + void diagnosticsAreSanitized() throws IOException { + String leakyBody = "{\"error\":\"invalid_client\",\"error_description\":\"bad " + SECRET + + "\",\"access_token\":\"leaked-token\"}"; + FakeEndpoint endpoint = new FakeEndpoint().answering(401, leakyBody); + ClientCredentialsGrant grant = ClientCredentialsGrant.builder( + "https://user:pw@auth.example.com/token?key=" + SECRET, "c").clientSecret(SECRET) + .transport(endpoint).build(); + AccessTokenProvider provider = new AccessTokenProvider(grant); + TokenAcquisitionException err = assertThrows(TokenAcquisitionException.class, provider::getToken); + String everything = err.getMessage() + " " + err + " " + provider + " " + grant; + for (String secret : new String[] {SECRET, "leaked-token", "pw@", "error_description"}) { + assertFalse(everything.contains(secret), secret + " in: " + everything); + } + assertTrue(err.getMessage().contains("https://auth.example.com/token"), err.getMessage()); + + AccessToken token = new AccessToken("the-value", "Bearer", 0, null); + assertFalse(token.toString().contains("the-value")); + assertEquals("Bearer the-value", token.toAuthorizationHeader()); + + FakeEndpoint jwt = new FakeEndpoint().answering(400, "{\"error\":\"invalid_grant\"}"); + JwtBearerGrant bearer = JwtBearerGrant.builder(TOKEN_URI, "svc", rsaKey()).transport(jwt).build(); + err = assertThrows(TokenAcquisitionException.class, bearer::fetch); + String assertion = form(jwt.forms.get(0)).get("assertion"); + assertFalse((err.getMessage() + bearer).contains(assertion.substring(0, 20))); + } + + @Test + @DisplayName("configuration builds each grant, and names a missing key without printing values") + void fromConfig() throws IOException { + File pem = File.createTempFile("cn1-cc", ".pem"); + pem.deleteOnExit(); + Files.write(pem.toPath(), KeyFixtures.RSA_PKCS8_PEM.getBytes(StandardCharsets.US_ASCII)); + Properties p = new Properties(); + String k = AccessTokenProviders.PREFIX; + p.setProperty(k + "billing.grant", "client_credentials"); + p.setProperty(k + "billing.token-uri", TOKEN_URI); + p.setProperty(k + "billing.client-id", "orders"); + p.setProperty(k + "billing.client-secret", SECRET); + p.setProperty(k + "billing.scopes", "a,b"); + p.setProperty(k + "signer.grant", "jwt_bearer"); + p.setProperty(k + "signer.token-uri", TOKEN_URI); + p.setProperty(k + "signer.issuer", "svc"); + p.setProperty(k + "signer.key-file", pem.getPath()); + p.setProperty(k + "signer.key-id", "kid-9"); + p.setProperty(k + "broken.grant", "client_credentials"); + p.setProperty(k + "broken.token-uri", TOKEN_URI); + p.setProperty(k + "broken.client-id", "x"); + Config config = Config.of(p, "prod"); + assertTrue(AccessTokenProviders.fromConfig(config, "billing").toString().contains("CLIENT_SECRET_BASIC")); + assertTrue(AccessTokenProviders.fromConfig(config, "signer").toString().contains("iss=svc")); + IOException missing = assertThrows(IOException.class, + () -> AccessTokenProviders.fromConfig(config, "broken")); + assertEquals(k + "broken.client-secret is not set", missing.getMessage()); + assertThrows(IOException.class, () -> AccessTokenProviders.fromConfig(config, "absent")); + } + + @Test + @DisplayName("a grant that throws something else is reported without its message") + void foreignFailure() { + AccessTokenProvider provider = new AccessTokenProvider(() -> { + throw new IllegalStateException(SECRET); + }); + TokenAcquisitionException err = assertThrows(TokenAcquisitionException.class, provider::getToken); + assertFalse(err.getMessage().contains(SECRET)); + // Not even as the cause, which a stack trace would print. + assertEquals(null, err.getCause()); + } + + @Test + @DisplayName("an Error from the grant is rethrown and does not leave the provider waiting on it") + void errorDoesNotWedge() { + final int[] calls = {0}; + FakeClock clock = new FakeClock(); + AccessTokenProvider provider = new AccessTokenProvider(() -> { + if (calls[0]++ == 0) { + throw new StackOverflowError(); + } + return new AccessToken("t", "Bearer", clock.now.get() + 3600000L, null); + }); + provider.setClock(clock); + assertThrows(StackOverflowError.class, provider::getToken); + clock.advanceSeconds(10); + assertEquals("t", provider.getTokenValue()); + } + + @Test + @DisplayName("a token living no longer than the skew is reused for half its life") + void shortLivedToken() { + final int[] calls = {0}; + FakeClock clock = new FakeClock(); + AccessTokenProvider provider = new AccessTokenProvider(() -> { + calls[0]++; + return new AccessToken("t" + calls[0], "Bearer", clock.now.get() + 60000L, null); + }); + provider.setClock(clock); + provider.getToken(); + clock.advanceSeconds(29); + assertEquals("t1", provider.getTokenValue()); + clock.advanceSeconds(2); + assertEquals("t2", provider.getTokenValue()); + assertEquals(2, calls[0]); + } +} diff --git a/maven/backend/src/test/java/com/codename1/backend/security/webauthn/PasskeyMigrationTest.java b/maven/backend/src/test/java/com/codename1/backend/security/webauthn/PasskeyMigrationTest.java new file mode 100644 index 00000000000..fcfed00a7ec --- /dev/null +++ b/maven/backend/src/test/java/com/codename1/backend/security/webauthn/PasskeyMigrationTest.java @@ -0,0 +1,94 @@ +/* + * 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.backend.security.webauthn; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; + +import com.codename1.backend.Json; +import com.codename1.backend.security.core.userdetails.InMemoryUserDetailsManager; +import com.codename1.backend.security.crypto.DelegatingPasswordEncoder; +import com.codename1.backend.security.crypto.PasswordEncoder; +import com.codename1.backend.security.crypto.Pbkdf2Sha256PasswordEncoder; +import com.codename1.backend.security.migration.IdentityMigration; +import com.codename1.backend.security.migration.LegacyPasskey; +import com.codename1.backend.security.migration.MigrationReport; +import java.util.Arrays; +import java.util.Collections; +import java.util.Map; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/// A passkey registered with another server signs in here once imported: the +/// authenticator keeps its key, its credential id and the user handle it answers with. +class PasskeyMigrationTest { + @Test + @DisplayName("Imported passkeys answer a challenge from this server; broken ones are reported") + void importedPasskeysSignIn() throws Exception { + InMemoryUserDetailsManager users = new InMemoryUserDetailsManager(); + InMemoryPublicKeyCredentialUserEntityRepository entities = + new InMemoryPublicKeyCredentialUserEntityRepository(); + InMemoryUserCredentialRepository credentials = new InMemoryUserCredentialRepository(); + IdentityMigration migration = new IdentityMigration(users, new DelegatingPasswordEncoder( + "pbkdf2-sha256", Collections.singletonMap("pbkdf2-sha256", + new Pbkdf2Sha256PasswordEncoder()))); + migration.setPasskeys(entities, credentials); + migration.user("ada", null, Arrays.asList("ROLE_USER"), true); + + // The authenticators as the old server saw them: a key, an id, a handle, a count. + byte[] handle = SoftAuthenticator.random(16); + SoftAuthenticator ec = new SoftAuthenticator(false, "example.org", "https://example.org"); + SoftAuthenticator rsa = new SoftAuthenticator(true, "example.org", "https://example.org"); + for (SoftAuthenticator a : new SoftAuthenticator[] {ec, rsa}) { + a.userHandle = handle; + a.counter = 41; + migration.passkey(new LegacyPasskey("ada", handle, a.credentialId, + SoftAuthenticator.cbor(a.coseKey(), false)).signatureCount(41) + .transports(a.transports).label("old")); + } + migration.passkey(new LegacyPasskey("ada", handle, ec.credentialId, + SoftAuthenticator.cbor(ec.coseKey(), false))); + migration.passkey(new LegacyPasskey("ada", SoftAuthenticator.random(16), + SoftAuthenticator.random(32), SoftAuthenticator.cbor(ec.coseKey(), false))); + migration.passkey(new LegacyPasskey("ada", handle, SoftAuthenticator.random(32), + new byte[] {1, 2, 3})); + migration.passkey(new LegacyPasskey("nobody", handle, SoftAuthenticator.random(32), + SoftAuthenticator.cbor(ec.coseKey(), false))); + MigrationReport report = migration.finish(); + // ada (no password), the corrupt key -> action; duplicate, other handle, nobody -> skipped + assertEquals(2, report.count(MigrationReport.Outcome.MIGRATED), report.toString()); + assertEquals(2, report.count(MigrationReport.Outcome.NEEDS_ACTION), report.toString()); + assertEquals(3, report.count(MigrationReport.Outcome.SKIPPED), report.toString()); + + WebAuthnRelyingPartyOperations ops = new WebAuthnRelyingPartyOperations( + new PublicKeyCredentialRpEntity("example.org", "Example"), + Arrays.asList("https://example.org"), entities, credentials); + for (SoftAuthenticator a : new SoftAuthenticator[] {ec, rsa}) { + PublicKeyCredentialRequestOptions request = ops.createCredentialRequestOptions(null); + Map answer = Json.parseObject(Json.write(a.get(request.toMap()))); + assertEquals("ada", ops.authenticate(request, answer).getUser().getName()); + assertEquals(42, credentials.findByCredentialId(a.credentialId).getSignatureCount()); + } + assertArrayEquals(handle, entities.findByUsername("ada").getId()); + } +} diff --git a/vm/backend/demo/authcheck/com/demo/AuthCheck.java b/vm/backend/demo/authcheck/com/demo/AuthCheck.java index 6d0c4205b58..818bf3240c6 100644 --- a/vm/backend/demo/authcheck/com/demo/AuthCheck.java +++ b/vm/backend/demo/authcheck/com/demo/AuthCheck.java @@ -51,6 +51,7 @@ public static void main(String[] args) throws Exception { signatures(); aesGcm(); AuthCheckTokens.run(); + AuthCheckCredentials.run(); AuthCheckPki.run(); AuthCheckServer.run(); System.out.println("passed=" + passed + " failed=" + failures.size()); diff --git a/vm/backend/demo/authcheck/com/demo/AuthCheckCredentials.java b/vm/backend/demo/authcheck/com/demo/AuthCheckCredentials.java new file mode 100644 index 00000000000..89570b9df4b --- /dev/null +++ b/vm/backend/demo/authcheck/com/demo/AuthCheckCredentials.java @@ -0,0 +1,270 @@ +/* + * 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.demo; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import com.codename1.backend.Base64; +import com.codename1.backend.security.Clock; +import com.codename1.backend.security.core.userdetails.InMemoryUserDetailsManager; +import com.codename1.backend.security.crypto.DelegatingPasswordEncoder; +import com.codename1.backend.security.crypto.Jwk; +import com.codename1.backend.security.crypto.JwkSet; +import com.codename1.backend.security.crypto.JwkSource; +import com.codename1.backend.security.crypto.KeyFiles; +import com.codename1.backend.security.crypto.ModularPbkdf2PasswordEncoder; +import com.codename1.backend.security.crypto.Pbkdf2Sha256PasswordEncoder; +import com.codename1.backend.security.migration.IdentityMigration; +import com.codename1.backend.security.migration.MigrationReport; +import com.codename1.backend.security.oauth2.client.credentials.AccessTokenProvider; +import com.codename1.backend.security.oauth2.client.credentials.ClientCredentialsGrant; +import com.codename1.backend.security.oauth2.client.credentials.GoogleServiceAccountCredentials; +import com.codename1.backend.security.oauth2.client.credentials.JwtBearerGrant; +import com.codename1.backend.security.oauth2.client.credentials.TokenAcquisitionException; +import com.codename1.backend.security.oauth2.client.credentials.TokenEndpoint; +import com.codename1.backend.security.oauth2.core.OAuth2Parameters; +import com.codename1.backend.security.oauth2.jose.jws.SignatureAlgorithm; +import com.codename1.backend.security.oauth2.jwt.DefaultJwtDecoder; +import com.codename1.backend.security.oauth2.jwt.Jwt; +import com.codename1.backend.security.oauth2.jwt.JwtTimestampValidator; + +/// The outbound credential grants on this runtime: the same form, the same +/// assertion claims, the same caching and the same refusal on both, against an +/// endpoint in process. The assertions carry a random jti, so they are checked by +/// verifying them rather than compared as VALUE lines. +final class AuthCheckCredentials { + private static final String TOKEN_URI = "https://auth.example.com/oauth/token"; + + private AuthCheckCredentials() { + } + + static final class Endpoint implements TokenEndpoint.Transport { + final List forms = new ArrayList(); + final List headers = new ArrayList(); + int status = 200; + String body; + int calls; + + public TokenEndpoint.Response post(String uri, List h, String form) { + calls++; + forms.add(form); + headers.add(h); + return new TokenEndpoint.Response(status, body != null ? body + : "{\"access_token\":\"tok-" + calls + "\",\"token_type\":\"Bearer\"," + + "\"expires_in\":3600}"); + } + + Map lastForm() { + return OAuth2Parameters.parse((String) forms.get(forms.size() - 1)); + } + } + + static final class FixedClock implements Clock { + long now = 1700000000000L; + + public long currentTimeMillis() { + return now; + } + } + + static void run() throws Exception { + clientCredentials(); + jwtBearer(); + caching(); + migration(); + } + + /// Hashes from other frameworks (computed with Python's hashlib) and an + /// import that keeps one, refuses one and reports the rest. + private static void migration() { + String pw = "correct horse"; + String[][] vectors = { + {"DJANGO", "pbkdf2_sha256$1000$seasalt$mQnueSakb748zqBAC1tmWVZsZbi2zPGZarEzTGdfmso="}, + {"DJANGO", "pbkdf2_sha1$1000$seasalt$iQvkNOF1wEL4Khh8eogJ8rUhipM="}, + {"PASSLIB", "$pbkdf2-sha256$1000$AAECAwQFBgcICQoLDA0ODw$" + + "yRTMTwbMbo9G0VfjobWqerzuuxe7BETNTErBbKKumGQ"}, + {"WERKZEUG", "pbkdf2:sha256:1000$NaCl1234$09648d88c553b8723dc823d15e17163d3110a5d75b" + + "040381a3557783e98e82ef"}}; + for (int iter = 0 ; iter < vectors.length ; iter++) { + ModularPbkdf2PasswordEncoder encoder = new ModularPbkdf2PasswordEncoder( + ModularPbkdf2PasswordEncoder.Format.valueOf(vectors[iter][0])); + AuthCheck.value("legacy hash " + iter, encoder.matches(pw, vectors[iter][1]) + " " + + encoder.matches("wrong", vectors[iter][1])); + } + Map encoders = new java.util.LinkedHashMap(); + encoders.put("pbkdf2-sha256", new Pbkdf2Sha256PasswordEncoder()); + encoders.put("django", new ModularPbkdf2PasswordEncoder( + ModularPbkdf2PasswordEncoder.Format.DJANGO)); + DelegatingPasswordEncoder passwords = new DelegatingPasswordEncoder("pbkdf2-sha256", encoders); + InMemoryUserDetailsManager users = new InMemoryUserDetailsManager(); + IdentityMigration migration = new IdentityMigration(users, passwords); + migration.setPasswordPrefix("django"); + migration.user("Ada@Example.com", vectors[0][1], java.util.Arrays.asList( + new String[] {"ROLE_ADMIN"}), true); + migration.user("old", "{md4}00", null, true); + migration.user("old", vectors[0][1], null, true); + MigrationReport report = migration.finish(); + AuthCheck.value("migration report", report.count(MigrationReport.Outcome.MIGRATED) + " " + + report.count(MigrationReport.Outcome.NEEDS_ACTION) + " " + + report.count(MigrationReport.Outcome.SKIPPED) + " " + + report.count(MigrationReport.Outcome.NOT_CARRIED)); + String stored = users.loadUserByUsername("Ada@Example.com").getPassword(); + AuthCheck.check("imported hash verifies", "true", String.valueOf(passwords.matches(pw, stored))); + AuthCheck.check("unverifiable hash replaced", "false", String.valueOf(passwords.matches( + "00", users.loadUserByUsername("old").getPassword()))); + } + + private static DefaultJwtDecoder verifier(String publicPem, String keyId) throws IOException { + final List keys = JwkSet.of(new Jwk[] {Jwk.ofPublicKey(KeyFiles.publicKey(publicPem)) + .withKeyId(keyId)}).getKeys(); + return DefaultJwtDecoder.withJwkSource(new JwkSource() { + public List getKeys() { + return keys; + } + }).jwsAlgorithms(new com.codename1.backend.security.oauth2.jose.jws.JwsAlgorithm[] { + SignatureAlgorithm.RS256, SignatureAlgorithm.ES256}).build(); + } + + private static void clientCredentials() throws IOException { + Endpoint endpoint = new Endpoint(); + String token = ClientCredentialsGrant.builder(TOKEN_URI, "orders svc") + .clientSecret("p@ss:word").scopes(new String[] {"a", "b"}).transport(endpoint) + .build().fetch().getTokenValue(); + AuthCheck.check("client credentials token", "tok-1", token); + AuthCheck.value("client credentials form", (String) endpoint.forms.get(0)); + List h = (List) endpoint.headers.get(0); + String basic = null; + for (int iter = 0 ; iter < h.size() ; iter++) { + String line = (String) h.get(iter); + if (line.startsWith("Authorization: Basic ")) { + basic = new String(Base64.decode(line.substring(21)), "UTF-8"); + } + } + AuthCheck.check("client_secret_basic header", "orders%20svc:p%40ss%3Aword", basic); + + Endpoint keyed = new Endpoint(); + FixedClock clock = new FixedClock(); + ClientCredentialsGrant.builder(TOKEN_URI, "orders") + .privateKeyJwt(Jwk.ofPrivateKey(KeyFiles.privateKey(KeyFixtures.RSA_PKCS8_PEM)) + .withKeyId("key-1")).transport(keyed).clock(clock).build().fetch(); + Map form = keyed.lastForm(); + Jwt assertion = verifierAt(KeyFixtures.RSA_PUBLIC_PEM, "key-1", + (String) form.get("client_assertion")); + AuthCheck.check("private_key_jwt iss", "orders", assertion == null ? null : assertion.getIssuer()); + AuthCheck.check("private_key_jwt sub", "orders", assertion == null ? null : assertion.getSubject()); + } + + /// Decodes against the fixed clock the assertions were signed at. + private static Jwt verifierAt(String publicPem, String keyId, String token) throws IOException { + DefaultJwtDecoder decoder = verifier(publicPem, keyId); + JwtTimestampValidator time = new JwtTimestampValidator(); + time.setClock(new FixedClock()); + decoder.setJwtValidator(time); + try { + return decoder.decode(token); + } catch (RuntimeException err) { + AuthCheck.fail("assertion verifies", err.getMessage()); + return null; + } + } + + private static void jwtBearer() throws IOException { + String[][] keys = {{KeyFixtures.RSA_PKCS8_PEM, KeyFixtures.RSA_PUBLIC_PEM, "RS256"}, + {KeyFixtures.EC256_PKCS8_PEM, KeyFixtures.EC256_PUBLIC_PEM, "ES256"}}; + for (int iter = 0 ; iter < keys.length ; iter++) { + Endpoint endpoint = new Endpoint(); + FixedClock clock = new FixedClock(); + JwtBearerGrant.builder(TOKEN_URI, "svc@example.iam", + Jwk.ofPrivateKey(KeyFiles.privateKey(keys[iter][0])).withKeyId("k" + iter)) + .subject("ada@example.com").scopes(new String[] {"s1", "s2"}) + .transport(endpoint).clock(clock).build().fetch(); + Map form = endpoint.lastForm(); + AuthCheck.value("jwt bearer grant_type " + keys[iter][2], (String) form.get("grant_type")); + Jwt jwt = verifierAt(keys[iter][1], "k" + iter, (String) form.get("assertion")); + if (jwt == null) { + continue; + } + AuthCheck.value("jwt bearer claims " + keys[iter][2], jwt.getHeaders().get("alg") + " " + + jwt.getIssuer() + " " + jwt.getSubject() + " " + jwt.getClaimAsString("scope") + + " " + jwt.getClaimAsLong("iat") + " " + jwt.getClaimAsLong("exp") + + " " + jwt.getClaimAsStringList("aud")); + } + StringBuilder file = new StringBuilder("{\"type\":\"service_account\",\"client_email\":" + + "\"svc@demo.iam.gserviceaccount.com\",\"private_key_id\":\"abc123\"," + + "\"private_key\":\""); + String pem = KeyFixtures.RSA_PKCS8_PEM; + for (int iter = 0 ; iter < pem.length() ; iter++) { + char c = pem.charAt(iter); + if (c == '\n') { + file.append("\\n"); + } else { + file.append(c); + } + } + file.append("\"}"); + GoogleServiceAccountCredentials account = GoogleServiceAccountCredentials.fromJson( + file.toString()); + Endpoint google = new Endpoint(); + account.builder().scopes(new String[] {"https://www.googleapis.com/auth/cloud-platform"}) + .transport(google).clock(new FixedClock()).build().fetch(); + Jwt jwt = verifierAt(KeyFixtures.RSA_PUBLIC_PEM, "abc123", + (String) google.lastForm().get("assertion")); + AuthCheck.value("google assertion", jwt == null ? "unverified" : jwt.getIssuer() + " " + + jwt.getClaimAsStringList("aud") + " " + jwt.getClaimAsString("scope")); + } + + private static void caching() { + Endpoint endpoint = new Endpoint(); + FixedClock clock = new FixedClock(); + AccessTokenProvider provider = new AccessTokenProvider(ClientCredentialsGrant + .builder(TOKEN_URI, "c").clientSecret("secret-value").transport(endpoint) + .clock(clock).build()); + provider.setClock(clock); + provider.getTokenValue(); + clock.now += (3600 - 61) * 1000L; + AuthCheck.check("cached until the skew", "tok-1", provider.getTokenValue()); + clock.now += 2000L; + AuthCheck.check("refreshed inside the skew", "tok-2", provider.getTokenValue()); + endpoint.status = 503; + endpoint.body = "{\"error\":\"temporarily_unavailable\"}"; + clock.now += 3600 * 1000L; + try { + provider.getToken(); + AuthCheck.fail("expired token after a failed refresh", "a token was returned"); + } catch (TokenAcquisitionException refused) { + AuthCheck.value("failed refresh", refused.getStatus() + " " + refused.getErrorCode() + + " " + refused.isRetryable() + " " + refused.getMessage()); + AuthCheck.check("failure names no secret", "-1", + String.valueOf(refused.getMessage().indexOf("secret-value"))); + } + int calls = endpoint.calls; + try { + provider.getToken(); + } catch (TokenAcquisitionException backingOff) { + AuthCheck.check("backs off", String.valueOf(calls), String.valueOf(endpoint.calls)); + } + } +} diff --git a/vm/backend/src/com/codename1/backend/security/crypto/DelegatingPasswordEncoder.java b/vm/backend/src/com/codename1/backend/security/crypto/DelegatingPasswordEncoder.java index 0c366dad708..9441170e502 100644 --- a/vm/backend/src/com/codename1/backend/security/crypto/DelegatingPasswordEncoder.java +++ b/vm/backend/src/com/codename1/backend/security/crypto/DelegatingPasswordEncoder.java @@ -106,6 +106,24 @@ public boolean matches(CharSequence rawPassword, String prefixEncodedPassword) { return delegate.matches(rawPassword, extractEncodedPassword(prefixEncodedPassword)); } + /// Whether `prefixEncodedPassword` names an id this encoder has a delegate + /// for. A value it does not is refused by [#matches(CharSequence, String)] + /// through the default for matches, which by default throws; a migration asks + /// this first, so it can report the row instead of importing a password nobody + /// can sign in with. + public boolean isSupported(String prefixEncodedPassword) { + String id = extractId(prefixEncodedPassword); + PasswordEncoder delegate = id == null ? null : idToPasswordEncoder.get(id); + if (delegate instanceof ModularPbkdf2PasswordEncoder) { + // One id covers a framework's every format, and only PBKDF2 is + // read: Werkzeug's scrypt or Django's argon2 under the same id + // would otherwise be imported as a hash nobody can sign in with. + return ((ModularPbkdf2PasswordEncoder) delegate).isReadable( + extractEncodedPassword(prefixEncodedPassword)); + } + return delegate != null; + } + @Override public boolean upgradeEncoding(String prefixEncodedPassword) { String id = extractId(prefixEncodedPassword); diff --git a/vm/backend/src/com/codename1/backend/security/crypto/ModularPbkdf2PasswordEncoder.java b/vm/backend/src/com/codename1/backend/security/crypto/ModularPbkdf2PasswordEncoder.java new file mode 100644 index 00000000000..84a8ef5ae0f --- /dev/null +++ b/vm/backend/src/com/codename1/backend/security/crypto/ModularPbkdf2PasswordEncoder.java @@ -0,0 +1,337 @@ +/* + * 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.backend.security.crypto; + +import com.codename1.backend.Base64; +import com.codename1.backend.Crypto; +import java.io.IOException; + +/// PBKDF2 password hashes in the self-describing formats other frameworks +/// store, read as they write them: the digest and the round count come from +/// the stored value, so every row verifies whatever its era's settings were. +/// +/// | format | stored value | +/// |---|---| +/// | [Format#DJANGO] | `pbkdf2_sha256$$$` (also `pbkdf2_sha1`) | +/// | [Format#PASSLIB] | `$pbkdf2-sha256$$$` (also `$pbkdf2$`, `$pbkdf2-sha512$`) | +/// | [Format#WERKZEUG] | `pbkdf2:sha256:$$` (also `sha1`, `sha512`) | +/// +/// Register it under an id of its own and give the imported hashes that prefix: +/// +/// ```java +/// Map encoders = new LinkedHashMap<>(); +/// encoders.put("pbkdf2-sha256", new Pbkdf2Sha256PasswordEncoder()); +/// encoders.put("django", new ModularPbkdf2PasswordEncoder(ModularPbkdf2PasswordEncoder.Format.DJANGO)); +/// PasswordEncoder passwords = new DelegatingPasswordEncoder("pbkdf2-sha256", encoders); +/// // a row imported as "{django}pbkdf2_sha256$600000$..." verifies, and is +/// // re-hashed with the default when its owner signs in +/// ``` +/// +/// [#upgradeEncoding(String)] is true for every value: these are formats to +/// migrate away from, not to keep writing. +/// +/// A stored value names its own work factor, so it is bounded. One above +/// `maxRounds` (5,000,000 by default) does not verify -- a hash that asked for +/// a billion rounds would hold the host thread it runs on for minutes. Werkzeug +/// values without a round count are refused too: the count they used was the +/// default of whichever Werkzeug release wrote them, and guessing it would +/// either fail every such login or need one PBKDF2 run per guess. +public final class ModularPbkdf2PasswordEncoder implements PasswordEncoder { + /// The stored layouts read. + public enum Format { + DJANGO, + PASSLIB, + WERKZEUG + } + + private static final int DEFAULT_ROUNDS = 600000; + + private final Format format; + private int maxRounds = 5000000; + private int encodeRounds = DEFAULT_ROUNDS; + + public ModularPbkdf2PasswordEncoder(Format format) { + if (format == null) { + throw new IllegalArgumentException("A format is required"); + } + this.format = format; + } + + /// The most rounds a stored value may ask for. Default 5,000,000. + public void setMaxRounds(int maxRounds) { + if (maxRounds < 1) { + throw new IllegalArgumentException("maxRounds must be positive"); + } + this.maxRounds = maxRounds; + } + + /// Rounds [#encode(CharSequence)] writes. Default 600,000 with SHA-256. + public void setEncodeRounds(int rounds) { + if (rounds < 1) { + throw new IllegalArgumentException("rounds must be positive"); + } + this.encodeRounds = rounds; + } + + /// A new SHA-256 hash in this format. Meant for fixtures and for tests of a + /// migration; a running server writes its default encoder's format. + @Override + public String encode(CharSequence rawPassword) { + if (rawPassword == null) { + throw new IllegalArgumentException("rawPassword cannot be null"); + } + byte[] random; + try { + random = Crypto.randomBytes(12); + } catch (IOException err) { + throw new IllegalStateException("Could not hash a password: " + err.getMessage(), err); + } + // Django and Werkzeug use the salt's characters as the salt, so it is + // written as text that needs no escaping in either layout. + String textSalt = hex(random); + byte[] hash; + switch (format) { + case DJANGO: + hash = derive(Crypto.SHA256, rawPassword, ascii(textSalt), encodeRounds, 32); + return "pbkdf2_sha256$" + encodeRounds + "$" + textSalt + "$" + Base64.encode(hash); + case PASSLIB: + hash = derive(Crypto.SHA256, rawPassword, random, encodeRounds, 32); + return "$pbkdf2-sha256$" + encodeRounds + "$" + ab64(random) + "$" + ab64(hash); + default: + hash = derive(Crypto.SHA256, rawPassword, ascii(textSalt), encodeRounds, 32); + return "pbkdf2:sha256:" + encodeRounds + "$" + textSalt + "$" + hex(hash); + } + } + + @Override + public boolean matches(CharSequence rawPassword, String encodedPassword) { + if (rawPassword == null || encodedPassword == null || encodedPassword.length() == 0) { + return false; + } + Parsed parsed = parse(encodedPassword); + if (parsed == null || parsed.rounds < 1 || parsed.rounds > maxRounds + || parsed.hash.length < 16 || parsed.hash.length > 64) { + return false; + } + return Crypto.equalsConstantTime(parsed.hash, derive(parsed.digest, rawPassword, + parsed.salt, parsed.rounds, parsed.hash.length)); + } + + @Override + public boolean upgradeEncoding(String encodedPassword) { + return true; + } + + /// Whether `encodedPassword` is a value of this format this encoder would + /// try to verify: the layout, a known digest and a round count within + /// `maxRounds`. What a migration asks before importing a row. + public boolean isReadable(String encodedPassword) { + Parsed parsed = encodedPassword == null ? null : parse(encodedPassword); + return parsed != null && parsed.rounds >= 1 && parsed.rounds <= maxRounds + && parsed.hash.length >= 16 && parsed.hash.length <= 64; + } + + private static final class Parsed { + String digest; + int rounds; + byte[] salt; + byte[] hash; + } + + private Parsed parse(String value) { + String[] parts; + Parsed p = new Parsed(); + switch (format) { + case DJANGO: + parts = split(value, 4); + if (parts == null) { + return null; + } + p.digest = "pbkdf2_sha256".equals(parts[0]) ? Crypto.SHA256 + : "pbkdf2_sha1".equals(parts[0]) ? Crypto.SHA1 : null; + p.rounds = number(parts[1]); + p.salt = ascii(parts[2]); + p.hash = Base64.decode(parts[3]); + break; + case PASSLIB: + if (!value.startsWith("$")) { + return null; + } + parts = split(value.substring(1), 4); + if (parts == null) { + return null; + } + p.digest = "pbkdf2-sha256".equals(parts[0]) ? Crypto.SHA256 + : "pbkdf2-sha512".equals(parts[0]) ? Crypto.SHA512 + : "pbkdf2".equals(parts[0]) ? Crypto.SHA1 : null; + p.rounds = number(parts[1]); + p.salt = unab64(parts[2]); + p.hash = unab64(parts[3]); + break; + default: + parts = split(value, 3); + if (parts == null) { + return null; + } + String[] method = splitOn(parts[0], ':'); + if (method.length != 3 || !"pbkdf2".equals(method[0])) { + return null; + } + p.digest = "sha256".equals(method[1]) ? Crypto.SHA256 + : "sha512".equals(method[1]) ? Crypto.SHA512 + : "sha1".equals(method[1]) ? Crypto.SHA1 : null; + p.rounds = number(method[2]); + p.salt = ascii(parts[1]); + p.hash = unhex(parts[2]); + break; + } + if (p.digest == null || p.salt == null || p.salt.length == 0 || p.hash == null) { + return null; + } + return p; + } + + private static byte[] derive(String digest, CharSequence rawPassword, byte[] salt, int rounds, + int length) { + try { + return Crypto.pbkdf2(digest, utf8(rawPassword.toString()), salt, rounds, length); + } catch (IOException err) { + throw new IllegalStateException("Could not hash a password: " + err.getMessage(), err); + } + } + + private static String[] split(String value, int count) { + String[] parts = splitOn(value, '$'); + return parts.length == count ? parts : null; + } + + private static String[] splitOn(String value, char separator) { + int n = 1; + for (int iter = 0 ; iter < value.length() ; iter++) { + if (value.charAt(iter) == separator) { + n++; + } + } + String[] out = new String[n]; + int start = 0; + int at = 0; + for (int iter = 0 ; iter <= value.length() ; iter++) { + if (iter == value.length() || value.charAt(iter) == separator) { + out[at++] = value.substring(start, iter); + start = iter + 1; + } + } + return out; + } + + private static int number(String text) { + if (text.length() == 0 || text.length() > 9) { + return -1; + } + int value = 0; + for (int iter = 0 ; iter < text.length() ; iter++) { + char c = text.charAt(iter); + if (c < '0' || c > '9') { + return -1; + } + value = value * 10 + (c - '0'); + } + return value; + } + + /// passlib's "adapted base64": `.` for `+`, no padding. + private static String ab64(byte[] data) { + String standard = Base64.encode(data); + StringBuilder out = new StringBuilder(standard.length()); + for (int iter = 0 ; iter < standard.length() ; iter++) { + char c = standard.charAt(iter); + if (c == '+') { + out.append('.'); + } else if (c != '=') { + out.append(c); + } + } + return out.toString(); + } + + private static byte[] unab64(String text) { + StringBuilder standard = new StringBuilder(text.length() + 3); + for (int iter = 0 ; iter < text.length() ; iter++) { + char c = text.charAt(iter); + if (c == '+' || c == '=') { + return null; + } + standard.append(c == '.' ? '+' : c); + } + while (standard.length() % 4 != 0) { + standard.append('='); + } + return Base64.decode(standard.toString()); + } + + private static byte[] ascii(String text) { + byte[] out = new byte[text.length()]; + for (int iter = 0 ; iter < out.length ; iter++) { + char c = text.charAt(iter); + if (c > 0x7e || c < 0x20) { + return null; + } + out[iter] = (byte) c; + } + return out; + } + + private static byte[] utf8(String value) { + try { + return value.getBytes("UTF-8"); + } catch (java.io.UnsupportedEncodingException err) { + throw new IllegalStateException("UTF-8 is required", err); + } + } + + private static String hex(byte[] data) { + char[] digits = {'0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', + 'f'}; + StringBuilder sb = new StringBuilder(data.length * 2); + for (byte b : data) { + sb.append(digits[(b >> 4) & 0xf]).append(digits[b & 0xf]); + } + return sb.toString(); + } + + private static byte[] unhex(String text) { + if ((text.length() & 1) != 0) { + return null; + } + byte[] out = new byte[text.length() / 2]; + for (int iter = 0 ; iter < out.length ; iter++) { + int high = Character.digit(text.charAt(iter * 2), 16); + int low = Character.digit(text.charAt(iter * 2 + 1), 16); + if (high < 0 || low < 0) { + return null; + } + out[iter] = (byte) ((high << 4) | low); + } + return out; + } +} diff --git a/vm/backend/src/com/codename1/backend/security/migration/IdentityMigration.java b/vm/backend/src/com/codename1/backend/security/migration/IdentityMigration.java new file mode 100644 index 00000000000..68d5f05ee1b --- /dev/null +++ b/vm/backend/src/com/codename1/backend/security/migration/IdentityMigration.java @@ -0,0 +1,376 @@ +/* + * 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.backend.security.migration; + +import com.codename1.backend.Base64Url; +import com.codename1.backend.Crypto; +import com.codename1.backend.security.SecuritySchema; +import com.codename1.backend.security.core.userdetails.User; +import com.codename1.backend.security.core.userdetails.UserDetailsManager; +import com.codename1.backend.security.crypto.DelegatingPasswordEncoder; +import com.codename1.backend.security.mfa.TotpRepository; +import com.codename1.backend.security.oauth2.client.FederatedIdentityRepository; +import com.codename1.backend.security.webauthn.CoseKey; +import com.codename1.backend.security.webauthn.CredentialRecord; +import com.codename1.backend.security.webauthn.PublicKeyCredentialUserEntity; +import com.codename1.backend.security.webauthn.PublicKeyCredentialUserEntityRepository; +import com.codename1.backend.security.webauthn.UserCredentialRepository; +import com.codename1.backend.security.webauthn.WebAuthnException; +import java.io.IOException; +import java.util.Arrays; +import java.util.Collection; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +/// Moves the accounts of another identity store into this server's stores, +/// keeping everything a user signs in with, and reports every record. +/// +/// The application reads its old store -- a SQL query, a JSON export, an LDAP +/// dump -- and hands the rows over; this class decides what each one becomes: +/// +/// ```java +/// IdentityMigration migration = new IdentityMigration(users, passwords); +/// migration.setPasswordPrefix("django"); // the old store's rows carry no {id} +/// migration.setFederatedIdentities(links); +/// migration.setPasskeys(userEntities, credentials); +/// for (OldRow row : oldRows) { +/// migration.user(row.username, row.passwordHash, row.roles, row.enabled); +/// if (row.googleSub != null) { +/// migration.federatedIdentity("google", row.googleSub, row.username); +/// } +/// } +/// MigrationReport report = migration.finish(); +/// ``` +/// +/// What it guarantees: +/// +/// - **Usernames, subjects and authorities are written verbatim.** An issued +/// token's `sub`, a federated link and a passkey's user handle are what +/// other systems hold on to, so none of them is normalised or regenerated. +/// - **A password hash is kept only when it can be verified.** One whose +/// `{id}` the encoder does not know is not imported as a hash nobody can +/// check: the user is created with a random password nobody knows and the +/// report says a reset is needed. A kept hash is re-encoded with the default +/// on its owner's next sign in, through the existing rehash-on-login path. +/// - **Nothing existing is overwritten.** A user, a link, a user handle or a +/// credential id already present is reported as skipped. Run it again after +/// fixing the rows it skipped; what it already wrote is skipped again. +/// - **A dry run writes nothing** ([#setDryRun(boolean)]) and produces the same +/// report, apart from conflicts between two rows of the same run. +/// +/// What it never carries is listed in the report too, as +/// [MigrationReport.Outcome#NOT_CARRIED]: sessions, remember-me tokens, +/// authorization codes and refresh tokens issued by the old server, and +/// recovery codes. Each is either bound to the old server's secret or stored +/// only as a hash the old server chose, and a user signs in again to get new +/// ones. Signing keys are configuration, not records: see +/// `AuthorizationServerKeys.KEY_IDS` and `VERIFICATION_KEYS`. +/// +/// Not synchronized: a migration is one pass by one thread. +public final class IdentityMigration { + private final UserDetailsManager users; + private final DelegatingPasswordEncoder passwords; + private FederatedIdentityRepository federated; + private PublicKeyCredentialUserEntityRepository userEntities; + private UserCredentialRepository credentials; + private TotpRepository totp; + private String passwordPrefix; + private boolean dryRun; + private MigrationReport report; + /// The user names this run imported, by their folded key. + private final Map seenUsers = new HashMap(); + /// Rows refused because their name differs only in case from another + /// user's. The stores fold A-Z, so anything attached to such a name would + /// land on that other account. + private final Set caseCollisions = new HashSet(); + private final Set seenCredentials = new HashSet(); + + /// @param users where the accounts are created + /// @param passwords the encoder the server verifies with; decides which + /// imported hashes can be kept + public IdentityMigration(UserDetailsManager users, DelegatingPasswordEncoder passwords) { + if (users == null || passwords == null) { + throw new IllegalArgumentException("A user store and a password encoder are required"); + } + this.users = users; + this.passwords = passwords; + } + + /// The `{id}` to put in front of a hash that has none, such as `bcrypt` for + /// a store of bare `$2a$` values. A hash that already starts with `{` is + /// left alone. Null (the default) adds nothing. + public void setPasswordPrefix(String id) { + this.passwordPrefix = id; + } + + public void setFederatedIdentities(FederatedIdentityRepository repository) { + this.federated = repository; + } + + public void setPasskeys(PublicKeyCredentialUserEntityRepository userEntities, + UserCredentialRepository credentials) { + this.userEntities = userEntities; + this.credentials = credentials; + } + + public void setTotp(TotpRepository repository) { + this.totp = repository; + } + + /// True to write nothing and only report. Set it before the first record. + public void setDryRun(boolean dryRun) { + if (report != null) { + throw new IllegalStateException("Set the dry run before the first record"); + } + this.dryRun = dryRun; + } + + private MigrationReport report() { + if (report == null) { + report = new MigrationReport(dryRun); + } + return report; + } + + /// Imports one account. + /// + /// @param username kept verbatim + /// @param passwordHash the stored hash, with or without its `{id}`; null or + /// empty for an account that never had a password (federated only) + /// @param authorities kept verbatim, `ROLE_` prefixes included + /// @param enabled false keeps a disabled account disabled + public void user(String username, String passwordHash, Collection authorities, + boolean enabled) { + MigrationReport r = report(); + if (username == null || username.length() == 0) { + r.add("user", "(no username)", MigrationReport.Outcome.SKIPPED, + "the row has no username"); + return; + } + String key = SecuritySchema.usernameKey(username); + String other = seenUsers.get(key); + if (other == null && users.userExists(username)) { + other = users.loadUserByUsername(username).getUsername(); + } + if (other != null) { + if (other.equals(username)) { + r.add("user", username, MigrationReport.Outcome.SKIPPED, "the user already exists"); + } else { + // A store that tells Admin from admin is common (Django is + // one); this server does not. Nothing of this row is imported, + // or its owner's links and keys would sign in as the other. + caseCollisions.add(username); + r.add("user", username, MigrationReport.Outcome.SKIPPED, "differs only in case " + + "from the user " + other + ", and user names here ignore case; " + + "nothing of this user is imported"); + } + return; + } + seenUsers.put(key, username); + String reason = null; + String stored; + if (passwordHash == null || passwordHash.length() == 0) { + stored = unusablePassword(); + reason = "no password was stored; the user signs in another way or sets one"; + } else { + stored = passwordPrefix != null && !passwordHash.startsWith("{") + ? "{" + passwordPrefix + "}" + passwordHash : passwordHash; + if (!passwords.isSupported(stored)) { + stored = unusablePassword(); + reason = "the password hash is in a format this server cannot verify; the " + + "user must reset the password"; + } + } + String[] roles = authorities == null ? new String[0] + : authorities.toArray(new String[authorities.size()]); + if (!dryRun) { + users.createUser(User.withUsername(username).password(stored).authorities(roles) + .disabled(!enabled).build()); + } + r.add("user", username, reason == null ? MigrationReport.Outcome.MIGRATED + : MigrationReport.Outcome.NEEDS_ACTION, reason); + } + + /// Links a sign-in provider's subject to an imported user, verbatim. + public void federatedIdentity(String provider, String subject, String username) { + MigrationReport r = report(); + String id = provider + "/" + subject; + if (federated == null) { + throw new IllegalStateException("setFederatedIdentities was not called"); + } + if (provider == null || subject == null || username == null) { + r.add("federated-identity", id, MigrationReport.Outcome.SKIPPED, + "the row lacks a provider, a subject or a username"); + return; + } + if (!userKnown(username)) { + r.add("federated-identity", id, MigrationReport.Outcome.SKIPPED, + "no user " + username); + return; + } + String existing = federated.findUsername(provider, subject); + if (existing != null) { + r.add("federated-identity", id, MigrationReport.Outcome.SKIPPED, existing.equals(username) + ? "already linked" : "already linked to another user"); + return; + } + if (!dryRun) { + federated.link(provider, subject, username); + } + r.add("federated-identity", id, MigrationReport.Outcome.MIGRATED, null); + } + + /// Imports a passkey, keeping its credential id and user handle. + public void passkey(LegacyPasskey passkey) { + MigrationReport r = report(); + if (credentials == null || userEntities == null) { + throw new IllegalStateException("setPasskeys was not called"); + } + byte[] credentialId = passkey.getCredentialId(); + String id = credentialId == null ? "(no credential id)" : Base64Url.encode(credentialId); + String username = passkey.getUsername(); + byte[] handle = passkey.getUserHandle(); + if (credentialId == null || credentialId.length == 0 || handle == null + || handle.length == 0 || username == null) { + r.add("passkey", id, MigrationReport.Outcome.SKIPPED, + "the row lacks a credential id, a user handle or a username"); + return; + } + if (!userKnown(username)) { + r.add("passkey", id, MigrationReport.Outcome.SKIPPED, "no user " + username); + return; + } + CoseKey key; + try { + key = CoseKey.parse(passkey.getCoseKey()); + } catch (WebAuthnException err) { + r.add("passkey", id, MigrationReport.Outcome.NEEDS_ACTION, "the public key cannot be " + + "used (" + err.getMessage() + "); the user must register a passkey again"); + return; + } catch (RuntimeException err) { + r.add("passkey", id, MigrationReport.Outcome.NEEDS_ACTION, "the public key is not " + + "valid CBOR; the user must register a passkey again"); + return; + } + if (!seenCredentials.add(id) || credentials.findByCredentialId(credentialId) != null) { + r.add("passkey", id, MigrationReport.Outcome.SKIPPED, "the credential id already exists"); + return; + } + PublicKeyCredentialUserEntity byName = userEntities.findByUsername(username); + if (byName != null && !Arrays.equals(byName.getId(), handle)) { + r.add("passkey", id, MigrationReport.Outcome.SKIPPED, "user " + username + + " already has another user handle; a passkey answers with the handle it " + + "was registered with"); + return; + } + PublicKeyCredentialUserEntity byHandle = userEntities.findById(handle); + if (byHandle != null && !username.equals(byHandle.getName())) { + r.add("passkey", id, MigrationReport.Outcome.SKIPPED, + "the user handle belongs to another user"); + return; + } + if (!dryRun) { + if (byName == null) { + userEntities.save(new PublicKeyCredentialUserEntity(username, handle, username)); + } + long now = System.currentTimeMillis(); + credentials.save(CredentialRecord.builder().credentialId(credentialId) + .userEntityUserId(handle).algorithm(key.getAlgorithm()) + .publicKey(key.getPublicKey()).signatureCount(passkey.getSignatureCount()) + .uvInitialized(passkey.isUvInitialized()) + .backupEligible(passkey.isBackupEligible()) + .backupState(passkey.isBackupState()).transports(passkey.getTransports()) + .label(passkey.getLabel()).created(now).lastUsed(now).build()); + } + r.add("passkey", id, MigrationReport.Outcome.MIGRATED, null); + } + + /// Imports a confirmed TOTP secret (the raw key, not its base32 text), so the + /// user's authenticator app keeps working. + public void totp(String username, byte[] secret) { + MigrationReport r = report(); + if (totp == null) { + throw new IllegalStateException("setTotp was not called"); + } + if (username == null || !userKnown(username)) { + r.add("totp", String.valueOf(username), MigrationReport.Outcome.SKIPPED, + "no user " + username); + return; + } + if (secret == null || secret.length < 10) { + r.add("totp", username, MigrationReport.Outcome.NEEDS_ACTION, + "the secret is shorter than 80 bits; the user must enrol again"); + return; + } + if (totp.find(username) != null) { + r.add("totp", username, MigrationReport.Outcome.SKIPPED, "the user already has TOTP"); + return; + } + if (!dryRun) { + totp.save(username, secret); + totp.confirm(username); + } + r.add("totp", username, MigrationReport.Outcome.MIGRATED, null); + } + + /// Closes the run: adds the state no migration carries and returns the report. + public MigrationReport finish() { + MigrationReport r = report(); + r.add("sessions", "*", MigrationReport.Outcome.NOT_CARRIED, + "bound to the old server; every user signs in once"); + r.add("remember-me", "*", MigrationReport.Outcome.NOT_CARRIED, + "the old server's tokens are hashed or signed with its own secret"); + r.add("authorization-codes", "*", MigrationReport.Outcome.NOT_CARRIED, + "single use and minutes long; a client in the middle of a sign in starts again"); + r.add("refresh-tokens", "*", MigrationReport.Outcome.NOT_CARRIED, + "issued by the old server; its clients sign the user in again"); + r.add("recovery-codes", "*", MigrationReport.Outcome.NOT_CARRIED, + "stored as hashes of the old server's choosing; users generate new ones"); + report = null; + return r; + } + + /// Whether `username`, spelled exactly so, is a user records may attach to. + private boolean userKnown(String username) { + if (caseCollisions.contains(username)) { + return false; + } + String imported = seenUsers.get(SecuritySchema.usernameKey(username)); + if (imported != null) { + return imported.equals(username); + } + return users.userExists(username) + && username.equals(users.loadUserByUsername(username).getUsername()); + } + + /// A hash of 32 random bytes nobody ever saw: a valid value no password matches. + private String unusablePassword() { + try { + return passwords.encode(Base64Url.encode(Crypto.randomBytes(32))); + } catch (IOException err) { + throw new IllegalStateException("Could not generate a password: " + err.getMessage(), err); + } + } +} diff --git a/vm/backend/src/com/codename1/backend/security/migration/LegacyPasskey.java b/vm/backend/src/com/codename1/backend/security/migration/LegacyPasskey.java new file mode 100644 index 00000000000..e788af999cc --- /dev/null +++ b/vm/backend/src/com/codename1/backend/security/migration/LegacyPasskey.java @@ -0,0 +1,130 @@ +/* + * 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.backend.security.migration; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/// A passkey as another relying party stored it. +/// +/// What a WebAuthn library keeps is the same everywhere, under different column +/// names: the credential id, the COSE public key the authenticator sent at +/// registration, its signature counter, and the user handle -- the +/// `user.id` the old server registered, which the authenticator returns with +/// every assertion and which therefore cannot change. +/// +/// A passkey moves only to a server of the same relying party id (the domain). +/// One registered for `old.example.com` does not answer a challenge for +/// `new.example.com`; that is WebAuthn, not this migration. +public final class LegacyPasskey { + private final String username; + private final byte[] userHandle; + private final byte[] credentialId; + private final byte[] coseKey; + private long signatureCount; + private List transports = Collections.emptyList(); + private String label; + private boolean backupEligible; + private boolean backupState; + private boolean uvInitialized; + + /// @param username the local user it belongs to + /// @param userHandle the `user.id` it was registered with + /// @param credentialId its credential id + /// @param coseKey its public key, CBOR-encoded as the authenticator sent it + public LegacyPasskey(String username, byte[] userHandle, byte[] credentialId, byte[] coseKey) { + this.username = username; + this.userHandle = userHandle == null ? null : userHandle.clone(); + this.credentialId = credentialId == null ? null : credentialId.clone(); + this.coseKey = coseKey == null ? null : coseKey.clone(); + } + + public LegacyPasskey signatureCount(long count) { + this.signatureCount = count; + return this; + } + + public LegacyPasskey transports(List transports) { + this.transports = transports == null ? Collections.emptyList() + : new ArrayList(transports); + return this; + } + + public LegacyPasskey label(String label) { + this.label = label; + return this; + } + + /// The backup flags the old server recorded (synced passkeys are eligible). + public LegacyPasskey backup(boolean eligible, boolean state) { + this.backupEligible = eligible; + this.backupState = state; + return this; + } + + public LegacyPasskey uvInitialized(boolean uvInitialized) { + this.uvInitialized = uvInitialized; + return this; + } + + String getUsername() { + return username; + } + + byte[] getUserHandle() { + return userHandle; + } + + byte[] getCredentialId() { + return credentialId; + } + + byte[] getCoseKey() { + return coseKey; + } + + long getSignatureCount() { + return signatureCount; + } + + List getTransports() { + return transports; + } + + String getLabel() { + return label; + } + + boolean isBackupEligible() { + return backupEligible; + } + + boolean isBackupState() { + return backupState; + } + + boolean isUvInitialized() { + return uvInitialized; + } +} diff --git a/vm/backend/src/com/codename1/backend/security/migration/MigrationReport.java b/vm/backend/src/com/codename1/backend/security/migration/MigrationReport.java new file mode 100644 index 00000000000..b527a10f9c9 --- /dev/null +++ b/vm/backend/src/com/codename1/backend/security/migration/MigrationReport.java @@ -0,0 +1,144 @@ +/* + * 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.backend.security.migration; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/// What an [IdentityMigration] did with every record it was given, and what it +/// could not carry at all. +/// +/// Every record lands in exactly one entry: migrated as it was, migrated with an +/// action its owner must take (a password that has to be reset, a second factor +/// to enrol again), or skipped with the reason. Nothing is dropped without an +/// entry, and nothing in an entry is secret -- usernames, providers, credential +/// ids and reasons, never a hash, a key or a TOTP seed. +public final class MigrationReport { + /// What happened to one record. + public enum Outcome { + /// Carried over unchanged; its owner signs in as before. + MIGRATED, + /// Carried over, and its owner has to act: see the reason. + NEEDS_ACTION, + /// Not carried over: see the reason. + SKIPPED, + /// State the migration never carries, and why. Not a record. + NOT_CARRIED + } + + /// One line of the report. + public static final class Entry { + private final String kind; + private final String id; + private final Outcome outcome; + private final String reason; + + Entry(String kind, String id, Outcome outcome, String reason) { + this.kind = kind; + this.id = id; + this.outcome = outcome; + this.reason = reason; + } + + /// `user`, `federated-identity`, `passkey`, `totp`, or for + /// [Outcome#NOT_CARRIED] the kind of state. + public String getKind() { + return kind; + } + + /// The record: a username, `provider/subject`, a credential id. + public String getId() { + return id; + } + + public Outcome getOutcome() { + return outcome; + } + + /// Why, for anything but [Outcome#MIGRATED]; null for that. + public String getReason() { + return reason; + } + + @Override + public String toString() { + return outcome + " " + kind + " " + id + (reason == null ? "" : ": " + reason); + } + } + + private final List entries = new ArrayList(); + private final boolean dryRun; + + MigrationReport(boolean dryRun) { + this.dryRun = dryRun; + } + + void add(String kind, String id, Outcome outcome, String reason) { + entries.add(new Entry(kind, id, outcome, reason)); + } + + /// Whether nothing was written: the report says what a real run would do. + public boolean isDryRun() { + return dryRun; + } + + public List getEntries() { + return Collections.unmodifiableList(entries); + } + + /// The entries with this outcome. + public List getEntries(Outcome outcome) { + List out = new ArrayList(); + for (Entry e : entries) { + if (e.getOutcome() == outcome) { + out.add(e); + } + } + return out; + } + + public int count(Outcome outcome) { + return getEntries(outcome).size(); + } + + /// True when no record was skipped. + public boolean isComplete() { + return count(Outcome.SKIPPED) == 0; + } + + @Override + public String toString() { + StringBuilder sb = new StringBuilder(); + sb.append(dryRun ? "Identity migration (dry run): " : "Identity migration: ") + .append(count(Outcome.MIGRATED)).append(" migrated, ") + .append(count(Outcome.NEEDS_ACTION)).append(" need action, ") + .append(count(Outcome.SKIPPED)).append(" skipped\n"); + for (Entry e : entries) { + if (e.getOutcome() != Outcome.MIGRATED) { + sb.append(" ").append(e).append('\n'); + } + } + return sb.toString(); + } +} diff --git a/vm/backend/src/com/codename1/backend/security/migration/package-info.java b/vm/backend/src/com/codename1/backend/security/migration/package-info.java new file mode 100644 index 00000000000..1d16c22fb6e --- /dev/null +++ b/vm/backend/src/com/codename1/backend/security/migration/package-info.java @@ -0,0 +1,27 @@ +/* + * 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. + */ +/// Moving the accounts of another identity store into this server: users and +/// their password hashes, federated links, passkeys and TOTP secrets, with a +/// [com.codename1.backend.security.migration.MigrationReport] that names every +/// record and what happened to it. +package com.codename1.backend.security.migration; diff --git a/vm/backend/src/com/codename1/backend/security/oauth2/client/credentials/AccessToken.java b/vm/backend/src/com/codename1/backend/security/oauth2/client/credentials/AccessToken.java new file mode 100644 index 00000000000..28e7cb74d4f --- /dev/null +++ b/vm/backend/src/com/codename1/backend/security/oauth2/client/credentials/AccessToken.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.backend.security.oauth2.client.credentials; + +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.Set; + +/// An access token this server obtained to call another one. +/// +/// The value is a credential: [#toString()] never prints it, so a token that +/// ends up in a log line or an exception message names its type, scopes and +/// expiry and nothing anyone could replay. +public final class AccessToken { + private final String tokenValue; + private final String tokenType; + private final long expiresAtMillis; + private final Set scopes; + + /// @param tokenValue the token, as the endpoint issued it + /// @param tokenType the type the endpoint named, normally `Bearer` + /// @param expiresAtMillis when it stops being accepted, in epoch milliseconds + /// @param scopes what it was issued for; empty when the endpoint did not say + public AccessToken(String tokenValue, String tokenType, long expiresAtMillis, + Set scopes) { + if (tokenValue == null || tokenValue.length() == 0) { + throw new IllegalArgumentException("An access token has a value"); + } + this.tokenValue = tokenValue; + this.tokenType = tokenType == null ? "Bearer" : tokenType; + this.expiresAtMillis = expiresAtMillis; + this.scopes = scopes == null ? Collections.emptySet() + : Collections.unmodifiableSet(new LinkedHashSet(scopes)); + } + + public String getTokenValue() { + return tokenValue; + } + + public String getTokenType() { + return tokenType; + } + + public long getExpiresAtMillis() { + return expiresAtMillis; + } + + public Set getScopes() { + return scopes; + } + + /// True once `nowMillis` has reached the expiry. + public boolean isExpired(long nowMillis) { + return nowMillis >= expiresAtMillis; + } + + /// The value of an `Authorization` header that presents this token. + public String toAuthorizationHeader() { + return tokenType + " " + tokenValue; + } + + @Override + public String toString() { + return "AccessToken[type=" + tokenType + ", scopes=" + scopes + ", expiresAt=" + + expiresAtMillis + ", value=]"; + } +} diff --git a/vm/backend/src/com/codename1/backend/security/oauth2/client/credentials/AccessTokenGrant.java b/vm/backend/src/com/codename1/backend/security/oauth2/client/credentials/AccessTokenGrant.java new file mode 100644 index 00000000000..862ebac85fd --- /dev/null +++ b/vm/backend/src/com/codename1/backend/security/oauth2/client/credentials/AccessTokenGrant.java @@ -0,0 +1,35 @@ +/* + * 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.backend.security.oauth2.client.credentials; + +/// One way of obtaining an access token: a grant against a token endpoint. +/// +/// Every call is a new exchange. Applications do not call a grant directly; +/// they wrap it in an [AccessTokenProvider], which caches the token, refreshes it +/// before it expires and shares one refresh among concurrent callers. +public interface AccessTokenGrant { + /// Exchanges the credentials for a new token. + /// + /// @throws TokenAcquisitionException when no token was issued + AccessToken fetch(); +} diff --git a/vm/backend/src/com/codename1/backend/security/oauth2/client/credentials/AccessTokenProvider.java b/vm/backend/src/com/codename1/backend/security/oauth2/client/credentials/AccessTokenProvider.java new file mode 100644 index 00000000000..b96c4604a75 --- /dev/null +++ b/vm/backend/src/com/codename1/backend/security/oauth2/client/credentials/AccessTokenProvider.java @@ -0,0 +1,248 @@ +/* + * 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.backend.security.oauth2.client.credentials; + +import com.codename1.backend.VirtualThread; +import com.codename1.backend.security.Clock; +import com.codename1.impl.backend.BackendAccess; + +/// An access token for calling another service, kept fresh. +/// +/// Wraps an [AccessTokenGrant] and holds the one token it last issued. That +/// token is handed out until `refreshSkewSeconds` before it expires (60 by +/// default), so a request that leaves with it still has that margin for clock +/// differences and time in flight; then the next caller refreshes it. +/// +/// Concurrent callers share the refresh work. Exactly one runs the exchange; the +/// others keep using the current token while it has not expired, and wait for the +/// exchange only when there is no unexpired token to give them. A failed refresh +/// is shared the same way: the waiters get the failure, not an expired token. +/// +/// A failure is not retried on the spot. Retrying in a loop would hold the +/// request that hit it, and on a virtual thread hold its host, for the whole +/// backoff. Instead the provider backs off: after `n` consecutive failures no +/// exchange is attempted for `min(maxBackoff, 2^(n-1))` seconds, jittered +/// downwards by up to half, and callers in that window get the token still in +/// hand if it has not expired, or the last failure if it has. The first success +/// resets the count. A refused credential (`invalid_client`, `invalid_grant`) +/// backs off the same way, so a revoked key cannot turn every request into an +/// exchange. +/// +/// One provider holds one token, so its memory is bounded by construction; a +/// server that calls several services, or one service with several scope sets, +/// keeps one provider for each. +public final class AccessTokenProvider { + private static final long WAIT_TIMEOUT_MILLIS = 30000; + + private final AccessTokenGrant grant; + private Clock clock = Clock.SYSTEM; + private long refreshSkewMillis = 60000; + private long maxBackoffMillis = 60000; + + private AccessToken current; + private long currentFetchedAt; + private boolean refreshing; + private int failures; + private long nextAttemptAt; + private TokenAcquisitionException lastFailure; + private long jitterSeed = 0x2545F4914F6CDD1DL; + + public AccessTokenProvider(AccessTokenGrant grant) { + if (grant == null) { + throw new IllegalArgumentException("A grant is required"); + } + this.grant = grant; + } + + public synchronized void setClock(Clock clock) { + this.clock = clock == null ? Clock.SYSTEM : clock; + } + + /// How long before expiry a token stops being handed out. Default 60. + public synchronized void setRefreshSkewSeconds(long seconds) { + if (seconds < 0) { + throw new IllegalArgumentException("The skew cannot be negative"); + } + this.refreshSkewMillis = seconds * 1000L; + } + + /// The longest pause between failed exchanges. Default 60. + public synchronized void setMaxBackoffSeconds(long seconds) { + if (seconds < 1) { + throw new IllegalArgumentException("The backoff is at least a second"); + } + this.maxBackoffMillis = seconds * 1000L; + } + + /// A token that has not expired. + /// + /// @throws TokenAcquisitionException when none can be had: the exchange + /// failed, or failed recently and the provider is backing off + public AccessToken getToken() { + long waitUntil = -1; + while (true) { + boolean mine = false; + synchronized (this) { + long now = clock.currentTimeMillis(); + if (current != null && now < current.getExpiresAtMillis() - skew()) { + return current; + } + boolean usable = current != null && !current.isExpired(now); + if (refreshing) { + if (usable) { + // Inside the skew but still good: the refresh in flight + // is someone else's, and this caller need not wait for it. + return current; + } + if (waitUntil < 0) { + waitUntil = System.currentTimeMillis() + WAIT_TIMEOUT_MILLIS; + } + if (System.currentTimeMillis() >= waitUntil) { + throw new TokenAcquisitionException("No access token: a refresh has " + + "been running for " + (WAIT_TIMEOUT_MILLIS / 1000) + + " seconds", -1, null, true, null); + } + if (!VirtualThread.isVirtual()) { + try { + wait(Math.max(1, waitUntil - System.currentTimeMillis())); + } catch (InterruptedException err) { + Thread.currentThread().interrupt(); + throw new TokenAcquisitionException("Interrupted while waiting " + + "for an access token", -1, null, true, err); + } + continue; + } + } else if (lastFailure != null && now < nextAttemptAt) { + if (usable) { + return current; + } + throw lastFailure; + } else { + refreshing = true; + mine = true; + } + } + if (mine) { + return refresh(); + } + // A virtual thread, outside the monitor: wait() would block the host + // and every other virtual thread on it, so it naps between looks, as + // AsyncTask.get does. + BackendAccess.get().napUntil(System.currentTimeMillis() + 5); + } + } + + /// The value of the current token, for an `Authorization: Bearer` header. + public String getTokenValue() { + return getToken().getTokenValue(); + } + + /// Forgets the current token, so the next call exchanges again. For a token + /// the other service refused (401) before it expired, such as after a key + /// rotation there. Does not reset a backoff in progress. + public synchronized void invalidate() { + current = null; + } + + /// The skew, but never more than half the current token's lifetime: a + /// token issued for no longer than the skew would otherwise be refreshed + /// on every call. Called with the monitor held. + private long skew() { + long half = (current.getExpiresAtMillis() - currentFetchedAt) / 2; + return half > 0 && half < refreshSkewMillis ? half : refreshSkewMillis; + } + + private AccessToken refresh() { + AccessToken fresh = null; + TokenAcquisitionException failure = null; + Error fatal = null; + try { + fresh = grant.fetch(); + } catch (TokenAcquisitionException err) { + failure = err; + } catch (RuntimeException err) { + // A grant's own bug, or a signing failure: reported without its + // message, which the provider cannot vouch for. + // Not even as the cause: a stack trace prints the cause's message. + failure = new TokenAcquisitionException("The grant failed: " + + err.getClass().getName(), -1, null, false, null); + } catch (Error err) { + // Recorded like any failure, so the flag below is cleared and the + // waiters woken; otherwise the provider would wait on a refresh + // that ended long ago until the process restarts. Then rethrown. + // Nothing allocated here: an OutOfMemoryError would fail again + // before the flag below is cleared. The failure is built after it. + fatal = err; + } + synchronized (this) { + refreshing = false; + notifyAll(); + long now = clock.currentTimeMillis(); + if (fresh != null) { + current = fresh; + currentFetchedAt = now; + failures = 0; + lastFailure = null; + nextAttemptAt = 0; + return fresh; + } + failures++; + if (fatal != null) { + failure = new TokenAcquisitionException("The grant failed: " + + fatal.getClass().getName(), -1, null, false, null); + } + lastFailure = failure; + nextAttemptAt = now + backoff(failures); + if (fatal != null) { + throw fatal; + } + if (current != null && !current.isExpired(now)) { + return current; + } + throw failure; + } + } + + /// `min(max, 2^(n-1) s)`, minus up to half of it. Jitter keeps a fleet of + /// servers that failed together from retrying together. Not a security + /// random: it only spreads load. + private long backoff(int n) { + long base = 1000L << Math.min(20, n - 1); + long capped = Math.min(maxBackoffMillis, base); + jitterSeed ^= jitterSeed << 13; + jitterSeed ^= jitterSeed >>> 7; + jitterSeed ^= jitterSeed << 17; + long half = capped / 2; + return capped - (half == 0 ? 0 : Math.abs(jitterSeed % half)); + } + + /// Consecutive failed exchanges since the last success. + public synchronized int getConsecutiveFailures() { + return failures; + } + + @Override + public String toString() { + return "AccessTokenProvider[" + grant + "]"; + } +} diff --git a/vm/backend/src/com/codename1/backend/security/oauth2/client/credentials/AccessTokenProviders.java b/vm/backend/src/com/codename1/backend/security/oauth2/client/credentials/AccessTokenProviders.java new file mode 100644 index 00000000000..d85811586a8 --- /dev/null +++ b/vm/backend/src/com/codename1/backend/security/oauth2/client/credentials/AccessTokenProviders.java @@ -0,0 +1,126 @@ +/* + * 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.backend.security.oauth2.client.credentials; + +import com.codename1.backend.Config; +import com.codename1.backend.security.crypto.Jwk; +import com.codename1.backend.security.crypto.KeyFiles; +import com.codename1.backend.security.oauth2.core.OAuth2Parameters; +import java.io.IOException; +import java.util.Set; + +/// Builds an [AccessTokenProvider] from configuration, so the credential lives in +/// a secret store or the environment instead of in code. +/// +/// Every key is under `cn1.security.oauth2.client.credentials..`: +/// +/// | key | meaning | +/// |---|---| +/// | `grant` | `client_credentials`, `jwt_bearer` or `google_service_account` | +/// | `token-uri` | the token endpoint (not used by `google_service_account`) | +/// | `client-id` | the client id (`client_credentials`) | +/// | `client-secret` | the secret, sent as HTTP Basic unless `client-authentication` says `post` | +/// | `client-authentication` | `basic`, `post` or `private_key_jwt` | +/// | `key-file` | PEM private key: `private_key_jwt`, or the `jwt_bearer` signing key | +/// | `key-id` | the `kid` the key is registered under | +/// | `issuer`, `subject`, `audience` | `jwt_bearer` claims; `audience` is also sent by `client_credentials` | +/// | `google-json` | path of a Google service-account key file | +/// | `scopes` | space- or comma-separated scopes | +/// | `scopes-in-assertion` | `jwt_bearer`: true sends the scopes as an assertion claim instead of a form parameter (false) | +/// | `refresh-skew` | seconds before expiry a token stops being used (60) | +/// | `max-backoff` | longest pause between failed exchanges, in seconds (60) | +/// +/// ```java +/// @Bean +/// AccessTokenProvider billing(Config config) throws IOException { +/// return AccessTokenProviders.fromConfig(config, "billing"); +/// } +/// ``` +public final class AccessTokenProviders { + /// The prefix of every key. + public static final String PREFIX = "cn1.security.oauth2.client.credentials."; + + private AccessTokenProviders() { + } + + /// @throws IOException when a file cannot be read or the configuration is + /// incomplete; the message names the missing key, never a value + public static AccessTokenProvider fromConfig(Config config, String name) throws IOException { + String p = PREFIX + name + "."; + String grant = config.get(p + "grant"); + if (grant == null) { + throw new IOException(p + "grant is not set"); + } + Set scopes = OAuth2Parameters.scopes(config.get(p + "scopes", "").replace(',', ' ')); + AccessTokenGrant built; + if ("client_credentials".equals(grant)) { + ClientCredentialsGrant.Builder b = ClientCredentialsGrant.builder( + required(config, p + "token-uri"), required(config, p + "client-id")) + .scopes(scopes).audience(config.get(p + "audience")); + String how = config.get(p + "client-authentication", "basic"); + if ("private_key_jwt".equals(how)) { + b.privateKeyJwt(key(config, p)); + } else if ("post".equals(how)) { + b.clientSecret(required(config, p + "client-secret"), + ClientCredentialsGrant.Authentication.CLIENT_SECRET_POST); + } else if ("basic".equals(how)) { + b.clientSecret(required(config, p + "client-secret")); + } else { + throw new IOException(p + "client-authentication is basic, post or " + + "private_key_jwt, not " + how); + } + built = b.build(); + } else if ("jwt_bearer".equals(grant)) { + built = JwtBearerGrant.builder(required(config, p + "token-uri"), + required(config, p + "issuer"), key(config, p)) + .subject(config.get(p + "subject")).audience(config.get(p + "audience")) + .scopes(scopes) + .scopesInAssertion(config.getBoolean(p + "scopes-in-assertion", false)) + .build(); + } else if ("google_service_account".equals(grant)) { + built = GoogleServiceAccountCredentials.fromFile(required(config, p + "google-json")) + .builder().subject(config.get(p + "subject")).scopes(scopes).build(); + } else { + throw new IOException(p + "grant is client_credentials, jwt_bearer or " + + "google_service_account, not " + grant); + } + AccessTokenProvider provider = new AccessTokenProvider(built); + provider.setRefreshSkewSeconds(config.getInt(p + "refresh-skew", 60)); + provider.setMaxBackoffSeconds(config.getInt(p + "max-backoff", 60)); + return provider; + } + + private static Jwk key(Config config, String p) throws IOException { + Jwk key = Jwk.ofPrivateKey(KeyFiles.readPrivateKey(required(config, p + "key-file"))); + String keyId = config.get(p + "key-id"); + return keyId == null ? key : key.withKeyId(keyId); + } + + private static String required(Config config, String key) throws IOException { + String value = config.get(key); + if (value == null || value.length() == 0) { + throw new IOException(key + " is not set"); + } + return value; + } +} diff --git a/vm/backend/src/com/codename1/backend/security/oauth2/client/credentials/ClientCredentialsGrant.java b/vm/backend/src/com/codename1/backend/security/oauth2/client/credentials/ClientCredentialsGrant.java new file mode 100644 index 00000000000..fed5c17903e --- /dev/null +++ b/vm/backend/src/com/codename1/backend/security/oauth2/client/credentials/ClientCredentialsGrant.java @@ -0,0 +1,225 @@ +/* + * 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.backend.security.oauth2.client.credentials; + +import com.codename1.backend.Base64; +import com.codename1.backend.security.Clock; +import com.codename1.backend.security.crypto.Jwk; +import com.codename1.backend.security.oauth2.core.OAuth2Parameters; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/// The OAuth2 client credentials grant (RFC 6749 section 4.4): this server +/// authenticates as itself and receives a token for its own account. +/// +/// ```java +/// AccessTokenProvider billing = new AccessTokenProvider( +/// ClientCredentialsGrant.builder("https://auth.example.com/oauth/token", "orders") +/// .clientSecret(config.get("billing.secret")) +/// .scopes("invoices:write") +/// .build()); +/// Web.request("POST", url, Arrays.asList("Authorization: " +/// + billing.getToken().toAuthorizationHeader()), body); +/// ``` +/// +/// The client authenticates with a secret in an HTTP Basic header (the default, +/// `client_secret_basic`), with a secret in the form (`client_secret_post`), or +/// with a JWT signed by its own key (`private_key_jwt`, RFC 7523 section 2.2), +/// which sends no shared secret at all. +public final class ClientCredentialsGrant implements AccessTokenGrant { + /// `client_assertion_type` of a `private_key_jwt` client. + public static final String JWT_BEARER_ASSERTION_TYPE = + "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"; + + /// How the client proves who it is. + public enum Authentication { + CLIENT_SECRET_BASIC, + CLIENT_SECRET_POST, + PRIVATE_KEY_JWT + } + + private final TokenEndpoint endpoint; + private final String clientId; + private final String clientSecret; + private final Authentication authentication; + private final JwtAssertion assertion; + private final Set scopes; + private final String audience; + private final String resource; + + private ClientCredentialsGrant(Builder b) { + this.endpoint = new TokenEndpoint(b.tokenUri, b.transport, b.clock); + this.clientId = b.clientId; + this.clientSecret = b.clientSecret; + this.authentication = b.authentication; + this.assertion = b.signingKey == null ? null : new JwtAssertion(b.signingKey); + this.scopes = b.scopes; + this.audience = b.audience; + this.resource = b.resource; + } + + /// @param tokenUri the authorization server's token endpoint + /// @param clientId this server's client id there + public static Builder builder(String tokenUri, String clientId) { + return new Builder(tokenUri, clientId); + } + + @Override + public AccessToken fetch() { + Map form = new LinkedHashMap(); + form.put("grant_type", "client_credentials"); + if (!scopes.isEmpty()) { + form.put("scope", OAuth2Parameters.scopes(scopes)); + } + if (audience != null) { + form.put("audience", audience); + } + if (resource != null) { + form.put("resource", resource); + } + List headers = new ArrayList(); + if (authentication == Authentication.CLIENT_SECRET_BASIC) { + // RFC 6749 section 2.3.1: both halves form-encoded before the colon. + headers.add("Authorization: Basic " + Base64.encode(OAuth2Parameters.utf8( + OAuth2Parameters.encode(clientId) + ":" + + OAuth2Parameters.encode(clientSecret)))); + } else if (authentication == Authentication.CLIENT_SECRET_POST) { + form.put("client_id", clientId); + form.put("client_secret", clientSecret); + } else { + long now = endpoint.getClock().currentTimeMillis() / 1000L; + form.put("client_id", clientId); + form.put("client_assertion_type", JWT_BEARER_ASSERTION_TYPE); + // RFC 7523 section 3: iss and sub are the client, aud the endpoint. + form.put("client_assertion", assertion.sign(clientId, clientId, endpoint.getUri(), + null, now, 300)); + } + return endpoint.exchange(OAuth2Parameters.format(form), headers); + } + + @Override + public String toString() { + return "ClientCredentialsGrant[" + TokenEndpoint.describe(endpoint.getUri()) + ", client=" + + clientId + ", " + authentication + "]"; + } + + public static final class Builder { + private final String tokenUri; + private final String clientId; + private String clientSecret; + private Authentication authentication; + private Jwk signingKey; + private Set scopes = new LinkedHashSet(); + private String audience; + private String resource; + private TokenEndpoint.Transport transport; + private Clock clock; + + Builder(String tokenUri, String clientId) { + if (clientId == null || clientId.length() == 0) { + throw new IllegalArgumentException("A client id is required"); + } + this.tokenUri = tokenUri; + this.clientId = clientId; + } + + /// Authenticates with a secret in an HTTP Basic header. + public Builder clientSecret(String secret) { + return clientSecret(secret, Authentication.CLIENT_SECRET_BASIC); + } + + /// Authenticates with a secret, in a header or in the form. + public Builder clientSecret(String secret, Authentication how) { + if (secret == null || secret.length() == 0) { + throw new IllegalArgumentException("A client secret is required"); + } + if (how == Authentication.PRIVATE_KEY_JWT) { + throw new IllegalArgumentException("private_key_jwt takes a key, not a secret"); + } + this.clientSecret = secret; + this.authentication = how; + this.signingKey = null; + return this; + } + + /// Authenticates with a JWT signed by `privateKey` (`private_key_jwt`). + /// Give the key the id the authorization server registered it under with + /// [Jwk#withKeyId(String)]. + public Builder privateKeyJwt(Jwk privateKey) { + JwtAssertion.algorithmFor(privateKey); + if (!privateKey.isPrivate()) { + throw new IllegalArgumentException("private_key_jwt signs with a private key"); + } + this.signingKey = privateKey; + this.authentication = Authentication.PRIVATE_KEY_JWT; + this.clientSecret = null; + return this; + } + + public Builder scopes(String... scopes) { + return scopes(Arrays.asList(scopes)); + } + + public Builder scopes(Collection scopes) { + this.scopes = new LinkedHashSet(scopes); + return this; + } + + /// An `audience` parameter, which Auth0 and Okta use to name the API the + /// token is for. Not part of RFC 6749; omitted unless set. + public Builder audience(String audience) { + this.audience = audience; + return this; + } + + /// A `resource` parameter (RFC 8707). Omitted unless set. + public Builder resource(String resource) { + this.resource = resource; + return this; + } + + public Builder transport(TokenEndpoint.Transport transport) { + this.transport = transport; + return this; + } + + public Builder clock(Clock clock) { + this.clock = clock; + return this; + } + + public ClientCredentialsGrant build() { + if (authentication == null) { + throw new IllegalStateException("The client authenticates with clientSecret(...) " + + "or privateKeyJwt(...)"); + } + return new ClientCredentialsGrant(this); + } + } +} diff --git a/vm/backend/src/com/codename1/backend/security/oauth2/client/credentials/GoogleServiceAccountCredentials.java b/vm/backend/src/com/codename1/backend/security/oauth2/client/credentials/GoogleServiceAccountCredentials.java new file mode 100644 index 00000000000..f2f89e09fe2 --- /dev/null +++ b/vm/backend/src/com/codename1/backend/security/oauth2/client/credentials/GoogleServiceAccountCredentials.java @@ -0,0 +1,151 @@ +/* + * 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.backend.security.oauth2.client.credentials; + +import com.codename1.backend.Json; +import com.codename1.backend.security.crypto.Jwk; +import com.codename1.backend.security.crypto.KeyFiles; +import java.io.IOException; +import java.util.Arrays; +import java.util.Collection; +import java.util.Map; + +/// A Google service account, read from the JSON key file the Cloud console +/// downloads, as a [JwtBearerGrant]. No Google library is involved: the key is +/// the file's PKCS#8 PEM, the assertion is signed by this runtime's own RSA, and +/// the exchange is one POST. +/// +/// ```java +/// AccessTokenProvider storage = new AccessTokenProvider( +/// GoogleServiceAccountCredentials.fromFile("/etc/app/service-account.json") +/// .grant("https://www.googleapis.com/auth/devstorage.read_only")); +/// Web.Result listing = Web.getJson( +/// "https://storage.googleapis.com/storage/v1/b/my-bucket/o", +/// storage.getToken().getTokenValue()); +/// ``` +/// +/// The key file is a credential: keep it out of the repository and readable by +/// the server's user alone. [#toString()] names the account, never the key. +public final class GoogleServiceAccountCredentials { + /// Where Google exchanges an assertion when the file does not say. + public static final String TOKEN_URI = "https://oauth2.googleapis.com/token"; + + private final String clientEmail; + private final Jwk key; + private final String tokenUri; + private final String projectId; + + private GoogleServiceAccountCredentials(String clientEmail, Jwk key, String tokenUri, + String projectId) { + this.clientEmail = clientEmail; + this.key = key; + this.tokenUri = tokenUri; + this.projectId = projectId; + } + + /// Reads a service-account key file. + /// + /// @throws IOException when it cannot be read, or is not a service-account key + public static GoogleServiceAccountCredentials fromFile(String path) throws IOException { + return fromJson(KeyFiles.read(path)); + } + + /// Reads the contents of a service-account key file. + /// + /// @throws IOException when it is not a service-account key; the message + /// names the field that is wrong and never repeats the key + public static GoogleServiceAccountCredentials fromJson(String json) throws IOException { + Map document; + try { + document = Json.parseObject(json); + } catch (RuntimeException malformed) { + throw new IOException("The service-account key is not JSON", malformed); + } + if (!"service_account".equals(document.get("type"))) { + throw new IOException("The JSON is not a service-account key: its type is " + + (document.get("type") instanceof String ? document.get("type") + : "missing")); + } + String email = text(document, "client_email"); + String pem = text(document, "private_key"); + if (email == null || pem == null) { + throw new IOException("The service-account key has no client_email or no " + + "private_key"); + } + Jwk key; + try { + key = Jwk.ofPrivateKey(KeyFiles.privateKey(pem)); + } catch (IOException unreadable) { + // KeyFiles' message describes the PEM's shape, not its contents. + throw new IOException("The service-account private_key could not be read: " + + unreadable.getMessage(), unreadable); + } + String keyId = text(document, "private_key_id"); + if (keyId != null) { + key = key.withKeyId(keyId); + } + String tokenUri = text(document, "token_uri"); + return new GoogleServiceAccountCredentials(email, key, + tokenUri == null ? TOKEN_URI : tokenUri, text(document, "project_id")); + } + + private static String text(Map document, String name) { + Object value = document.get(name); + return value instanceof String && ((String) value).length() > 0 ? (String) value : null; + } + + public String getClientEmail() { + return clientEmail; + } + + public String getProjectId() { + return projectId; + } + + public String getTokenUri() { + return tokenUri; + } + + /// A grant for these scopes, acting as the service account itself. + public JwtBearerGrant grant(String... scopes) { + return builder().scopes(Arrays.asList(scopes)).build(); + } + + /// A grant for these scopes, acting as the service account itself. + public JwtBearerGrant grant(Collection scopes) { + return builder().scopes(scopes).build(); + } + + /// A builder preset for this account, for a subject (domain-wide delegation), + /// a transport or a clock. + public JwtBearerGrant.Builder builder() { + // Google reads the scopes from the assertion, not from the form. + return JwtBearerGrant.builder(tokenUri, clientEmail, key).audience(tokenUri) + .scopesInAssertion(true); + } + + @Override + public String toString() { + return "GoogleServiceAccountCredentials[" + clientEmail + "]"; + } +} diff --git a/vm/backend/src/com/codename1/backend/security/oauth2/client/credentials/JwtAssertion.java b/vm/backend/src/com/codename1/backend/security/oauth2/client/credentials/JwtAssertion.java new file mode 100644 index 00000000000..20c8a5e819c --- /dev/null +++ b/vm/backend/src/com/codename1/backend/security/oauth2/client/credentials/JwtAssertion.java @@ -0,0 +1,86 @@ +/* + * 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.backend.security.oauth2.client.credentials; + +import com.codename1.backend.security.crypto.Jwk; +import com.codename1.backend.security.crypto.JwkSet; +import com.codename1.backend.security.oauth2.core.OAuth2Parameters; +import com.codename1.backend.security.oauth2.jose.jws.SignatureAlgorithm; +import com.codename1.backend.security.oauth2.jwt.DefaultJwtEncoder; +import com.codename1.backend.security.oauth2.jwt.JwsHeader; +import com.codename1.backend.security.oauth2.jwt.JwtClaimsSet; +import com.codename1.backend.security.oauth2.jwt.JwtEncoderParameters; +import java.util.Collection; + +/// Signs the short-lived JWTs of RFC 7523: the assertion a JWT bearer grant +/// exchanges, and the `private_key_jwt` client authentication. +final class JwtAssertion { + private final Jwk key; + private final SignatureAlgorithm algorithm; + private final DefaultJwtEncoder encoder; + + JwtAssertion(Jwk key) { + if (key == null || !key.isPrivate()) { + throw new IllegalArgumentException("An assertion is signed with a private key"); + } + this.key = key; + this.algorithm = algorithmFor(key); + this.encoder = new DefaultJwtEncoder(JwkSet.of(key)); + } + + static SignatureAlgorithm algorithmFor(Jwk key) { + if ("RSA".equals(key.getKeyType())) { + return SignatureAlgorithm.RS256; + } + if ("EC".equals(key.getKeyType()) && "P-256".equals(key.getCurve())) { + return SignatureAlgorithm.ES256; + } + if ("EC".equals(key.getKeyType()) && "P-384".equals(key.getCurve())) { + return SignatureAlgorithm.ES384; + } + throw new IllegalArgumentException("An assertion is signed with an RSA, P-256 or " + + "P-384 key, not " + key.getKeyType()); + } + + /// @param issuer `iss` + /// @param subject `sub` + /// @param audience `aud`, normally the token endpoint + /// @param scopes a `scope` claim, as Google's JWT bearer flow wants; null for none + /// @param nowSeconds `iat` + /// @param lifetimeSeconds how long after `iat` the assertion expires + String sign(String issuer, String subject, String audience, Collection scopes, + long nowSeconds, long lifetimeSeconds) { + JwtClaimsSet.Builder claims = JwtClaimsSet.builder().issuer(issuer).subject(subject) + .audience(audience).issuedAt(nowSeconds).expiresAt(nowSeconds + lifetimeSeconds) + .id(OAuth2Parameters.random(16)); + if (scopes != null && !scopes.isEmpty()) { + claims.claim("scope", OAuth2Parameters.scopes(scopes)); + } + JwsHeader.Builder header = JwsHeader.with(algorithm); + if (key.getKeyId() != null) { + header.keyId(key.getKeyId()); + } + return encoder.encode(JwtEncoderParameters.from(header.build(), claims.build())) + .getTokenValue(); + } +} diff --git a/vm/backend/src/com/codename1/backend/security/oauth2/client/credentials/JwtBearerGrant.java b/vm/backend/src/com/codename1/backend/security/oauth2/client/credentials/JwtBearerGrant.java new file mode 100644 index 00000000000..b076df50589 --- /dev/null +++ b/vm/backend/src/com/codename1/backend/security/oauth2/client/credentials/JwtBearerGrant.java @@ -0,0 +1,171 @@ +/* + * 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.backend.security.oauth2.client.credentials; + +import com.codename1.backend.security.Clock; +import com.codename1.backend.security.crypto.Jwk; +import com.codename1.backend.security.oauth2.core.OAuth2Parameters; +import java.util.Arrays; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Set; + +/// The JWT bearer grant of RFC 7523 section 2.1: this server signs an assertion +/// with a service account's private key and exchanges it for an access token. +/// No client secret exists; the key is the credential. +/// +/// It is how Google service accounts authenticate (see +/// [GoogleServiceAccountCredentials]), and Salesforce, DocuSign and others accept +/// it the same way. Every exchange signs a new assertion, valid for +/// `assertionLifetimeSeconds` (one hour by default, the most Google accepts) and +/// carrying a fresh `jti`. +public final class JwtBearerGrant implements AccessTokenGrant { + /// The `grant_type` of this grant. + public static final String GRANT_TYPE = "urn:ietf:params:oauth:grant-type:jwt-bearer"; + + private final TokenEndpoint endpoint; + private final JwtAssertion assertion; + private final String issuer; + private final String subject; + private final String audience; + private final Set scopes; + private final boolean scopesInAssertion; + private final long assertionLifetimeSeconds; + + private JwtBearerGrant(Builder b) { + this.endpoint = new TokenEndpoint(b.tokenUri, b.transport, b.clock); + this.assertion = new JwtAssertion(b.signingKey); + this.issuer = b.issuer; + this.subject = b.subject == null ? b.issuer : b.subject; + this.audience = b.audience == null ? b.tokenUri : b.audience; + this.scopes = b.scopes; + this.scopesInAssertion = b.scopesInAssertion; + this.assertionLifetimeSeconds = b.assertionLifetimeSeconds; + } + + /// @param tokenUri the token endpoint the assertion is exchanged at + /// @param issuer `iss`: the service account, such as its client email + /// @param signingKey the service account's private key; its id, if it has + /// one, goes into the header as `kid` + public static Builder builder(String tokenUri, String issuer, Jwk signingKey) { + return new Builder(tokenUri, issuer, signingKey); + } + + @Override + public AccessToken fetch() { + long now = endpoint.getClock().currentTimeMillis() / 1000L; + String signed = assertion.sign(issuer, subject, audience, + scopesInAssertion ? scopes : null, now, assertionLifetimeSeconds); + Map form = new LinkedHashMap(); + form.put("grant_type", GRANT_TYPE); + form.put("assertion", signed); + if (!scopesInAssertion && !scopes.isEmpty()) { + form.put("scope", OAuth2Parameters.scopes(scopes)); + } + return endpoint.exchange(OAuth2Parameters.format(form), null); + } + + @Override + public String toString() { + return "JwtBearerGrant[" + TokenEndpoint.describe(endpoint.getUri()) + ", iss=" + issuer + + ", sub=" + subject + "]"; + } + + public static final class Builder { + private final String tokenUri; + private final String issuer; + private final Jwk signingKey; + private String subject; + private String audience; + private Set scopes = new LinkedHashSet(); + private boolean scopesInAssertion; + private long assertionLifetimeSeconds = 3600; + private TokenEndpoint.Transport transport; + private Clock clock; + + Builder(String tokenUri, String issuer, Jwk signingKey) { + if (issuer == null || issuer.length() == 0) { + throw new IllegalArgumentException("An assertion needs an issuer"); + } + JwtAssertion.algorithmFor(signingKey); + this.tokenUri = tokenUri; + this.issuer = issuer; + this.signingKey = signingKey; + } + + /// `sub`: whom the token acts for. The issuer itself unless set; for a + /// Google Workspace domain-wide delegation, the user being impersonated. + public Builder subject(String subject) { + this.subject = subject; + return this; + } + + /// `aud`: the token endpoint unless set. + public Builder audience(String audience) { + this.audience = audience; + return this; + } + + public Builder scopes(String... scopes) { + return scopes(Arrays.asList(scopes)); + } + + public Builder scopes(Collection scopes) { + this.scopes = new LinkedHashSet(scopes); + return this; + } + + /// Where the scopes go: a `scope` form parameter (false, the default, + /// as RFC 7521 section 4.1 and RFC 6749 section 3.3 define it) or a + /// `scope` claim in the assertion (true, as Google expects; the Google + /// builder sets it). + public Builder scopesInAssertion(boolean inAssertion) { + this.scopesInAssertion = inAssertion; + return this; + } + + public Builder assertionLifetimeSeconds(long seconds) { + if (seconds < 1) { + throw new IllegalArgumentException("An assertion lives at least a second"); + } + this.assertionLifetimeSeconds = seconds; + return this; + } + + public Builder transport(TokenEndpoint.Transport transport) { + this.transport = transport; + return this; + } + + public Builder clock(Clock clock) { + this.clock = clock; + return this; + } + + public JwtBearerGrant build() { + return new JwtBearerGrant(this); + } + } +} diff --git a/vm/backend/src/com/codename1/backend/security/oauth2/client/credentials/TokenAcquisitionException.java b/vm/backend/src/com/codename1/backend/security/oauth2/client/credentials/TokenAcquisitionException.java new file mode 100644 index 00000000000..2deda89610b --- /dev/null +++ b/vm/backend/src/com/codename1/backend/security/oauth2/client/credentials/TokenAcquisitionException.java @@ -0,0 +1,68 @@ +/* + * 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.backend.security.oauth2.client.credentials; + +/// A token endpoint did not issue a token. +/// +/// The message names the endpoint, the HTTP status and the OAuth `error` code, +/// and nothing else. The request carried a client secret or a signed assertion +/// and the response may carry a token, so neither body is ever copied into it: +/// this exception is written to logs and error pages by code that has no idea +/// what it holds. +public class TokenAcquisitionException extends RuntimeException { + private static final long serialVersionUID = 1L; + + private final int status; + private final String errorCode; + private final boolean retryable; + + /// @param message what failed, with no secret in it + /// @param status the HTTP status, or -1 when the endpoint was not reached + /// @param errorCode the OAuth error code the endpoint gave, or null + /// @param retryable whether trying again later can succeed + /// @param cause the failure underneath, or null + public TokenAcquisitionException(String message, int status, String errorCode, + boolean retryable, Throwable cause) { + super(message, cause); + this.status = status; + this.errorCode = errorCode; + this.retryable = retryable; + } + + /// The HTTP status, or -1 when the endpoint was not reached. + public int getStatus() { + return status; + } + + /// The OAuth `error` code, such as `invalid_grant`, or null. + public String getErrorCode() { + return errorCode; + } + + /// True for a failure that can go away by itself: the endpoint could not be + /// reached, answered 5xx, or asked to slow down with 429. A refused + /// credential (`invalid_client`, `invalid_grant`) is not. + public boolean isRetryable() { + return retryable; + } +} diff --git a/vm/backend/src/com/codename1/backend/security/oauth2/client/credentials/TokenEndpoint.java b/vm/backend/src/com/codename1/backend/security/oauth2/client/credentials/TokenEndpoint.java new file mode 100644 index 00000000000..34626c7cc38 --- /dev/null +++ b/vm/backend/src/com/codename1/backend/security/oauth2/client/credentials/TokenEndpoint.java @@ -0,0 +1,195 @@ +/* + * 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.backend.security.oauth2.client.credentials; + +import com.codename1.backend.Json; +import com.codename1.backend.Web; +import com.codename1.backend.security.Clock; +import com.codename1.backend.security.oauth2.client.DefaultAuthorizationCodeTokenResponseClient; +import com.codename1.backend.security.oauth2.client.OAuth2AccessTokenResponse; +import com.codename1.backend.security.oauth2.core.OAuth2Parameters; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/// A token endpoint: where a grant posts its form, and how the answer is read. +/// +/// The transport is replaceable so a grant can be exercised without a socket -- +/// the tests and the native self-check post to an in-process fake and assert on +/// the exact form that was sent. +public final class TokenEndpoint { + /// Lifetime assumed for a token whose response gives no `expires_in`. RFC 6749 + /// leaves the default to the server; assuming a short one costs an extra + /// exchange now and then, assuming a long one presents a dead token. + public static final long DEFAULT_LIFETIME_SECONDS = 300; + + /// Sends one form-encoded POST. + public interface Transport { + /// @param uri the token endpoint + /// @param headers `Name: value` lines + /// @param form the `application/x-www-form-urlencoded` body + /// @return what the endpoint answered + /// @throws IOException when it could not be reached + Response post(String uri, List headers, String form) throws IOException; + } + + /// A token endpoint's answer. + public static final class Response { + private final int status; + private final String body; + + public Response(int status, String body) { + this.status = status; + this.body = body; + } + + public int getStatus() { + return status; + } + + public String getBody() { + return body; + } + } + + /// The transport every grant uses unless it is given another: [Web]. + public static final Transport WEB = new Transport() { + @Override + public Response post(String uri, List headers, String form) throws IOException { + Web.Result result = Web.request("POST", uri, headers, OAuth2Parameters.utf8(form)); + return new Response(result.getStatus(), result.getBodyAsString()); + } + }; + + private final String uri; + private final Transport transport; + private final Clock clock; + + public TokenEndpoint(String uri, Transport transport, Clock clock) { + if (uri == null || uri.length() == 0) { + throw new IllegalArgumentException("A token endpoint URI is required"); + } + this.uri = uri; + this.transport = transport == null ? WEB : transport; + this.clock = clock == null ? Clock.SYSTEM : clock; + } + + public String getUri() { + return uri; + } + + Clock getClock() { + return clock; + } + + /// Posts `form` and reads the token out of the answer. + /// + /// @param extraHeaders more `Name: value` lines, such as `Authorization` + /// @throws TokenAcquisitionException when no token came back; its message + /// carries no part of either body + AccessToken exchange(String form, List extraHeaders) { + List headers = new ArrayList(); + headers.add("Content-Type: application/x-www-form-urlencoded"); + headers.add("Accept: application/json"); + if (extraHeaders != null) { + headers.addAll(extraHeaders); + } + String where = describe(uri); + Response response; + try { + response = transport.post(uri, headers, form); + } catch (IOException err) { + // The cause stays attached for whoever debugs the network; its + // message is the transport's, which never sees the form. + throw new TokenAcquisitionException("The token endpoint " + where + + " could not be reached", -1, null, true, err); + } + int status = response.getStatus(); + Map fields = parse(response.getBody()); + Object error = fields == null ? null : fields.get("error"); + if (status < 200 || status > 299 || error != null) { + String code = error == null ? null + : DefaultAuthorizationCodeTokenResponseClient.sanitizeErrorCode( + String.valueOf(error)); + boolean retryable = status >= 500 || status == 429 || status < 0; + throw new TokenAcquisitionException("The token endpoint " + where + " answered " + + status + (code == null ? "" : " (" + code + ")"), status, code, + retryable, null); + } + if (fields == null) { + throw new TokenAcquisitionException("The token endpoint " + where + " answered " + + status + " with something that is not a token response", status, null, + false, null); + } + OAuth2AccessTokenResponse token = new OAuth2AccessTokenResponse(fields); + if (token.getAccessToken() == null) { + throw new TokenAcquisitionException("The token endpoint " + where + + " answered without an access token", status, null, false, null); + } + long lifetime = token.getExpiresIn() > 0 ? token.getExpiresIn() + : DEFAULT_LIFETIME_SECONDS; + return new AccessToken(token.getAccessToken(), + token.getTokenType() == null ? "Bearer" : token.getTokenType(), + clock.currentTimeMillis() + lifetime * 1000L, token.getScopes()); + } + + private static Map parse(String body) { + if (body == null) { + return null; + } + String trimmed = body.trim(); + if (!trimmed.startsWith("{")) { + return null; + } + try { + return Json.parseObject(trimmed); + } catch (IOException malformed) { + return null; + } catch (RuntimeException malformed) { + return null; + } + } + + /// The endpoint as it may be printed: scheme, host and path, without any + /// user info or query, either of which can hold a credential. + static String describe(String uri) { + String text = uri; + int query = text.indexOf('?'); + if (query >= 0) { + text = text.substring(0, query); + } + int fragment = text.indexOf('#'); + if (fragment >= 0) { + text = text.substring(0, fragment); + } + int scheme = text.indexOf("://"); + int hostStart = scheme < 0 ? 0 : scheme + 3; + int at = text.indexOf('@', hostStart); + int slash = text.indexOf('/', hostStart); + if (at >= 0 && (slash < 0 || at < slash)) { + text = text.substring(0, hostStart) + text.substring(at + 1); + } + return text; + } +} diff --git a/vm/backend/src/com/codename1/backend/security/oauth2/client/credentials/package-info.java b/vm/backend/src/com/codename1/backend/security/oauth2/client/credentials/package-info.java new file mode 100644 index 00000000000..648461d80e7 --- /dev/null +++ b/vm/backend/src/com/codename1/backend/security/oauth2/client/credentials/package-info.java @@ -0,0 +1,33 @@ +/* + * 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. + */ +/// Access tokens this server obtains to call other services: the OAuth2 client +/// credentials grant, the JWT bearer grant of RFC 7523, and Google service +/// accounts, without a provider SDK. +/// +/// A grant ([com.codename1.backend.security.oauth2.client.credentials.ClientCredentialsGrant], +/// [com.codename1.backend.security.oauth2.client.credentials.JwtBearerGrant]) performs +/// one exchange; an +/// [com.codename1.backend.security.oauth2.client.credentials.AccessTokenProvider] +/// caches its token, refreshes it ahead of expiry, shares a refresh among +/// concurrent callers and backs off after a failure. +package com.codename1.backend.security.oauth2.client.credentials; diff --git a/vm/backend/src/com/codename1/backend/security/oauth2/server/authorization/AuthorizationServerKeys.java b/vm/backend/src/com/codename1/backend/security/oauth2/server/authorization/AuthorizationServerKeys.java index cd4b0eadc5c..bf02dbb5220 100644 --- a/vm/backend/src/com/codename1/backend/security/oauth2/server/authorization/AuthorizationServerKeys.java +++ b/vm/backend/src/com/codename1/backend/security/oauth2/server/authorization/AuthorizationServerKeys.java @@ -50,6 +50,21 @@ /// openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out signing.pem /// ``` /// +/// A key brought over from another authorization server keeps the `kid` its +/// tokens already carry, or none of them would find their key. Name the ids in +/// the same order as the files; an empty entry keeps the derived id: +/// +/// ``` +/// cn1.security.authorizationserver.jwk.keys=/etc/acme/new.pem,/etc/acme/legacy.pem +/// cn1.security.authorizationserver.jwk.key-ids=,legacy-2024-rsa +/// ``` +/// +/// A key whose private half stayed behind -- the old provider's public JWK Set +/// or a public PEM -- can still verify the tokens it signed. List it under +/// `cn1.security.authorizationserver.jwk.verification-keys`: it is published +/// and verifies, and never signs. A `.json` file is read as a JWK Set and keeps +/// each key's `kid`; any other file is a public key PEM under its derived id. +/// /// On a development profile with no key set, a key is made when the server /// starts and a warning says so: every restart then invalidates every token. /// Anywhere else a missing key stops the server from starting. @@ -72,6 +87,12 @@ public final class AuthorizationServerKeys { /// The setting that lists the key files, separated by commas. public static final String KEYS = "cn1.security.authorizationserver.jwk.keys"; + /// The `kid` of each key in [#KEYS], in the same order; an empty entry keeps + /// the id derived from the public key. + public static final String KEY_IDS = "cn1.security.authorizationserver.jwk.key-ids"; + /// Public keys that verify and are published, and never sign. + public static final String VERIFICATION_KEYS = + "cn1.security.authorizationserver.jwk.verification-keys"; private AuthorizationServerKeys() { } @@ -83,20 +104,20 @@ private AuthorizationServerKeys() { public static JwkSource load(Config config) throws IOException { String listed = config.get(KEYS); final List keys = new ArrayList(); - if (listed != null) { - int start = 0; - while (start <= listed.length()) { - int comma = listed.indexOf(',', start); - int end = comma < 0 ? listed.length() : comma; - String path = listed.substring(start, end).trim(); - if (path.length() > 0) { - keys.add(usable(Jwk.ofPrivateKey(KeyFiles.readPrivateKey(path)), path)); - } - if (comma < 0) { - break; - } - start = comma + 1; + List paths = entries(listed); + List ids = entries(config.get(KEY_IDS)); + if (ids.size() > paths.size()) { + throw new IllegalStateException(KEY_IDS + " names " + ids.size() + " ids for " + + paths.size() + " keys in " + KEYS); + } + for (int iter = 0 ; iter < paths.size() ; iter++) { + String path = paths.get(iter); + if (path.length() == 0) { + continue; } + Jwk key = Jwk.ofPrivateKey(KeyFiles.readPrivateKey(path)); + String id = iter < ids.size() ? ids.get(iter) : ""; + keys.add(usable(id.length() == 0 ? key : key.withKeyId(id), path)); } if (keys.isEmpty()) { if (!config.isDevelopmentProfile()) { @@ -110,9 +131,61 @@ public static JwkSource load(Config config) throws IOException { + "verifying when this process ends. Development only."); keys.add(usable(Jwk.ofPrivateKey(Crypto.generateRsaKey(2048)), "the generated key")); } + for (String path : entries(config.get(VERIFICATION_KEYS))) { + if (path.length() == 0) { + continue; + } + if (path.endsWith(".json")) { + for (Jwk key : JwkSet.parse(KeyFiles.read(path)).getKeys()) { + keys.add(verifying(key, path)); + } + } else { + // Read as either kind, so a private key is refused by name rather than + // as a file that failed to parse. + keys.add(verifying(Jwk.ofPem(KeyFiles.read(path)), path)); + } + } return of(keys); } + /// The comma-separated entries of a setting, trimmed, empty ones kept so that + /// a list of ids lines up with its list of files. + private static List entries(String listed) { + List out = new ArrayList(); + if (listed == null || listed.trim().length() == 0) { + return out; + } + int start = 0; + while (true) { + int comma = listed.indexOf(',', start); + int end = comma < 0 ? listed.length() : comma; + out.add(listed.substring(start, end).trim()); + if (comma < 0) { + return out; + } + start = comma + 1; + } + } + + /// A public key brought along to verify tokens it signed elsewhere. + private static Jwk verifying(Jwk key, String where) { + String algorithm = algorithm(key); + if (algorithm == null) { + throw new IllegalStateException("The authorization server verifies with an RSA or " + + "a P-256 key, and " + where + " holds one that is neither"); + } + if (key.isPrivate()) { + throw new IllegalStateException(VERIFICATION_KEYS + " takes public keys, and " + where + + " holds a private one; list it in " + KEYS + " to sign with it"); + } + if (key.getUse() != null && !"sig".equals(key.getUse())) { + throw new IllegalStateException(where + " holds a key for use \"" + key.getUse() + + "\"; only signature keys verify tokens"); + } + return key.withUse("sig").withAlgorithm( + key.getAlgorithm() == null ? algorithm : key.getAlgorithm()); + } + /// A source of exactly these keys, the signing one first. public static JwkSource of(List keys) { final List fixed = JwkSet.of(keys.toArray(new Jwk[keys.size()])).getKeys(); diff --git a/vm/backend/src/com/codename1/backend/security/webauthn/CoseKey.java b/vm/backend/src/com/codename1/backend/security/webauthn/CoseKey.java index 4829aad17ba..71afa2ec1e0 100644 --- a/vm/backend/src/com/codename1/backend/security/webauthn/CoseKey.java +++ b/vm/backend/src/com/codename1/backend/security/webauthn/CoseKey.java @@ -64,6 +64,18 @@ private CoseKey(long algorithm, byte[] publicKey) { this.publicKey = publicKey; } + /// The key a COSE_Key encodes, as another relying party stored it: what a + /// migration of passkeys reads. + /// + /// - [WebAuthnException]: when it is not CBOR, or not a key of a supported + /// algorithm + public static CoseKey parse(byte[] coseKey) { + if (coseKey == null || coseKey.length == 0) { + throw invalid("The credential's public key is empty"); + } + return of(Cbor.decode(coseKey)); + } + /// The key a CBOR map decodes to. /// /// - [WebAuthnException]: when it is not a key of a supported algorithm