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 authorize( + @RequestBody(required = false) AuthorizeRequest request) { + String label = request == null || request.clientLabel() == null || request.clientLabel().isBlank() + ? "idea2strategy-cli" + : request.clientLabel().trim(); + var grant = devices.request(label); + return ResponseEntity.status(HttpStatus.CREATED).body(new DeviceAuthorizationResponse( + grant.deviceCode(), + grant.userCode(), + verificationUri, + verificationUri + "?code=" + grant.userCode(), + grant.expiresAt(), + grant.pollIntervalSeconds())); + } + + /** The account comes from the browser session, never from the body. */ + @PostMapping("/approve") + public ResponseEntity approve(@RequestBody UserCodeRequest request) { + devices.approve(request.userCode(), principal.accountId()); + return ResponseEntity.noContent().build(); + } + + @PostMapping("/deny") + public ResponseEntity deny(@RequestBody UserCodeRequest request) { + devices.deny(request.userCode()); + return ResponseEntity.noContent().build(); + } + + /** + * Unauthenticated, and answers 202 while pending so a polling client can tell "not yet" from a + * refusal without reading a body it might not parse. + */ + @PostMapping("/token") + public ResponseEntity token(@RequestBody DeviceCodeRequest request) { + DeviceAuthorizationOutcome outcome = devices.collect(request.deviceCode()); + return switch (outcome.status()) { + case APPROVED -> ResponseEntity.ok(tokens.tokenResponse( + authentication.completeApprovedDeviceLogin( + outcome.accountId().orElseThrow(), UUID.randomUUID())) + .getBody()); + case PENDING -> ResponseEntity.accepted().body(new PendingResponse("authorization_pending")); + case DENIED -> ResponseEntity.status(HttpStatus.FORBIDDEN).body(new PendingResponse("access_denied")); + case EXPIRED -> ResponseEntity.status(HttpStatus.GONE).body(new PendingResponse("expired_token")); + case UNKNOWN -> ResponseEntity.status(HttpStatus.NOT_FOUND).body(new PendingResponse("invalid_device_code")); + }; + } + + public record AuthorizeRequest(String clientLabel) {} + + public record UserCodeRequest(String userCode) {} + + public record DeviceCodeRequest(String deviceCode) { + @Override + public String toString() { + return "DeviceCodeRequest[deviceCode=REDACTED]"; + } + } + + public record DeviceAuthorizationResponse( + String deviceCode, + String userCode, + String verificationUri, + String verificationUriComplete, + Instant expiresAt, + short intervalSeconds) { + @Override + public String toString() { + return "DeviceAuthorizationResponse[codes=REDACTED]"; + } + } + + public record PendingResponse(String error) {} +} diff --git a/apps/backend-api/src/main/java/com/idea2strategy/backend/api/identity/HmacDeviceCodes.java b/apps/backend-api/src/main/java/com/idea2strategy/backend/api/identity/HmacDeviceCodes.java new file mode 100644 index 00000000..3e3f5324 --- /dev/null +++ b/apps/backend-api/src/main/java/com/idea2strategy/backend/api/identity/HmacDeviceCodes.java @@ -0,0 +1,87 @@ +package com.idea2strategy.backend.api.identity; + +import com.idea2strategy.backend.application.identity.DeviceCodeMaterial; +import com.idea2strategy.backend.application.identity.DeviceCodeMaterialPort; +import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; +import java.security.SecureRandom; +import java.util.Base64; +import java.util.Objects; +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; + +/** + * The two codes a device authorization runs on. + * + *

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 VALUELESS = List.of("--browser", "--no-open"); + private final List positionals; private final Map options; @@ -23,6 +32,12 @@ static Arguments parse(List values) { positionals.add(value); continue; } + if (VALUELESS.contains(value)) { + if (options.put(value, "true") != null) { + throw usage("Option may be supplied only once: " + value); + } + continue; + } if (index + 1 >= values.size() || values.get(index + 1).startsWith("--")) { throw usage("Option requires a value: " + value); } @@ -49,6 +64,10 @@ String optional(String name) { return options.get(name); } + boolean flag(String name) { + return "true".equals(options.get(name)); + } + String optional(String name, String defaultValue) { return options.getOrDefault(name, defaultValue); } diff --git a/apps/idea2strategy-cli/src/main/java/com/idea2strategy/cli/Idea2StrategyCli.java b/apps/idea2strategy-cli/src/main/java/com/idea2strategy/cli/Idea2StrategyCli.java index 0ab0153d..fa0ed4e6 100644 --- a/apps/idea2strategy-cli/src/main/java/com/idea2strategy/cli/Idea2StrategyCli.java +++ b/apps/idea2strategy-cli/src/main/java/com/idea2strategy/cli/Idea2StrategyCli.java @@ -12,6 +12,7 @@ import java.io.PrintWriter; import java.math.BigDecimal; import java.net.URLEncoder; +import java.time.Instant; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; @@ -118,8 +119,74 @@ private static JsonNode toolContract() { } } + /** + * Signs in through the browser so nothing driving this CLI ever handles a password. + * + *

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 command = os.contains("win") + ? List.of("rundll32", "url.dll,FileProtocolHandler", uri) + : os.contains("mac") ? List.of("open", uri) : List.of("xdg-open", uri); + try { + new ProcessBuilder(command).inheritIO().start(); + } catch (IOException ignored) { + System.err.println("Could not open a browser automatically. Open the address above."); + } + } + private static JsonNode login(Arguments args, ApiClient api, CredentialStore credentials, InputStream stdin) { - args.rejectUnknown("--email"); + args.rejectUnknown("--email", "--browser", "--no-open"); + if (args.flag("--browser")) { + return browserLogin(args, api, credentials); + } String password; try { password = new BufferedReader(new InputStreamReader(stdin, StandardCharsets.UTF_8)).readLine(); diff --git a/db-migration/src/main/resources/db/migration/V20260810120000__backend_device_authorization_requests.sql b/db-migration/src/main/resources/db/migration/V20260810120000__backend_device_authorization_requests.sql new file mode 100644 index 00000000..0e859e1d --- /dev/null +++ b/db-migration/src/main/resources/db/migration/V20260810120000__backend_device_authorization_requests.sql @@ -0,0 +1,57 @@ +-- Browser approval for a command-line client. +-- +-- Today the CLI takes a password on standard input, which means anything driving the CLI — +-- including an AI agent asked to "set it up" — has to be handed the customer's password. This +-- table is what lets the browser hold the credential instead: the CLI never sees it, and the +-- customer approves a short code in a session they already trust. +-- +-- The user code and the device code are separate secrets on purpose. The short one is read aloud +-- and typed by a person, so it is guessable by construction and must not be enough to collect a +-- token; the long one never leaves the CLI. Only digests are stored, so a database reader cannot +-- complete somebody's pending login. + +CREATE TYPE identity.device_authorization_status AS ENUM ( + 'PENDING', + 'APPROVED', + 'CONSUMED', + 'DENIED', + 'EXPIRED' +); + +CREATE TABLE identity.device_authorization_requests ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + device_code_digest varchar(128) NOT NULL UNIQUE, + user_code_digest varchar(128) NOT NULL UNIQUE, + digest_key_version smallint NOT NULL, + client_label varchar(80) NOT NULL, + status identity.device_authorization_status NOT NULL, + approved_account_id uuid REFERENCES identity.accounts (id), + approved_login_identity_id uuid REFERENCES identity.login_identities (id), + poll_interval_seconds smallint NOT NULL DEFAULT 5, + requested_at timestamptz NOT NULL DEFAULT now(), + expires_at timestamptz NOT NULL, + approved_at timestamptz, + consumed_at timestamptz, + denied_at timestamptz, + failed_attempt_count integer NOT NULL DEFAULT 0, + last_polled_at timestamptz, + + -- An approved request names who approved it; a pending one cannot. + CONSTRAINT device_authorization_requests_approval_is_complete CHECK ( + (status IN ('APPROVED', 'CONSUMED')) + = (approved_account_id IS NOT NULL AND approved_at IS NOT NULL) + ), + -- A token may be collected once. CONSUMED is the record of that having happened. + CONSTRAINT device_authorization_requests_consumed_is_approved CHECK ( + (status = 'CONSUMED') = (consumed_at IS NOT NULL) + ), + CONSTRAINT device_authorization_requests_denied_is_marked CHECK ( + (status = 'DENIED') = (denied_at IS NOT NULL) + ) +); + +CREATE INDEX ON identity.device_authorization_requests (status, expires_at); +CREATE INDEX ON identity.device_authorization_requests (approved_account_id, requested_at); + +COMMENT ON TABLE identity.device_authorization_requests IS + '브라우저 승인으로 CLI 를 인증시키는 기기 인증 요청. 사용자가 보는 짧은 user_code 와 CLI 가 폴링하는 device_code 는 서로 다른 비밀이며 둘 다 다이제스트로만 저장한다. 승인은 브라우저 세션이, 토큰 수령은 device_code 소지자가 한다. 소진은 1회 한정.'; diff --git a/db-migration/src/test/java/com/idea2strategy/backend/migration/CanonicalMigrationBundleAssemblerTest.java b/db-migration/src/test/java/com/idea2strategy/backend/migration/CanonicalMigrationBundleAssemblerTest.java index 2f9b7176..cc11191a 100644 --- a/db-migration/src/test/java/com/idea2strategy/backend/migration/CanonicalMigrationBundleAssemblerTest.java +++ b/db-migration/src/test/java/com/idea2strategy/backend/migration/CanonicalMigrationBundleAssemblerTest.java @@ -85,6 +85,7 @@ void assemblesOnlyOwnedCanonicalContributionsInGlobalVersionOrder() throws Excep "V20260809120000__backend_account_email_notification_preference.sql", "V20260809140000__backend_publish_delegation_disclosure_policy.sql", "V20260810090000__backend_publish_delegation_disclosure_policy_v2.sql", + "V20260810120000__backend_device_authorization_requests.sql", DatabaseAccessPolicy.RUNTIME_GRANTS_FILE), result.orderedFileNames()); assertTrue(Files.readString(result.directory().resolve(DatabaseAccessPolicy.RUNTIME_GRANTS_FILE)) diff --git a/db-migration/src/test/java/com/idea2strategy/backend/migration/MigrationPolicyTest.java b/db-migration/src/test/java/com/idea2strategy/backend/migration/MigrationPolicyTest.java index b4eecd5f..65453e13 100644 --- a/db-migration/src/test/java/com/idea2strategy/backend/migration/MigrationPolicyTest.java +++ b/db-migration/src/test/java/com/idea2strategy/backend/migration/MigrationPolicyTest.java @@ -105,7 +105,8 @@ void verifiesTheCheckedInMigrationDirectoryAndBaselineChecksum() throws Exceptio "V20260808120000__backend_publish_production_backtest_resolutions.sql", "V20260809120000__backend_account_email_notification_preference.sql", "V20260809140000__backend_publish_delegation_disclosure_policy.sql", - "V20260810090000__backend_publish_delegation_disclosure_policy_v2.sql"), + "V20260810090000__backend_publish_delegation_disclosure_policy_v2.sql", + "V20260810120000__backend_device_authorization_requests.sql"), plan.orderedFileNames()); } diff --git a/modules/backend-application/src/main/java/com/idea2strategy/backend/application/identity/DeviceAuthorizationCommandPort.java b/modules/backend-application/src/main/java/com/idea2strategy/backend/application/identity/DeviceAuthorizationCommandPort.java new file mode 100644 index 00000000..7e722b47 --- /dev/null +++ b/modules/backend-application/src/main/java/com/idea2strategy/backend/application/identity/DeviceAuthorizationCommandPort.java @@ -0,0 +1,23 @@ +package com.idea2strategy.backend.application.identity; + +import java.time.Instant; +import java.util.UUID; + +public interface DeviceAuthorizationCommandPort { + UUID create( + String deviceCodeDigest, + String userCodeDigest, + short digestKeyVersion, + String clientLabel, + short pollIntervalSeconds, + Instant requestedAt, + Instant expiresAt); + + /** Returns false when no unexpired pending request carries that user code. */ + boolean approve(String userCodeDigest, UUID accountId, Instant at); + + boolean deny(String userCodeDigest, Instant at); + + /** Atomically moves an approved request to consumed; every other state answers without a token. */ + DeviceAuthorizationOutcome consume(String deviceCodeDigest, Instant at); +} diff --git a/modules/backend-application/src/main/java/com/idea2strategy/backend/application/identity/DeviceAuthorizationGrant.java b/modules/backend-application/src/main/java/com/idea2strategy/backend/application/identity/DeviceAuthorizationGrant.java new file mode 100644 index 00000000..0867d359 --- /dev/null +++ b/modules/backend-application/src/main/java/com/idea2strategy/backend/application/identity/DeviceAuthorizationGrant.java @@ -0,0 +1,8 @@ +package com.idea2strategy.backend.application.identity; + +import java.time.Instant; +import java.util.UUID; + +/** Returned once, at request time. The codes are never readable again. */ +public record DeviceAuthorizationGrant( + UUID id, String deviceCode, String userCode, Instant expiresAt, short pollIntervalSeconds) {} diff --git a/modules/backend-application/src/main/java/com/idea2strategy/backend/application/identity/DeviceAuthorizationOutcome.java b/modules/backend-application/src/main/java/com/idea2strategy/backend/application/identity/DeviceAuthorizationOutcome.java new file mode 100644 index 00000000..0698e492 --- /dev/null +++ b/modules/backend-application/src/main/java/com/idea2strategy/backend/application/identity/DeviceAuthorizationOutcome.java @@ -0,0 +1,32 @@ +package com.idea2strategy.backend.application.identity; + +import java.util.Optional; +import java.util.UUID; + +/** + * What a polling client learns. + * + *

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 accountId) { + public enum Status { + PENDING, + APPROVED, + DENIED, + EXPIRED, + UNKNOWN + } + + public static DeviceAuthorizationOutcome pending() { + return new DeviceAuthorizationOutcome(Status.PENDING, Optional.empty()); + } + + public static DeviceAuthorizationOutcome approved(UUID accountId) { + return new DeviceAuthorizationOutcome(Status.APPROVED, Optional.of(accountId)); + } + + public static DeviceAuthorizationOutcome of(Status status) { + return new DeviceAuthorizationOutcome(status, Optional.empty()); + } +} diff --git a/modules/backend-application/src/main/java/com/idea2strategy/backend/application/identity/DeviceAuthorizationRejectedException.java b/modules/backend-application/src/main/java/com/idea2strategy/backend/application/identity/DeviceAuthorizationRejectedException.java new file mode 100644 index 00000000..10168931 --- /dev/null +++ b/modules/backend-application/src/main/java/com/idea2strategy/backend/application/identity/DeviceAuthorizationRejectedException.java @@ -0,0 +1,7 @@ +package com.idea2strategy.backend.application.identity; + +public class DeviceAuthorizationRejectedException extends RuntimeException { + public DeviceAuthorizationRejectedException(String message) { + super(message); + } +} diff --git a/modules/backend-application/src/main/java/com/idea2strategy/backend/application/identity/DeviceAuthorizationService.java b/modules/backend-application/src/main/java/com/idea2strategy/backend/application/identity/DeviceAuthorizationService.java new file mode 100644 index 00000000..2bf3079a --- /dev/null +++ b/modules/backend-application/src/main/java/com/idea2strategy/backend/application/identity/DeviceAuthorizationService.java @@ -0,0 +1,98 @@ +package com.idea2strategy.backend.application.identity; + +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.util.Objects; +import java.util.UUID; + +/** + * Browser approval for a command-line client. + * + *

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 findPasswordLoginByEmailLookup(String emailLookup); + + /** + * The same account, found by id rather than by email. + * + *

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 findPasswordLoginByAccountId(java.util.UUID accountId); } diff --git a/modules/backend-application/src/test/java/com/idea2strategy/backend/application/identity/EmailAuthenticationServiceTest.java b/modules/backend-application/src/test/java/com/idea2strategy/backend/application/identity/EmailAuthenticationServiceTest.java index 79c3315e..6dc494ac 100644 --- a/modules/backend-application/src/test/java/com/idea2strategy/backend/application/identity/EmailAuthenticationServiceTest.java +++ b/modules/backend-application/src/test/java/com/idea2strategy/backend/application/identity/EmailAuthenticationServiceTest.java @@ -87,6 +87,12 @@ private record StubQueryPort(PasswordLoginAccount account) implements IdentityQu public Optional findPasswordLoginByEmailLookup(String emailLookup) { return Optional.ofNullable(account); } + + @Override + public Optional findPasswordLoginByAccountId(java.util.UUID accountId) { + return Optional.ofNullable(account) + .filter(candidate -> candidate.accountId().equals(accountId)); + } } private static final class RecordingCommandPort implements IdentityCommandPort { diff --git a/modules/backend-application/src/test/java/com/idea2strategy/backend/application/identity/LifecyclePasswordStepUpServiceTest.java b/modules/backend-application/src/test/java/com/idea2strategy/backend/application/identity/LifecyclePasswordStepUpServiceTest.java index cbea9b4e..e36932f9 100644 --- a/modules/backend-application/src/test/java/com/idea2strategy/backend/application/identity/LifecyclePasswordStepUpServiceTest.java +++ b/modules/backend-application/src/test/java/com/idea2strategy/backend/application/identity/LifecyclePasswordStepUpServiceTest.java @@ -88,7 +88,17 @@ private static void assertRejected(PasswordLoginAccount account) { private static LifecyclePasswordStepUpService service( PasswordLoginAccount account, boolean passwordMatches, IdentityCommandPort commands) { - IdentityQueryPort identities = ignored -> Optional.ofNullable(account); + IdentityQueryPort identities = new IdentityQueryPort() { + @Override + public Optional findPasswordLoginByEmailLookup(String emailLookup) { + return Optional.ofNullable(account); + } + + @Override + public Optional findPasswordLoginByAccountId(java.util.UUID accountId) { + return Optional.ofNullable(account); + } + }; PasswordVerifier passwords = (raw, encoded) -> passwordMatches && raw.equals("correct-secret"); EmailLookup emails = raw -> raw.strip().toLowerCase(); return new LifecyclePasswordStepUpService( diff --git a/modules/backend-persistence/src/main/java/com/idea2strategy/backend/persistence/identity/DeviceAuthorizationJooqAdapter.java b/modules/backend-persistence/src/main/java/com/idea2strategy/backend/persistence/identity/DeviceAuthorizationJooqAdapter.java new file mode 100644 index 00000000..f3126055 --- /dev/null +++ b/modules/backend-persistence/src/main/java/com/idea2strategy/backend/persistence/identity/DeviceAuthorizationJooqAdapter.java @@ -0,0 +1,114 @@ +package com.idea2strategy.backend.persistence.identity; + +import com.idea2strategy.backend.application.identity.DeviceAuthorizationCommandPort; +import com.idea2strategy.backend.application.identity.DeviceAuthorizationOutcome; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.Objects; +import java.util.UUID; +import org.jooq.DSLContext; +import org.jooq.Record; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +/** + * Device authorization requests. + * + *

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 findPasswordRecoveryByAccountId(UUID re record.value1(), record.value2(), record.value3(), record.value4())); } + @Override + public Optional findPasswordLoginByAccountId(UUID accountId) { + return findPasswordLogin(field(name("account", "id"), UUID.class).eq(accountId)); + } + @Override public Optional findPasswordLoginByEmailLookup(String emailLookup) { + return findPasswordLogin(field(name("email", "email_lookup_hmac"), String.class).eq(emailLookup)); + } + + private Optional findPasswordLogin(org.jooq.Condition subject) { var emails = table(name("identity", "account_emails")).as("email"); var accounts = table(name("identity", "accounts")).as("account"); var identities = table(name("identity", "login_identities")).as("login"); @@ -173,8 +182,7 @@ public Optional findPasswordLoginByEmailLookup(String emai .eq(field(name("login", "provider_id"), Short.class))) .join(credentials).on(field(name("credential", "login_identity_id"), UUID.class).eq(loginId)) .join(security).on(field(name("security", "account_id"), UUID.class).eq(accountId)) - .where(field(name("email", "email_lookup_hmac"), String.class).eq(emailLookup) - .and(field(name("provider", "code"), String.class).eq("PASSWORD"))) + .where(subject.and(field(name("provider", "code"), String.class).eq("PASSWORD"))) .fetchOptional(record -> new PasswordLoginAccount( record.get(accountId), record.get(loginId),