# Loopback OAuth

Desktop loopback PKCE OAuth flow. Binds a transient `127.0.0.1` listener, opens the system browser, waits for the provider callback, exchanges the code via `Client`, and stores the bearer on the client instance.

```rust
use akira_billing::loopback::{loopback_login, LoopbackError, LoopbackOutcome};
```

> Feature-gated: `features = ["loopback"]`. Pulls Tokio (mandatory) and `url`.

## loopback_login

```rust
pub async fn loopback_login(
    sdk: &mut Client,
    product: &str,
    provider: &str,
    open_browser: impl Fn(&str) -> Result<(), String>,
    timeout_secs: u64,
) -> Result<LoopbackOutcome, LoopbackError>;

pub struct LoopbackOutcome {
    pub exchange: OauthExchangeResponse,
}
```

Sequence:

1. `TcpListener::bind("127.0.0.1:0")` — kernel picks a port.
2. Generate PKCE challenge + OAuth state (`oauth::generate_*`).
3. `build_oauth_init_url` with `redirect_uri = http://127.0.0.1:{port}/cb`.
4. `open_browser(&auth_url)` — caller decides how the URL reaches the user.
5. Await the callback via `accept_callback`, bounded by `tokio::time::timeout(Duration::from_secs(timeout_secs.max(1)))`.
6. Verify the returned `state` matches the generated one.
7. `client.exchange_oauth_code({ code, code_verifier: pkce.verifier })`.
8. `client.set_customer_token(access_token)`.

The function:

- Takes `&mut Client` because it mutates `customer_token` on success.
- Borrows `provider` and `product` as `&str` (no `Into<String>`); arguments live only for the call.
- Treats `timeout_secs == 0` as 1 second (`.max(1)`).

## LoopbackError

```rust
#[derive(Debug, thiserror::Error)]
pub enum LoopbackError {
    #[error("bind callback listener: {0}")]
    Bind(String),

    #[error("read callback: {0}")]
    Read(String),

    #[error("parse callback url: {0}")]
    Parse(String),

    #[error("oauth state mismatch")]
    StateMismatch,

    #[error("oauth callback missing code")]
    MissingCode,

    #[error("oauth callback missing state")]
    MissingState,

    #[error("open browser: {0}")]
    OpenBrowser(String),

    #[error("oauth callback timed out")]
    Timeout,

    #[error("api: {0}")]
    Api(#[from] Error),                  // wraps the SDK Error from exchange_oauth_code
}
```

Branch on the variant for retry / surface decisions:

```rust
match loopback_login(&mut client, "unified-dev", "github", open_browser, 300).await {
    Ok(outcome) => println!("signed in as {}", outcome.exchange.customer.email),
    Err(LoopbackError::Timeout) => warn("user did not finish sign-in within 5 minutes"),
    Err(LoopbackError::OpenBrowser(msg)) => fall_back_to_manual_paste(msg),
    Err(LoopbackError::Api(err)) => return Err(err.into()),
    Err(other) => warn(&other.to_string()),
}
```

## open_browser

The `desktop` module exposes a portable default:

```rust
use akira_billing::desktop::open_browser;

let outcome = loopback_login(&mut client, "unified-dev", "github", open_browser, 300).await?;
```

`open_browser` shells out to:

- `open` on macOS
- `explorer` on Windows
- `xdg-open` on Linux

Returns `Err(String)` if `spawn` fails. The launched browser inherits stdio handles by default — the caller controls whether to detach further (`process::Command` builder).

Custom integrations should wrap their own platform helper:

```rust
// Tauri example
fn open_in_tauri(url: &str) -> Result<(), String> {
    tauri::api::shell::open(&handle, url.to_string(), None)
        .map_err(|e| e.to_string())
}

loopback_login(&mut client, product, provider, open_in_tauri, 300).await?;
```

## Server lifecycle

The listener is **single-shot**:

- One `accept()` call, then close.
- Reads the first request line, extracts the URL, sends `HTTP/1.1 200 OK` + the success HTML.
- Stream is closed; the listener drops at end of function scope.

No connection is kept alive. The success page is inline HTML (no external assets) — preserves UX when the callback completes while offline.

## Security notes

- The PKCE verifier never leaves the local process.
- `state` is regenerated per call — no risk of replay across logins.
- `TcpListener::bind("127.0.0.1:0")` binds the loopback interface only — never exposed to the LAN.
- The callback handler only reads the request line; query parameters from anywhere except the SDK's chosen path can be ignored (the server closes after one request regardless).
- The signature on `client.exchange_oauth_code` rejects forged codes — the loopback path adds no new trust assumptions to the existing SDK flow.

## Pairing with desktop AuthController

The `desktop::auth::refresh_auth` helper composes `loopback_login` + license check + customer-features fetch into a single state-machine transition. See [41-desktop](41-desktop.md).

---

Navigation: [← Lifecycle](33-lifecycle.md) · **Loopback** · [Desktop →](41-desktop.md)
