diff --git a/apps/backend-api/src/main/java/com/idea2strategy/backend/api/identity/DeviceAuthorizationController.java b/apps/backend-api/src/main/java/com/idea2strategy/backend/api/identity/DeviceAuthorizationController.java new file mode 100644 index 00000000..6b45a7c2 --- /dev/null +++ b/apps/backend-api/src/main/java/com/idea2strategy/backend/api/identity/DeviceAuthorizationController.java @@ -0,0 +1,124 @@ +package com.idea2strategy.backend.api.identity; + +import com.idea2strategy.backend.application.common.CurrentPrincipal; +import com.idea2strategy.backend.application.identity.DeviceAuthorizationOutcome; +import com.idea2strategy.backend.application.identity.DeviceAuthorizationService; +import com.idea2strategy.backend.application.identity.EmailAuthenticationService; +import java.time.Instant; +import java.util.UUID; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * Signing a command-line client in through the browser. + * + *
The CLI never sees a password. It asks for a pair of codes, shows the short one, and polls;
+ * the customer approves in a browser session that already holds their credential. That matters most
+ * when something else is driving the CLI — an agent told to "set it up" would otherwise have to be
+ * handed the password itself.
+ */
+@RestController
+@RequestMapping("/api/v1/auth/device")
+@ConditionalOnProperty(name = {"spring.datasource.url", "identity.crypto.customer-jwt-signing-key"})
+public class DeviceAuthorizationController {
+ private final DeviceAuthorizationService devices;
+ private final EmailAuthenticationService authentication;
+ private final IdentityAuthController tokens;
+ private final CurrentPrincipal principal;
+ private final String verificationUri;
+
+ public DeviceAuthorizationController(
+ DeviceAuthorizationService devices,
+ EmailAuthenticationService authentication,
+ IdentityAuthController tokens,
+ CurrentPrincipal principal,
+ @org.springframework.beans.factory.annotation.Value(
+ "${identity.device-authorization.verification-uri:https://ideatostrategy.com/cli-auth}")
+ String verificationUri) {
+ this.devices = devices;
+ this.authentication = authentication;
+ this.tokens = tokens;
+ this.principal = principal;
+ this.verificationUri = verificationUri;
+ }
+
+ /** Unauthenticated: this is what a client calls before it has any credential at all. */
+ @PostMapping("/authorize")
+ public ResponseEntity The user code is typed by a person, so it is short and drops characters that are misread aloud
+ * or on screen — no 0/O, 1/I, or vowels that could spell something. That deliberately makes it
+ * weak, which is why it can only ever request approval. The device code is a full 256-bit secret
+ * and is the only thing that collects a token.
+ *
+ * Both are stored as digests, so a database reader cannot approve or collect on someone's
+ * behalf, and the short lifetime bounds what a stolen digest could be replayed against.
+ */
+public final class HmacDeviceCodes implements DeviceCodeMaterialPort {
+ private static final SecureRandom RANDOM = new SecureRandom();
+ private static final char[] USER_CODE_ALPHABET = "BCDFGHJKLMNPQRSTVWXZ23456789".toCharArray();
+ private static final int USER_CODE_HALF = 4;
+
+ private final byte[] key;
+ private final short keyVersion;
+
+ public HmacDeviceCodes(byte[] key, short keyVersion) {
+ Objects.requireNonNull(key, "key");
+ if (key.length < 32) {
+ throw new IllegalArgumentException("Device code HMAC key must contain at least 256 bits");
+ }
+ if (keyVersion < 1) {
+ throw new IllegalArgumentException("Device code key version must be positive");
+ }
+ this.key = key.clone();
+ this.keyVersion = keyVersion;
+ }
+
+ @Override
+ public DeviceCodeMaterial issue() {
+ byte[] deviceBytes = new byte[32];
+ RANDOM.nextBytes(deviceBytes);
+ String deviceCode = Base64.getUrlEncoder().withoutPadding().encodeToString(deviceBytes);
+ String userCode = userCode();
+ return new DeviceCodeMaterial(
+ deviceCode, digest(deviceCode), userCode, digest(userCode), keyVersion);
+ }
+
+ @Override
+ public String digestDeviceCode(String deviceCode) {
+ return digest(deviceCode);
+ }
+
+ /** Case and dashes are presentation; a person retyping the code should not fail on either. */
+ @Override
+ public String digestUserCode(String userCode) {
+ return digest(userCode.replace("-", "").trim().toUpperCase(java.util.Locale.ROOT));
+ }
+
+ private String userCode() {
+ StringBuilder code = new StringBuilder(USER_CODE_HALF * 2 + 1);
+ for (int index = 0; index < USER_CODE_HALF * 2; index++) {
+ if (index == USER_CODE_HALF) {
+ code.append('-');
+ }
+ code.append(USER_CODE_ALPHABET[RANDOM.nextInt(USER_CODE_ALPHABET.length)]);
+ }
+ return code.toString();
+ }
+
+ private String digest(String value) {
+ try {
+ Mac mac = Mac.getInstance("HmacSHA256");
+ mac.init(new SecretKeySpec(key, "HmacSHA256"));
+ return Base64.getUrlEncoder()
+ .withoutPadding()
+ .encodeToString(mac.doFinal(value.getBytes(StandardCharsets.UTF_8)));
+ } catch (GeneralSecurityException exception) {
+ throw new IllegalStateException("Device code digest failed", exception);
+ }
+ }
+}
diff --git a/apps/backend-api/src/main/java/com/idea2strategy/backend/api/identity/IdentityAuthConfiguration.java b/apps/backend-api/src/main/java/com/idea2strategy/backend/api/identity/IdentityAuthConfiguration.java
index 3c5df835..21ad577b 100644
--- a/apps/backend-api/src/main/java/com/idea2strategy/backend/api/identity/IdentityAuthConfiguration.java
+++ b/apps/backend-api/src/main/java/com/idea2strategy/backend/api/identity/IdentityAuthConfiguration.java
@@ -56,7 +56,8 @@
AccountLifecycleJpaCommandAdapter.class,
AccountPreferencesConsentJooqAdapter.class,
AccountPreferencesConsentJpaAdapter.class,
- OidcStepUpChallengeJpaAdapter.class
+ OidcStepUpChallengeJpaAdapter.class,
+ com.idea2strategy.backend.persistence.identity.DeviceAuthorizationJooqAdapter.class
})
public class IdentityAuthConfiguration {
/**
@@ -113,6 +114,29 @@ HmacVerificationTokens verificationTokens(
return new HmacVerificationTokens(decode(key));
}
+ @Bean
+ com.idea2strategy.backend.application.identity.DeviceAuthorizationService deviceAuthorizationService(
+ com.idea2strategy.backend.persistence.identity.DeviceAuthorizationJooqAdapter adapter,
+ HmacDeviceCodes deviceCodes,
+ Clock identityClock,
+ @Value("${identity.device-authorization.lifetime:PT10M}") Duration lifetime,
+ @Value("${identity.device-authorization.poll-interval-seconds:5}") short pollIntervalSeconds) {
+ return new com.idea2strategy.backend.application.identity.DeviceAuthorizationService(
+ adapter, deviceCodes, identityClock, lifetime, pollIntervalSeconds);
+ }
+
+ /**
+ * Falls back to the verification key so a deployment without a dedicated device-code key still
+ * stores digests rather than raw codes. A separate key is preferable and is what the property
+ * is for.
+ */
+ @Bean
+ HmacDeviceCodes hmacDeviceCodes(
+ @Value("${identity.crypto.device-code-hmac-key:${identity.crypto.verification-hmac-key}}") String key,
+ @Value("${identity.crypto.device-code-key-version:1}") short keyVersion) {
+ return new HmacDeviceCodes(decode(key), keyVersion);
+ }
+
@Bean
HmacRefreshTokenSecrets refreshTokenSecrets(
@Value("${identity.crypto.refresh-token-hmac-key}") String key) {
diff --git a/apps/idea2strategy-cli/src/main/java/com/idea2strategy/cli/Arguments.java b/apps/idea2strategy-cli/src/main/java/com/idea2strategy/cli/Arguments.java
index 654443e8..2aceb277 100644
--- a/apps/idea2strategy-cli/src/main/java/com/idea2strategy/cli/Arguments.java
+++ b/apps/idea2strategy-cli/src/main/java/com/idea2strategy/cli/Arguments.java
@@ -6,6 +6,15 @@
import java.util.Map;
final class Arguments {
+ /**
+ * Switches that carry no value.
+ *
+ * Named explicitly rather than inferred from "no value follows", because inferring it would
+ * turn a forgotten value — `--email` with nothing after it — into the string "true" and let a
+ * typo log in as nobody.
+ */
+ private static final List The short code is printed for a person to check against what the browser shows; the long
+ * one stays here and is what actually collects the token. Progress is written to standard error
+ * so standard output stays a single JSON document for whatever is parsing it.
+ */
+ private static JsonNode browserLogin(Arguments args, ApiClient api, CredentialStore credentials) {
+ ObjectNode request = JSON.createObjectNode().put("clientLabel", "idea2strategy-cli");
+ JsonNode authorization = api.post("/api/v1/auth/device/authorize", request, null);
+ String deviceCode = authorization.path("deviceCode").asText();
+ String userCode = authorization.path("userCode").asText();
+ String openUri = authorization.path("verificationUriComplete").asText();
+ if (deviceCode.isBlank() || userCode.isBlank() || openUri.isBlank()) {
+ throw new CliFailure(6, "INVALID_SERVER_RESPONSE", "Device authorization response was incomplete");
+ }
+
+ System.err.println("Open " + openUri);
+ System.err.println("Confirm this code in the browser: " + userCode);
+ if (!args.flag("--no-open")) {
+ openInBrowser(openUri);
+ }
+
+ long intervalSeconds = Math.max(1, authorization.path("intervalSeconds").asLong(5));
+ Instant deadline = Instant.now().plusSeconds(600);
+ ObjectNode poll = JSON.createObjectNode().put("deviceCode", deviceCode);
+ while (Instant.now().isBefore(deadline)) {
+ try {
+ Thread.sleep(intervalSeconds * 1000L);
+ } catch (InterruptedException interrupted) {
+ Thread.currentThread().interrupt();
+ throw new CliFailure(70, "INTERRUPTED", "Browser login was interrupted");
+ }
+ // A pending request answers 202, which the client treats as success with no token, so
+ // the blank check below is the wait. Denied, expired, and unknown all arrive as
+ // failures and propagate: none of them will ever turn into an approval, and polling on
+ // would just burn the deadline.
+ JsonNode response = api.post("/api/v1/auth/device/token", poll, null);
+ String token = response.path("accessToken").asText();
+ if (token.isBlank()) {
+ continue;
+ }
+ credentials.save(token);
+ ObjectNode result = JSON.createObjectNode().put("credentialSaved", true);
+ copyIfPresent(response, result, "accountId", "expiresAt");
+ return result;
+ }
+ throw new CliFailure(5, "DEVICE_AUTHORIZATION_TIMED_OUT", "The browser approval was not completed in time");
+ }
+
+ /** Best effort. A headless machine still gets the URI on standard error. */
+ private static void openInBrowser(String uri) {
+ String os = System.getProperty("os.name", "").toLowerCase(java.util.Locale.ROOT);
+ List PENDING and the terminal states are deliberately distinct: a client that cannot tell "not yet"
+ * from "denied" either gives up on a live request or polls a dead one forever.
+ */
+public record DeviceAuthorizationOutcome(Status status, Optional The CLI asks for a pair of codes, shows the short one to a person, and polls with the long
+ * one. The person approves in a browser session that already holds their credential, so the CLI —
+ * and anything driving it — never handles a password.
+ *
+ * The two codes are not interchangeable. The user code is short enough to read aloud, so it is
+ * guessable by construction and only ever identifies a request for approval; the device code is
+ * the secret that collects the token. Swapping their roles would make a shoulder-surfed code
+ * enough to steal a session.
+ */
+public final class DeviceAuthorizationService {
+ private final DeviceAuthorizationCommandPort commands;
+ private final DeviceCodeMaterialPort codes;
+ private final Clock clock;
+ private final Duration lifetime;
+ private final short pollIntervalSeconds;
+
+ public DeviceAuthorizationService(
+ DeviceAuthorizationCommandPort commands,
+ DeviceCodeMaterialPort codes,
+ Clock clock,
+ Duration lifetime,
+ short pollIntervalSeconds) {
+ this.commands = Objects.requireNonNull(commands, "commands");
+ this.codes = Objects.requireNonNull(codes, "codes");
+ this.clock = Objects.requireNonNull(clock, "clock");
+ this.lifetime = Objects.requireNonNull(lifetime, "lifetime");
+ if (pollIntervalSeconds < 1) {
+ throw new IllegalArgumentException("poll interval must be positive");
+ }
+ this.pollIntervalSeconds = pollIntervalSeconds;
+ }
+
+ public DeviceAuthorizationGrant request(String clientLabel) {
+ String label = requireText(clientLabel, "clientLabel");
+ DeviceCodeMaterial material = codes.issue();
+ Instant now = clock.instant();
+ UUID id = commands.create(
+ material.deviceCodeDigest(),
+ material.userCodeDigest(),
+ material.digestKeyVersion(),
+ label,
+ pollIntervalSeconds,
+ now,
+ now.plus(lifetime));
+ return new DeviceAuthorizationGrant(
+ id,
+ material.deviceCode(),
+ material.userCode(),
+ now.plus(lifetime),
+ pollIntervalSeconds);
+ }
+
+ /**
+ * Approval is an authenticated browser action. The account comes from that session and never
+ * from the request body, so a person can only ever approve a device onto their own account.
+ */
+ public void approve(String userCode, UUID accountId) {
+ Objects.requireNonNull(accountId, "accountId");
+ String digest = codes.digestUserCode(requireText(userCode, "userCode"));
+ if (!commands.approve(digest, accountId, clock.instant())) {
+ throw new DeviceAuthorizationRejectedException("No pending device request matches that code");
+ }
+ }
+
+ public void deny(String userCode) {
+ String digest = codes.digestUserCode(requireText(userCode, "userCode"));
+ if (!commands.deny(digest, clock.instant())) {
+ throw new DeviceAuthorizationRejectedException("No pending device request matches that code");
+ }
+ }
+
+ /**
+ * Collecting the token consumes the request. Returning it more than once would leave a
+ * long-lived code that mints sessions, which is the thing a short expiry is meant to prevent.
+ */
+ public DeviceAuthorizationOutcome collect(String deviceCode) {
+ String digest = codes.digestDeviceCode(requireText(deviceCode, "deviceCode"));
+ return commands.consume(digest, clock.instant());
+ }
+
+ private static String requireText(String value, String field) {
+ if (value == null || value.isBlank()) {
+ throw new IllegalArgumentException(field + " is required");
+ }
+ return value.trim();
+ }
+}
diff --git a/modules/backend-application/src/main/java/com/idea2strategy/backend/application/identity/DeviceCodeMaterial.java b/modules/backend-application/src/main/java/com/idea2strategy/backend/application/identity/DeviceCodeMaterial.java
new file mode 100644
index 00000000..91aaae1d
--- /dev/null
+++ b/modules/backend-application/src/main/java/com/idea2strategy/backend/application/identity/DeviceCodeMaterial.java
@@ -0,0 +1,20 @@
+package com.idea2strategy.backend.application.identity;
+
+import java.util.Objects;
+
+public record DeviceCodeMaterial(
+ String deviceCode,
+ String deviceCodeDigest,
+ String userCode,
+ String userCodeDigest,
+ short digestKeyVersion) {
+ public DeviceCodeMaterial {
+ Objects.requireNonNull(deviceCode, "deviceCode");
+ Objects.requireNonNull(deviceCodeDigest, "deviceCodeDigest");
+ Objects.requireNonNull(userCode, "userCode");
+ Objects.requireNonNull(userCodeDigest, "userCodeDigest");
+ if (digestKeyVersion < 1) {
+ throw new IllegalArgumentException("digest key version must be positive");
+ }
+ }
+}
diff --git a/modules/backend-application/src/main/java/com/idea2strategy/backend/application/identity/DeviceCodeMaterialPort.java b/modules/backend-application/src/main/java/com/idea2strategy/backend/application/identity/DeviceCodeMaterialPort.java
new file mode 100644
index 00000000..60cc3c1f
--- /dev/null
+++ b/modules/backend-application/src/main/java/com/idea2strategy/backend/application/identity/DeviceCodeMaterialPort.java
@@ -0,0 +1,9 @@
+package com.idea2strategy.backend.application.identity;
+
+public interface DeviceCodeMaterialPort {
+ DeviceCodeMaterial issue();
+
+ String digestDeviceCode(String deviceCode);
+
+ String digestUserCode(String userCode);
+}
diff --git a/modules/backend-application/src/main/java/com/idea2strategy/backend/application/identity/EmailAuthenticationService.java b/modules/backend-application/src/main/java/com/idea2strategy/backend/application/identity/EmailAuthenticationService.java
index 825692b5..0602f86e 100644
--- a/modules/backend-application/src/main/java/com/idea2strategy/backend/application/identity/EmailAuthenticationService.java
+++ b/modules/backend-application/src/main/java/com/idea2strategy/backend/application/identity/EmailAuthenticationService.java
@@ -2,6 +2,7 @@
import java.time.Clock;
import java.time.Duration;
+import java.time.Instant;
import java.util.Objects;
import java.util.UUID;
@@ -86,6 +87,44 @@ public LoginResult login(LoginCommand command) {
familyId, token.rawToken(), expiresAt);
}
+ /**
+ * Mints the session a device authorization collects.
+ *
+ * Deliberately the same path as a password login: the same refresh family, the same auth
+ * epoch and credential version, the same success record. A device-approved session that did not
+ * carry those would survive a password change, which is exactly what someone revoking access
+ * expects it not to do. The password check is absent because the browser already performed it —
+ * everything after it still applies.
+ */
+ public LoginResult completeApprovedDeviceLogin(UUID accountId, UUID correlationId) {
+ Objects.requireNonNull(accountId, "accountId");
+ var account = queryPort.findPasswordLoginByAccountId(accountId)
+ .orElseThrow(() -> new AuthenticationRejectedException("Account is not available"));
+ if (account.accountStatus() != AccountLifecycleStatus.ACTIVE
+ || account.loginIdentityStatus() != LoginIdentityStatus.ACTIVE) {
+ throw new AuthenticationRejectedException("Account is not active");
+ }
+ Instant now = clock.instant();
+ RefreshTokenSecret token = tokenIssuer.issue();
+ UUID familyId = UUID.randomUUID();
+ var expiresAt = now.plus(refreshTokenLifetime);
+ commandPort.completeLogin(
+ new RefreshTokenFamily(
+ familyId,
+ account.accountId(),
+ account.loginIdentityId(),
+ account.authEpoch(),
+ account.credentialVersion(),
+ token.digest(),
+ now,
+ expiresAt),
+ new AuthenticationSuccess(
+ account.accountId(), account.loginIdentityId(), correlationId, now));
+ return new LoginResult(
+ account.accountId(), account.loginIdentityId(), account.authEpoch(),
+ account.credentialVersion(), familyId, token.rawToken(), expiresAt);
+ }
+
private void reject(PasswordLoginAccount account, LoginCommand command, String reason, String message) {
commandPort.recordLoginFailure(new LoginFailure(
account.accountId(), account.loginIdentityId(), reason, command.correlationId(), clock.instant()));
diff --git a/modules/backend-application/src/main/java/com/idea2strategy/backend/application/identity/IdentityQueryPort.java b/modules/backend-application/src/main/java/com/idea2strategy/backend/application/identity/IdentityQueryPort.java
index a3904a12..0a58f171 100644
--- a/modules/backend-application/src/main/java/com/idea2strategy/backend/application/identity/IdentityQueryPort.java
+++ b/modules/backend-application/src/main/java/com/idea2strategy/backend/application/identity/IdentityQueryPort.java
@@ -4,4 +4,13 @@
public interface IdentityQueryPort {
Optional A device authorization already knows who approved it — the browser session said so — and
+ * still needs the login identity, auth epoch, and credential version to mint a session that
+ * dies with a password change like any other.
+ */
+ Optional Approval and consumption are single conditional statements rather than read-then-write. Two
+ * polls arriving together must not both collect a token, and an approval racing an expiry must not
+ * revive a dead request; letting the database decide is what makes that true without a lock.
+ */
+@Component
+public class DeviceAuthorizationJooqAdapter implements DeviceAuthorizationCommandPort {
+ private final DSLContext dsl;
+
+ public DeviceAuthorizationJooqAdapter(DSLContext dsl) {
+ this.dsl = Objects.requireNonNull(dsl, "dsl");
+ }
+
+ @Override
+ public UUID create(
+ String deviceCodeDigest,
+ String userCodeDigest,
+ short digestKeyVersion,
+ String clientLabel,
+ short pollIntervalSeconds,
+ Instant requestedAt,
+ Instant expiresAt) {
+ UUID id = UUID.randomUUID();
+ dsl.execute(
+ "insert into identity.device_authorization_requests ("
+ + "id, device_code_digest, user_code_digest, digest_key_version, client_label, "
+ + "status, poll_interval_seconds, requested_at, expires_at) "
+ + "values (?, ?, ?, ?, ?, 'PENDING', ?, ?::timestamptz, ?::timestamptz)",
+ id, deviceCodeDigest, userCodeDigest, digestKeyVersion, clientLabel,
+ pollIntervalSeconds, offset(requestedAt), offset(expiresAt));
+ return id;
+ }
+
+ @Override
+ public boolean approve(String userCodeDigest, UUID accountId, Instant at) {
+ return dsl.execute(
+ "update identity.device_authorization_requests set status = 'APPROVED', "
+ + "approved_account_id = ?, approved_at = ?::timestamptz "
+ + "where user_code_digest = ? and status = 'PENDING' "
+ + "and expires_at > ?::timestamptz",
+ accountId, offset(at), userCodeDigest, offset(at)) == 1;
+ }
+
+ @Override
+ public boolean deny(String userCodeDigest, Instant at) {
+ return dsl.execute(
+ "update identity.device_authorization_requests set status = 'DENIED', "
+ + "denied_at = ?::timestamptz "
+ + "where user_code_digest = ? and status = 'PENDING' "
+ + "and expires_at > ?::timestamptz",
+ offset(at), userCodeDigest, offset(at)) == 1;
+ }
+
+ @Override
+ @Transactional
+ public DeviceAuthorizationOutcome consume(String deviceCodeDigest, Instant at) {
+ // The update is the decision: only an approved, unexpired request can move to CONSUMED, and
+ // only one statement can win it. Reading the row first and updating after would let two
+ // polls both see APPROVED.
+ Record consumed = dsl.fetchOne(
+ "update identity.device_authorization_requests set status = 'CONSUMED', "
+ + "consumed_at = ?::timestamptz, last_polled_at = ?::timestamptz "
+ + "where device_code_digest = ? and status = 'APPROVED' "
+ + "and expires_at > ?::timestamptz "
+ + "returning approved_account_id",
+ offset(at), offset(at), deviceCodeDigest, offset(at));
+ if (consumed != null) {
+ return DeviceAuthorizationOutcome.approved(consumed.get("approved_account_id", UUID.class));
+ }
+
+ Record current = dsl.fetchOne(
+ "select status::text as status, expires_at from identity.device_authorization_requests "
+ + "where device_code_digest = ?",
+ deviceCodeDigest);
+ if (current == null) {
+ return DeviceAuthorizationOutcome.of(DeviceAuthorizationOutcome.Status.UNKNOWN);
+ }
+ dsl.execute(
+ "update identity.device_authorization_requests set last_polled_at = ?::timestamptz "
+ + "where device_code_digest = ?",
+ offset(at), deviceCodeDigest);
+
+ var expiresAt = current.get("expires_at", java.time.OffsetDateTime.class);
+ if (expiresAt != null && !expiresAt.toInstant().isAfter(at)) {
+ return DeviceAuthorizationOutcome.of(DeviceAuthorizationOutcome.Status.EXPIRED);
+ }
+ return switch (current.get("status", String.class)) {
+ case "PENDING" -> DeviceAuthorizationOutcome.pending();
+ case "DENIED" -> DeviceAuthorizationOutcome.of(DeviceAuthorizationOutcome.Status.DENIED);
+ // Already collected. Reporting UNKNOWN rather than APPROVED keeps a replayed device
+ // code from looking like a fresh grant.
+ default -> DeviceAuthorizationOutcome.of(DeviceAuthorizationOutcome.Status.UNKNOWN);
+ };
+ }
+
+ private static java.time.OffsetDateTime offset(Instant value) {
+ return value == null ? null : value.atOffset(ZoneOffset.UTC);
+ }
+}
diff --git a/modules/backend-persistence/src/main/java/com/idea2strategy/backend/persistence/identity/IdentityJooqQueryAdapter.java b/modules/backend-persistence/src/main/java/com/idea2strategy/backend/persistence/identity/IdentityJooqQueryAdapter.java
index f80db976..8eabafb0 100644
--- a/modules/backend-persistence/src/main/java/com/idea2strategy/backend/persistence/identity/IdentityJooqQueryAdapter.java
+++ b/modules/backend-persistence/src/main/java/com/idea2strategy/backend/persistence/identity/IdentityJooqQueryAdapter.java
@@ -139,8 +139,17 @@ public Optional