Skip to content

Add browser-based device authorization#16

Open
IliaMManolov wants to merge 1 commit into
mainfrom
feat/device-authorization
Open

Add browser-based device authorization#16
IliaMManolov wants to merge 1 commit into
mainfrom
feat/device-authorization

Conversation

@IliaMManolov

@IliaMManolov IliaMManolov commented Jul 17, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • add OAuth-style device authorization with PKCE, browser login, logout, and scoped client credentials
  • reload rotated credentials without restarting while surfacing needs_reauth and needs_upgrade states
  • isolate server secrets by issuer and installation, securely replace credential files, and terminate child process trees on auth changes or shutdown
  • move client server actions to the scoped request API while retaining the legacy API-key compatibility path

Validation

  • cargo test --workspace (95 tests)
  • cargo clippy --workspace --all-targets -- -D warnings
  • cargo package -p edison-stdiod --allow-dirty
  • changed-file prek hooks
  • offline GoTrue and real Playwright device approval, denial, reauthorization, revocation, and MCP round-trip coverage with the matching edison-watch branch

Summary by cubic

Adds OAuth-style device authorization with PKCE for edison-stdiod, enabling browser login, logout, and scoped client credentials. The daemon now reloads rotated credentials at runtime and handles auth states without restarts.

  • New Features

    • Browser/device auth with PKCE: login opens the browser; logout revokes and clears local credentials.
    • Scoped client credentials with a narrow /api/v1/client/... surface; legacy API-key flows remain compatible for add/list/remove where valid.
    • Daemon reacts to auth changes: live credential reloads, needs_reauth/needs_upgrade states, and clean child process tree termination on auth changes or shutdown.
    • Safer on-disk handling: isolated env stores per issuer/installation and atomic private file replacement for credentials and env data.
  • Migration

    • New installs: run edison-stdiod login --backend <URL> to issue and persist a client token; use logout to revoke/remove.
    • Legacy API keys keep working; no immediate action required, but browser auth is now the default.
    • install requires a persisted credential (config.toml); set the backend and authenticate before installing the service.

Written for commit 888e2f0. Summary will update on new commits.

Review in cubic

@qodo-code-review

Copy link
Copy Markdown

Qodo reviews are paused for this user.

Troubleshooting steps vary by plan Learn more →

On a Teams plan?
Reviews resume once this user has a paid seat and their Git account is linked in Qodo.
Link Git account →

Using GitHub Enterprise Server, GitLab Self-Managed, or Bitbucket Data Center?
These require an Enterprise plan - Contact us
Contact us →

@IliaMManolov

Copy link
Copy Markdown
Collaborator Author

Companion backend/dashboard PR: https://github.com/Edison-Watch/edison-watch/pull/1077

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

7 issues found across 24 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="crates/edison-stdiod/src/config_extra_tests.rs">

<violation number="1" location="crates/edison-stdiod/src/config_extra_tests.rs:49">
P2: Replacing an invalid saved backend must not leave its stale scoped token usable, but this regression test never checks `client_access_token`. As written, credential-isolation regressions can pass unnoticed; asserting that `merged.client_access_token` is `None` would cover the seeded case.</violation>
</file>

<file name="crates/edison-stdiod/src/secure_file.rs">

<violation number="1" location="crates/edison-stdiod/src/secure_file.rs:86">
P2: On Windows, saving credentials can permanently remove the current file if the process stops or the follow-up `rename` fails after `remove_file` succeeds; readers can also observe the file missing during normal replacement. An atomic Windows replacement API, or a failure-safe replacement/rollback strategy, would preserve the existing file until the new one is installed.</violation>
</file>

<file name="crates/edison-stdiod/src/tunnel.rs">

<violation number="1" location="crates/edison-stdiod/src/tunnel.rs:237">
P2: A close carrying a reason returns before `send_task.abort()` runs, so the old sink task can outlive the session and retain its receiver/socket while the supervisor handles the close error. Cleaning up the task before returning the classified error keeps revoked or otherwise rejected sessions from leaking connection resources.</violation>
</file>

<file name="crates/edison-stdiod/src/cli/login.rs">

<violation number="1" location="crates/edison-stdiod/src/cli/login.rs:78">
P1: Reauthorizing a reused installation for a different user or organization is not recognized as an account change because this condition omits `authenticated_user_id` and `authenticated_org_id`. Comparing both returned account IDs here would clear the previous per-user secret and revoke the old client credential instead of retaining it.</violation>
</file>

<file name="crates/edison-stdiod/src/auth.rs">

<violation number="1" location="crates/edison-stdiod/src/auth.rs:144">
P2: The token exchange can leak the PKCE verifier to a redirect target because this client follows redirects by default and a 307/308 response can resend the POST body. Disabling redirects for this auth client (or rejecting every cross-origin redirect) keeps authorization material bound to the configured backend.</violation>

<violation number="2" location="crates/edison-stdiod/src/auth.rs:334">
P3: A valid bearer token response using another casing (for example `bearer`) is rejected as a protocol error. Compare the token type case-insensitively before accepting the response.</violation>
</file>

<file name="crates/edison-stdiod/src/config.rs">

<violation number="1" location="crates/edison-stdiod/src/config.rs:247">
P2: A saved backend URL without any saved credential now blocks a different `--backend` override, so first-run/partial configs and logged-out users cannot switch servers without editing the config. Restrict this mismatch error to configs that actually contain issuer-bound authentication state (or otherwise allow the explicit backend override).</violation>
</file>

Tip: cubic can generate docs of your entire codebase and keep them up to date. Try it here.

Re-trigger cubic

Comment on lines +78 to +79
let account_changed = !same_issuer
|| cfg.client_installation_id.as_deref() != Some(token.client_installation_id.as_str());

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Reauthorizing a reused installation for a different user or organization is not recognized as an account change because this condition omits authenticated_user_id and authenticated_org_id. Comparing both returned account IDs here would clear the previous per-user secret and revoke the old client credential instead of retaining it.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At crates/edison-stdiod/src/cli/login.rs, line 78:

<comment>Reauthorizing a reused installation for a different user or organization is not recognized as an account change because this condition omits `authenticated_user_id` and `authenticated_org_id`. Comparing both returned account IDs here would clear the previous per-user secret and revoke the old client credential instead of retaining it.</comment>

<file context>
@@ -1,122 +1,328 @@
+    println!("Waiting for authorization...");
+
+    let token = auth.poll(&code, pkce.verifier()).await?;
+    let account_changed = !same_issuer
+        || cfg.client_installation_id.as_deref() != Some(token.client_installation_id.as_str());
+
</file context>
Suggested change
let account_changed = !same_issuer
|| cfg.client_installation_id.as_deref() != Some(token.client_installation_id.as_str());
let account_changed = !same_issuer
|| cfg.client_installation_id.as_deref() != Some(token.client_installation_id.as_str())
|| cfg.authenticated_user_id.as_deref() != Some(token.user_id.as_str())
|| cfg.authenticated_org_id.as_deref() != Some(token.org_id.as_str());

},
)
.unwrap();
assert_eq!(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Replacing an invalid saved backend must not leave its stale scoped token usable, but this regression test never checks client_access_token. As written, credential-isolation regressions can pass unnoticed; asserting that merged.client_access_token is None would cover the seeded case.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At crates/edison-stdiod/src/config_extra_tests.rs, line 49:

<comment>Replacing an invalid saved backend must not leave its stale scoped token usable, but this regression test never checks `client_access_token`. As written, credential-isolation regressions can pass unnoticed; asserting that `merged.client_access_token` is `None` would cover the seeded case.</comment>

<file context>
@@ -0,0 +1,53 @@
+        },
+    )
+    .unwrap();
+    assert_eq!(
+        merged.backend_url.as_deref(),
+        Some("https://replacement.test")
</file context>

// std has no Windows equivalent of POSIX rename-over-existing.
// This remove/rename fallback makes repeated saves work, but there
// is a brief non-atomic window where the destination is absent.
match std::fs::remove_file(path) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: On Windows, saving credentials can permanently remove the current file if the process stops or the follow-up rename fails after remove_file succeeds; readers can also observe the file missing during normal replacement. An atomic Windows replacement API, or a failure-safe replacement/rollback strategy, would preserve the existing file until the new one is installed.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At crates/edison-stdiod/src/secure_file.rs, line 86:

<comment>On Windows, saving credentials can permanently remove the current file if the process stops or the follow-up `rename` fails after `remove_file` succeeds; readers can also observe the file missing during normal replacement. An atomic Windows replacement API, or a failure-safe replacement/rollback strategy, would preserve the existing file until the new one is installed.</comment>

<file context>
@@ -0,0 +1,147 @@
+            // std has no Windows equivalent of POSIX rename-over-existing.
+            // This remove/rename fallback makes repeated saves work, but there
+            // is a brief non-atomic window where the destination is absent.
+            match std::fs::remove_file(path) {
+                Ok(()) => {}
+                Err(error) if error.kind() == io::ErrorKind::NotFound => {}
</file context>

if let Some(frame) = frame {
let reason = frame.reason.to_string();
if !reason.is_empty() {
return Err(session_close_error(frame.code, reason).into());

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: A close carrying a reason returns before send_task.abort() runs, so the old sink task can outlive the session and retain its receiver/socket while the supervisor handles the close error. Cleaning up the task before returning the classified error keeps revoked or otherwise rejected sessions from leaking connection resources.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At crates/edison-stdiod/src/tunnel.rs, line 237:

<comment>A close carrying a reason returns before `send_task.abort()` runs, so the old sink task can outlive the session and retain its receiver/socket while the supervisor handles the close error. Cleaning up the task before returning the classified error keeps revoked or otherwise rejected sessions from leaking connection resources.</comment>

<file context>
@@ -187,8 +230,16 @@ pub async fn run_frame_loop(
+                if let Some(frame) = frame {
+                    let reason = frame.reason.to_string();
+                    if !reason.is_empty() {
+                        return Err(session_close_error(frame.code, reason).into());
+                    }
+                    info!(code = %frame.code, "backend closed WS");
</file context>
Suggested change
return Err(session_close_error(frame.code, reason).into());
let error = session_close_error(frame.code, reason);
send_task.abort();
let _ = send_task.await;
return Err(error.into());

pub fn new(base: impl Into<String>) -> Result<Self, AuthError> {
let base = config::normalize_backend_url(&base.into())
.map_err(|_| AuthError::Protocol("backend URL was invalid".into()))?;
let http = Client::builder()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The token exchange can leak the PKCE verifier to a redirect target because this client follows redirects by default and a 307/308 response can resend the POST body. Disabling redirects for this auth client (or rejecting every cross-origin redirect) keeps authorization material bound to the configured backend.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At crates/edison-stdiod/src/auth.rs, line 144:

<comment>The token exchange can leak the PKCE verifier to a redirect target because this client follows redirects by default and a 307/308 response can resend the POST body. Disabling redirects for this auth client (or rejecting every cross-origin redirect) keeps authorization material bound to the configured backend.</comment>

<file context>
@@ -0,0 +1,693 @@
+    pub fn new(base: impl Into<String>) -> Result<Self, AuthError> {
+        let base = config::normalize_backend_url(&base.into())
+            .map_err(|_| AuthError::Protocol("backend URL was invalid".into()))?;
+        let http = Client::builder()
+            .timeout(Duration::from_secs(20))
+            .build()
</file context>


if !has_legacy_override {
if let (Some(requested), Some(saved)) = (&override_backend, &persisted_backend) {
if requested != saved {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: A saved backend URL without any saved credential now blocks a different --backend override, so first-run/partial configs and logged-out users cannot switch servers without editing the config. Restrict this mismatch error to configs that actually contain issuer-bound authentication state (or otherwise allow the explicit backend override).

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At crates/edison-stdiod/src/config.rs, line 247:

<comment>A saved backend URL without any saved credential now blocks a different `--backend` override, so first-run/partial configs and logged-out users cannot switch servers without editing the config. Restrict this mismatch error to configs that actually contain issuer-bound authentication state (or otherwise allow the explicit backend override).</comment>

<file context>
@@ -101,14 +213,99 @@ pub struct Resolved {
+
+        if !has_legacy_override {
+            if let (Some(requested), Some(saved)) = (&override_backend, &persisted_backend) {
+                if requested != saved {
+                    return Err(anyhow!(
+                        "--backend does not match the backend bound to the saved credential; pass an explicit legacy --api-key or run `edison-stdiod login`"
</file context>

}

fn validate_token(token: &DeviceTokenResponse) -> Result<(), AuthError> {
if token.token_type != "Bearer" {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: A valid bearer token response using another casing (for example bearer) is rejected as a protocol error. Compare the token type case-insensitively before accepting the response.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At crates/edison-stdiod/src/auth.rs, line 334:

<comment>A valid bearer token response using another casing (for example `bearer`) is rejected as a protocol error. Compare the token type case-insensitively before accepting the response.</comment>

<file context>
@@ -0,0 +1,693 @@
+}
+
+fn validate_token(token: &DeviceTokenResponse) -> Result<(), AuthError> {
+    if token.token_type != "Bearer" {
+        return Err(AuthError::Protocol("token_type was not Bearer".into()));
+    }
</file context>

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant