Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>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<DeviceAuthorizationResponse> 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<Void> approve(@RequestBody UserCodeRequest request) {
devices.approve(request.userCode(), principal.accountId());
return ResponseEntity.noContent().build();
}

@PostMapping("/deny")
public ResponseEntity<Void> 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) {}
}
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>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.
*
* <p>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);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,8 @@
AccountLifecycleJpaCommandAdapter.class,
AccountPreferencesConsentJooqAdapter.class,
AccountPreferencesConsentJpaAdapter.class,
OidcStepUpChallengeJpaAdapter.class
OidcStepUpChallengeJpaAdapter.class,
com.idea2strategy.backend.persistence.identity.DeviceAuthorizationJooqAdapter.class
})
public class IdentityAuthConfiguration {
/**
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,15 @@
import java.util.Map;

final class Arguments {
/**
* Switches that carry no value.
*
* <p>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<String> VALUELESS = List.of("--browser", "--no-open");

private final List<String> positionals;
private final Map<String, String> options;

Expand All @@ -23,6 +32,12 @@ static Arguments parse(List<String> 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);
}
Expand All @@ -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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -118,8 +119,74 @@ private static JsonNode toolContract() {
}
}

/**
* Signs in through the browser so nothing driving this CLI ever handles a password.
*
* <p>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<String> 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();
Expand Down
Loading