An encrypted personal cloud built on the AT Protocol.

Handle-based login with automatic PDS resolution

Resolve PDS, DID, and handle from just a handle or DID — no more
mandatory --pds flag. Add DPoP nonce retry and expired-token handling
to the XRPC client. Fix OAuth scope, session deserialization, and
callback error handling. Redirect to frontend after CLI OAuth by
default, with --no-redirect for inline HTML fallback.
EOF

sans-self.org 376f8af3 eea6d226

Waiting for spindle ...
+449 -131
+3
.envrc
··· 1 + # Frontend URL for CLI OAuth callback redirect 2 + # Production: https://app.opake.app/oauth/cli-callback 3 + export OPAKE_FRONTEND_URL=http://localhost:5173/oauth/cli-callback
+3 -3
AGENT-BLACKBOX-TEST.md
··· 31 31 ### 1.1 Login 32 32 33 33 ```bash 34 - opake login --pds <A-pds-url> --identifier <A-handle> 35 - # prompts for password, prints "Logged in as <handle>" 34 + opake login <A-handle> 35 + # resolves PDS, authenticates via OAuth, prints "Logged in as <handle>" 36 36 # also publishes encryption public key (putRecord) 37 37 38 - opake login --pds <B-pds-url> --identifier <B-handle> 38 + opake login <B-handle> 39 39 ``` 40 40 41 41 **Verify:**
+1
CHANGELOG.md
··· 12 12 - Remove bearer token authentication fallback from AppView [#26](https://issues.opake.app/issues/26.html) 13 13 14 14 ### Added 15 + - Add handle-based login with automatic PDS resolution [#182](https://issues.opake.app/issues/182.html) 15 16 - Implement web login flow with AT Protocol OAuth [#167](https://issues.opake.app/issues/167.html) 16 17 - Add AT Protocol OAuth (DPoP) for CLI authentication [#175](https://issues.opake.app/issues/175.html) 17 18 - Wire opake-core WASM into web frontend [#163](https://issues.opake.app/issues/163.html)
+7 -4
README.md
··· 37 37 ## Usage 38 38 39 39 ```sh 40 - # authenticate with your PDS (uses OAuth by default) 41 - opake login --pds https://pds.example.com --identifier alice.example.com 40 + # authenticate (resolves PDS automatically, uses OAuth by default) 41 + opake login alice.example.com 42 + 43 + # explicit PDS override 44 + opake login alice.example.com --pds https://pds.example.com 42 45 43 46 # force legacy password-based auth 44 - opake login --pds https://pds.example.com --identifier alice.example.com --legacy 47 + opake login alice.example.com --legacy 45 48 46 49 # log in to a second account 47 - opake login --pds https://other-pds.example.com --identifier bob.other.com 50 + opake login bob.other.com 48 51 49 52 # list accounts and switch default 50 53 opake accounts
+51 -20
crates/opake-cli/src/commands/login.rs
··· 3 3 use clap::Args; 4 4 use log::debug; 5 5 use opake_core::client::{Session, XrpcClient}; 6 + use opake_core::resolve::resolve_pds_for_login; 6 7 7 8 use crate::config::{AccountConfig, FileStorage}; 8 9 use crate::identity; ··· 33 34 #[derive(Args)] 34 35 /// Authenticate with your PDS 35 36 pub struct LoginCommand { 36 - /// PDS URL (e.g. https://pds.example.com) 37 - #[arg(long)] 38 - pds: String, 37 + /// Handle or DID (e.g. alice.bsky.social, did:plc:...) 38 + identifier: String, 39 39 40 - /// Handle or DID 40 + /// PDS URL override (e.g. https://pds.example.com). Resolved automatically if omitted. 41 41 #[arg(long)] 42 - identifier: String, 42 + pds: Option<String>, 43 43 44 44 /// Force legacy password-based authentication 45 45 #[arg(long)] 46 46 legacy: bool, 47 + 48 + /// Don't redirect to frontend after OAuth - show inline response instead 49 + #[arg(long)] 50 + no_redirect: bool, 47 51 } 48 52 49 53 impl LoginCommand { 50 54 pub async fn execute(self, storage: &FileStorage) -> Result<Option<Session>> { 51 55 debug!("Starting login command"); 52 56 53 - if !self.legacy { 54 - match crate::oauth::try_oauth_login(&self.pds, &self.identifier, storage).await { 55 - Ok(session) => return Ok(Some(session)), 56 - Err(e) => { 57 - log::warn!("OAuth login failed, falling back to password authentication. Password auth is deprecated by AT Protocol and will stop working. Error: {e}"); 58 - } 57 + // Resolve PDS, DID, and handle — either from --pds or by querying the network. 58 + let (pds_url, identifier, resolved_handle) = match self.pds { 59 + Some(pds) => (pds, self.identifier, None), 60 + None => { 61 + println!("Resolving PDS for {}...", self.identifier); 62 + let transport = ReqwestTransport::new(); 63 + let (did, pds, handle) = resolve_pds_for_login(&transport, &self.identifier) 64 + .await 65 + .map_err(|e| { 66 + anyhow::anyhow!("failed to resolve PDS for '{}': {e}", self.identifier) 67 + })?; 68 + debug!("resolved: did={did}, pds={pds}, handle={handle:?}"); 69 + println!("Found PDS: {pds}"); 70 + (pds, did, handle) 59 71 } 72 + }; 73 + 74 + if self.legacy { 75 + return Self::legacy_login(&pds_url, &identifier, storage).await; 60 76 } 61 77 62 - self.legacy_login(storage).await 78 + match crate::oauth::try_oauth_login( 79 + &pds_url, 80 + &identifier, 81 + resolved_handle.as_deref(), 82 + storage, 83 + self.no_redirect, 84 + ) 85 + .await 86 + { 87 + Ok(session) => Ok(Some(session)), 88 + Err(e) => { 89 + log::warn!("OAuth login failed, falling back to password authentication. Password auth is deprecated by AT Protocol and will stop working. Error: {e}"); 90 + Self::legacy_login(&pds_url, &identifier, storage).await 91 + } 92 + } 63 93 } 64 94 65 - async fn legacy_login(self, storage: &FileStorage) -> Result<Option<Session>> { 95 + async fn legacy_login( 96 + pds_url: &str, 97 + identifier: &str, 98 + storage: &FileStorage, 99 + ) -> Result<Option<Session>> { 66 100 let password = resolve_password(prefixed_get_env("PASSWORD"), || { 67 - prompt_password(&self.identifier, &self.pds) 101 + prompt_password(identifier, pds_url) 68 102 })?; 69 103 70 104 let transport = ReqwestTransport::new(); 71 - let mut client = XrpcClient::new(transport, self.pds.clone()); 105 + let mut client = XrpcClient::new(transport, pds_url.to_string()); 72 106 73 - let session = client 74 - .login(self.identifier.trim(), &password) 75 - .await? 76 - .clone(); 107 + let session = client.login(identifier.trim(), &password).await?.clone(); 77 108 78 109 let mut cfg = storage.load_config_anyhow().unwrap_or_default(); 79 110 80 111 cfg.add_account( 81 112 session.did().to_owned(), 82 113 AccountConfig { 83 - pds_url: self.pds.clone(), 114 + pds_url: pds_url.to_string(), 84 115 handle: session.handle().to_owned(), 85 116 }, 86 117 );
+82 -25
crates/opake-cli/src/oauth.rs
··· 23 23 24 24 /// Attempt a full OAuth login flow. Returns `Err` if the PDS doesn't support 25 25 /// OAuth discovery, so the caller can fall back to password auth. 26 + /// 27 + /// `handle` is the resolved handle from the DID document — may differ from 28 + /// `identifier` when the user logs in by DID. 29 + /// 30 + /// `no_redirect` controls the callback response: if false (default), redirects 31 + /// to the frontend callback page; if true, serves inline HTML. 26 32 pub async fn try_oauth_login( 27 33 pds_url: &str, 28 34 identifier: &str, 35 + handle: Option<&str>, 29 36 storage: &FileStorage, 37 + no_redirect: bool, 30 38 ) -> Result<Session> { 31 39 let transport = ReqwestTransport::new(); 32 40 ··· 41 49 // Step 2: Bind loopback server to get the redirect URI 42 50 let listener = TcpListener::bind("127.0.0.1:0").await?; 43 51 let port = listener.local_addr()?.port(); 44 - let redirect_uri = format!("http://127.0.0.1:{port}/callback"); 52 + let redirect_uri = format!("http://localhost:{port}/callback"); 45 53 debug!("loopback server on port {port}"); 46 54 47 55 // Step 3: Generate DPoP keypair and PKCE challenge ··· 53 61 OsRng.fill_bytes(&mut state_bytes); 54 62 let state = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(state_bytes); 55 63 56 - // Client ID: for native apps, use the redirect URI as client_id per atproto spec 64 + // Client ID: for loopback apps, metadata is encoded in the URL query params. 65 + // Must be http://localhost (not 127.0.0.1) — the AS recognizes this as a 66 + // loopback client and uses hardcoded metadata instead of fetching it. 67 + let scope = "atproto transition:generic"; 57 68 let client_id = format!( 58 - "http://localhost?redirect_uri={}", 59 - urlencoding::encode(&redirect_uri) 69 + "http://localhost?redirect_uri={}&scope={}", 70 + urlencoding::encode(&redirect_uri), 71 + urlencoding::encode(scope), 60 72 ); 61 73 62 74 let par_endpoint = asm ··· 75 87 &client_id, 76 88 &redirect_uri, 77 89 &pkce, 78 - "atproto", 90 + scope, 79 91 &state, 80 92 &dpop_key, 81 93 &mut dpop_nonce, ··· 95 107 println!("If the browser doesn't open, visit:\n {auth_url}"); 96 108 open_browser(&auth_url); 97 109 110 + // Frontend callback URL - used when redirecting after OAuth. 111 + // Can be overridden via OPAKE_FRONTEND_URL env var. 112 + let frontend_callback_url = if no_redirect { 113 + None 114 + } else { 115 + Some( 116 + std::env::var("OPAKE_FRONTEND_URL") 117 + .unwrap_or_else(|_| "https://app.opake.app/oauth/cli-callback".to_string()), 118 + ) 119 + }; 120 + 98 121 // Step 6: Wait for the callback (PAR request_uri expires) 99 - let (code, callback_state) = wait_for_callback(listener, par_response.expires_in).await?; 122 + let (code, callback_state, error) = wait_for_callback( 123 + listener, 124 + par_response.expires_in, 125 + frontend_callback_url.as_deref(), 126 + ) 127 + .await?; 128 + 129 + let callback_state = 130 + callback_state.ok_or_else(|| anyhow::anyhow!("callback missing state parameter"))?; 100 131 anyhow::ensure!( 101 132 callback_state == state, 102 133 "OAuth state mismatch — possible CSRF attack" 103 134 ); 135 + 136 + // Check for AS errors (user denied, etc.) after CSRF validation 137 + if let Some(err) = error { 138 + anyhow::bail!("OAuth error from AS: {err}"); 139 + } 140 + 141 + let code = code.ok_or_else(|| anyhow::anyhow!("callback missing authorization code"))?; 104 142 info!("received authorization code"); 105 143 106 144 // Step 7: Exchange code for tokens ··· 128 166 .ok_or_else(|| anyhow::anyhow!("token response missing `sub` claim"))?; 129 167 info!("authenticated as {did}"); 130 168 131 - let handle = identifier.to_string(); 169 + let handle = handle 170 + .map(|h| h.to_string()) 171 + .or_else(|| { 172 + // Only use the raw identifier as handle if it's not a DID. 173 + (!identifier.starts_with("did:")).then(|| identifier.to_string()) 174 + }) 175 + .unwrap_or_default(); 132 176 133 177 let expires_at = token_response 134 178 .expires_in ··· 190 234 } 191 235 192 236 /// Wait for the OAuth callback on the loopback server. 193 - /// Returns `(code, state)` from the query parameters. 237 + /// Returns `(code, state, error)` from the query parameters. 194 238 /// Times out after `expires_in` seconds (the PAR request_uri lifetime). 195 - async fn wait_for_callback(listener: TcpListener, expires_in: u64) -> Result<(String, String)> { 239 + /// 240 + /// If `frontend_callback_url` is `Some`, redirects to that URL after OAuth. 241 + /// If `None`, serves inline HTML instead (used with `--no-redirect`). 242 + async fn wait_for_callback( 243 + listener: TcpListener, 244 + expires_in: u64, 245 + frontend_callback_url: Option<&str>, 246 + ) -> Result<(Option<String>, Option<String>, Option<String>)> { 196 247 let timeout = std::time::Duration::from_secs(expires_in); 197 248 let (mut stream, _addr) = tokio::time::timeout(timeout, listener.accept()) 198 249 .await ··· 233 284 } 234 285 } 235 286 236 - // Respond to browser 237 - let (status, body) = if error.is_some() { 238 - ("400 Bad Request", "<html><body><h1>Authentication failed</h1><p>You can close this tab.</p></body></html>") 287 + // Build response: redirect to frontend or inline HTML 288 + let response = if let Some(callback_url) = frontend_callback_url { 289 + // Redirect to frontend callback — no OAuth params, they're already 290 + // handled by CLI. Only pass error for display if present. 291 + let location = if let Some(ref err) = error { 292 + let err = urlencoding::encode(err); 293 + format!("{callback_url}?error={err}") 294 + } else { 295 + callback_url.to_string() 296 + }; 297 + format!("HTTP/1.1 302 Found\r\nLocation: {location}\r\nConnection: close\r\n\r\n",) 239 298 } else { 240 - ("200 OK", "<html><body><h1>Authentication successful</h1><p>You can close this tab and return to the terminal.</p></body></html>") 299 + // Inline HTML response (--no-redirect) 300 + let (status, body) = if error.is_some() { 301 + ("400 Bad Request", "<html><body><h1>Authentication failed</h1><p>You can close this tab.</p></body></html>") 302 + } else { 303 + ("200 OK", "<html><body><h1>Authentication successful</h1><p>You can close this tab and return to your terminal.</p></body></html>") 304 + }; 305 + format!( 306 + "HTTP/1.1 {status}\r\nContent-Type: text/html\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", 307 + body.len() 308 + ) 241 309 }; 242 310 243 - let response = format!( 244 - "HTTP/1.1 {status}\r\nContent-Type: text/html\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", 245 - body.len() 246 - ); 247 311 stream.write_all(response.as_bytes()).await?; 248 312 stream.shutdown().await?; 249 313 250 - if let Some(err) = error { 251 - anyhow::bail!("OAuth error from AS: {err}"); 252 - } 253 - 254 - Ok(( 255 - code.ok_or_else(|| anyhow::anyhow!("callback missing `code` parameter"))?, 256 - state.ok_or_else(|| anyhow::anyhow!("callback missing `state` parameter"))?, 257 - )) 314 + Ok((code, state, error)) 258 315 } 259 316 260 317 /// Open a URL in the system browser. Best-effort — doesn't fail if the
+6 -3
crates/opake-core/src/client/dpop.rs
··· 184 184 response.header("dpop-nonce").map(|v| v.to_string()) 185 185 } 186 186 187 - /// Check whether a response is a `use_dpop_nonce` error — the AS telling us 188 - /// to retry with the nonce it provided in the `DPoP-Nonce` header. 187 + /// Check whether a response is a `use_dpop_nonce` error — the server telling 188 + /// us to retry with the nonce it provided in the `DPoP-Nonce` header. 189 + /// 190 + /// The AS returns 400 during token exchange; the PDS returns 401 for resource 191 + /// requests. Both are valid per the atproto OAuth spec. 189 192 pub fn is_use_dpop_nonce_error(response: &HttpResponse) -> bool { 190 - if response.status != 400 { 193 + if response.status != 400 && response.status != 401 { 191 194 return false; 192 195 } 193 196
+11 -1
crates/opake-core/src/client/dpop_tests.rs
··· 256 256 } 257 257 258 258 #[test] 259 - fn is_use_dpop_nonce_error_rejects_non_400() { 259 + fn is_use_dpop_nonce_error_detects_401() { 260 260 let response = HttpResponse { 261 261 status: 401, 262 + headers: vec![("DPoP-Nonce".into(), "pds-nonce".into())], 263 + body: br#"{"error":"use_dpop_nonce"}"#.to_vec(), 264 + }; 265 + assert!(is_use_dpop_nonce_error(&response)); 266 + } 267 + 268 + #[test] 269 + fn is_use_dpop_nonce_error_rejects_other_status() { 270 + let response = HttpResponse { 271 + status: 403, 262 272 headers: vec![], 263 273 body: br#"{"error":"use_dpop_nonce"}"#.to_vec(), 264 274 };
+13 -2
crates/opake-core/src/client/xrpc/mod.rs
··· 10 10 11 11 use serde::{Deserialize, Serialize}; 12 12 13 - use super::dpop::{create_dpop_proof, extract_dpop_nonce, DpopKeyPair}; 13 + use super::dpop::{create_dpop_proof, extract_dpop_nonce, is_use_dpop_nonce_error, DpopKeyPair}; 14 14 use super::transport::*; 15 15 use crate::crypto::OsRng; 16 16 use crate::error::Error; ··· 90 90 let value = serde_json::Value::deserialize(deserializer)?; 91 91 92 92 match value.get("type").and_then(|t| t.as_str()) { 93 - Some("oauth") => { 93 + Some("oauth" | "oAuth") => { 94 94 let oauth: OAuthSession = 95 95 serde_json::from_value(value).map_err(serde::de::Error::custom)?; 96 96 Ok(Session::OAuth(oauth)) ··· 305 305 let mut response = self.transport.send(request.clone()).await?; 306 306 self.update_dpop_nonce(&response); 307 307 308 + // The PDS may reject the first request for a stale DPoP nonce (the AS 309 + // nonce doesn't work here). Retry with the PDS-provided nonce. 310 + if is_use_dpop_nonce_error(&response) { 311 + let retried = self.replace_auth_headers(request.clone())?; 312 + response = self.transport.send(retried).await?; 313 + self.update_dpop_nonce(&response); 314 + } 315 + 316 + // After a nonce fix (or on the first attempt), the token itself may be 317 + // expired. Refresh and retry. A second nonce error here is not retried — 318 + // one retry is the spec-expected behavior (RFC 9449 §7.1). 308 319 if Self::is_expired_token(&response) { 309 320 self.refresh_session().await?; 310 321 let retried = self.replace_auth_headers(request)?;
+145
crates/opake-core/src/resolve.rs
··· 10 10 get_record_public, pds_from_did_document, resolve_did_document, resolve_handle, Transport, 11 11 XrpcClient, 12 12 }; 13 + 14 + /// Public Bluesky API — used for handle resolution when no PDS is known yet. 15 + const BSKY_PUBLIC_API: &str = "https://public.api.bsky.app"; 13 16 use crate::crypto::X25519PublicKey; 14 17 use crate::error::Error; 15 18 use crate::records::{self, PublicKeyRecord, PUBLIC_KEY_COLLECTION, PUBLIC_KEY_RKEY}; ··· 27 30 pub algo: String, 28 31 /// Ed25519 signing key — present if the user has published one. 29 32 pub signing_key: Option<Ed25519PublicKeyBytes>, 33 + } 34 + 35 + /// Bootstrap resolution for login: resolve a handle or DID to (did, pds_url, handle) 36 + /// without needing a known PDS. 37 + /// 38 + /// If input starts with `did:` → fetch DID document → extract PDS + handle. 39 + /// If input is a handle → resolve via the public Bluesky API → fetch DID document → extract PDS. 40 + /// 41 + /// The returned handle comes from the DID document's `alsoKnownAs`, not the raw input. 42 + pub async fn resolve_pds_for_login( 43 + transport: &impl Transport, 44 + handle_or_did: &str, 45 + ) -> Result<(String, String, Option<String>), Error> { 46 + let did = if handle_or_did.starts_with("did:") { 47 + debug!("input is already a DID: {}", handle_or_did); 48 + handle_or_did.to_string() 49 + } else { 50 + debug!("resolving handle {} via public API", handle_or_did); 51 + resolve_handle(transport, BSKY_PUBLIC_API, handle_or_did).await? 52 + }; 53 + 54 + debug!("fetching DID document for {}", did); 55 + let doc = resolve_did_document(transport, &did).await?; 56 + let pds_url = pds_from_did_document(&doc)?; 57 + debug!("resolved PDS: {}", pds_url); 58 + 59 + let handle = doc 60 + .also_known_as 61 + .iter() 62 + .find_map(|alias| alias.strip_prefix("at://")) 63 + .map(|h| h.to_string()); 64 + 65 + Ok((did, pds_url, handle)) 30 66 } 31 67 32 68 /// Full resolution: input → DID → PDS → public key. ··· 303 339 let reqs = mock.requests(); 304 340 assert_eq!(reqs.len(), 1); 305 341 assert!(reqs[0].url.contains("putRecord")); 342 + } 343 + 344 + #[tokio::test] 345 + async fn login_resolve_from_handle() { 346 + let mock = MockTransport::new(); 347 + 348 + // 1. resolveHandle via public API → DID 349 + mock.enqueue(success(r#"{"did":"did:plc:alice"}"#)); 350 + // 2. DID document 351 + mock.enqueue(success(&did_document_json( 352 + "did:plc:alice", 353 + "alice.bsky.social", 354 + "https://morel.us-east.host.bsky.network", 355 + ))); 356 + 357 + let (did, pds, handle) = resolve_pds_for_login(&mock, "alice.bsky.social") 358 + .await 359 + .unwrap(); 360 + 361 + assert_eq!(did, "did:plc:alice"); 362 + assert_eq!(pds, "https://morel.us-east.host.bsky.network"); 363 + assert_eq!(handle.as_deref(), Some("alice.bsky.social")); 364 + 365 + let reqs = mock.requests(); 366 + assert_eq!(reqs.len(), 2); 367 + assert!(reqs[0].url.contains("resolveHandle")); 368 + assert!(reqs[0].url.contains("public.api.bsky.app")); 369 + assert!(reqs[1].url.contains("plc.directory")); 370 + } 371 + 372 + #[tokio::test] 373 + async fn login_resolve_from_did() { 374 + let mock = MockTransport::new(); 375 + 376 + // Only 1 request — DID document, no resolveHandle 377 + mock.enqueue(success(&did_document_json( 378 + "did:plc:bob", 379 + "bob.test", 380 + "https://pds.bob.example.com", 381 + ))); 382 + 383 + let (did, pds, handle) = resolve_pds_for_login(&mock, "did:plc:bob").await.unwrap(); 384 + 385 + assert_eq!(did, "did:plc:bob"); 386 + assert_eq!(pds, "https://pds.bob.example.com"); 387 + assert_eq!(handle.as_deref(), Some("bob.test")); 388 + 389 + assert_eq!(mock.requests().len(), 1); 390 + } 391 + 392 + #[tokio::test] 393 + async fn login_resolve_handle_not_found() { 394 + let mock = MockTransport::new(); 395 + 396 + // resolveHandle returns 400 (unknown handle) 397 + mock.enqueue(HttpResponse { 398 + status: 400, 399 + headers: vec![], 400 + body: br#"{"error":"InvalidRequest","message":"Unable to resolve handle"}"#.to_vec(), 401 + }); 402 + 403 + let err = resolve_pds_for_login(&mock, "nonexistent.invalid") 404 + .await 405 + .unwrap_err(); 406 + assert!(err.to_string().contains("400"), "got: {err}"); 407 + } 408 + 409 + #[tokio::test] 410 + async fn login_resolve_did_no_pds_in_document() { 411 + let mock = MockTransport::new(); 412 + 413 + // DID doc with no atproto_pds service 414 + mock.enqueue(success( 415 + &serde_json::json!({ 416 + "id": "did:plc:nopds", 417 + "service": [] 418 + }) 419 + .to_string(), 420 + )); 421 + 422 + let err = resolve_pds_for_login(&mock, "did:plc:nopds") 423 + .await 424 + .unwrap_err(); 425 + assert!(err.to_string().contains("atproto_pds"), "got: {err}"); 426 + } 427 + 428 + #[tokio::test] 429 + async fn login_resolve_did_no_also_known_as() { 430 + let mock = MockTransport::new(); 431 + 432 + // DID doc with PDS but no alsoKnownAs 433 + mock.enqueue(success( 434 + &serde_json::json!({ 435 + "id": "did:plc:nohandle", 436 + "service": [{ 437 + "id": "#atproto_pds", 438 + "serviceEndpoint": "https://pds.nohandle", 439 + }] 440 + }) 441 + .to_string(), 442 + )); 443 + 444 + let (did, pds, handle) = resolve_pds_for_login(&mock, "did:plc:nohandle") 445 + .await 446 + .unwrap(); 447 + 448 + assert_eq!(did, "did:plc:nohandle"); 449 + assert_eq!(pds, "https://pds.nohandle"); 450 + assert!(handle.is_none()); 306 451 } 307 452 308 453 #[tokio::test]
+4 -2
docs/flows/authentication.md
··· 11 11 participant Browser 12 12 participant PDS/AS 13 13 14 - User->>CLI: opake login --pds <url> --identifier <handle> 14 + User->>CLI: opake login <handle> 15 + CLI->>CLI: Resolve PDS from handle (public API → DID doc) 15 16 16 17 CLI->>PDS/AS: GET /.well-known/oauth-protected-resource 17 18 PDS/AS-->>CLI: { authorization_servers: [<as_url>] } ··· 54 55 participant CLI 55 56 participant PDS 56 57 57 - User->>CLI: opake login --pds <url> --identifier <handle> --legacy 58 + User->>CLI: opake login <handle> --legacy 59 + CLI->>CLI: Resolve PDS from handle 58 60 CLI->>User: Password prompt (or OPAKE_PASSWORD env) 59 61 User-->>CLI: password 60 62
+2 -2
docs/flows/multi-device.md
··· 36 36 participant Crypto 37 37 participant PDS 38 38 39 - User->>CLI: opake login --pds <url> --identifier <handle> 39 + User->>CLI: opake login <handle> 40 40 CLI->>User: No identity found. Enter seed phrase or generate new? 41 41 User-->>CLI: "abandon ability able about above absent ..." 42 42 ··· 98 98 participant CLI 99 99 participant PDS 100 100 101 - User->>CLI: opake login --pds <url> --identifier <handle> 101 + User->>CLI: opake login <handle> 102 102 CLI->>CLI: Derive keypair from seed phrase 103 103 104 104 CLI->>PDS: getRecord (publicKey/self)
+17 -17
web/src/routeTree.gen.ts
··· 12 12 import { Route as LoginRouteImport } from './routes/login' 13 13 import { Route as CabinetRouteImport } from './routes/cabinet' 14 14 import { Route as IndexRouteImport } from './routes/index' 15 - import { Route as OauthCallbackRouteImport } from './routes/oauth.callback' 15 + import { Route as OauthCliCallbackRouteImport } from './routes/oauth.cli-callback' 16 16 17 17 const LoginRoute = LoginRouteImport.update({ 18 18 id: '/login', ··· 29 29 path: '/', 30 30 getParentRoute: () => rootRouteImport, 31 31 } as any) 32 - const OauthCallbackRoute = OauthCallbackRouteImport.update({ 33 - id: '/oauth/callback', 34 - path: '/oauth/callback', 32 + const OauthCliCallbackRoute = OauthCliCallbackRouteImport.update({ 33 + id: '/oauth/cli-callback', 34 + path: '/oauth/cli-callback', 35 35 getParentRoute: () => rootRouteImport, 36 36 } as any) 37 37 ··· 39 39 '/': typeof IndexRoute 40 40 '/cabinet': typeof CabinetRoute 41 41 '/login': typeof LoginRoute 42 - '/oauth/callback': typeof OauthCallbackRoute 42 + '/oauth/cli-callback': typeof OauthCliCallbackRoute 43 43 } 44 44 export interface FileRoutesByTo { 45 45 '/': typeof IndexRoute 46 46 '/cabinet': typeof CabinetRoute 47 47 '/login': typeof LoginRoute 48 - '/oauth/callback': typeof OauthCallbackRoute 48 + '/oauth/cli-callback': typeof OauthCliCallbackRoute 49 49 } 50 50 export interface FileRoutesById { 51 51 __root__: typeof rootRouteImport 52 52 '/': typeof IndexRoute 53 53 '/cabinet': typeof CabinetRoute 54 54 '/login': typeof LoginRoute 55 - '/oauth/callback': typeof OauthCallbackRoute 55 + '/oauth/cli-callback': typeof OauthCliCallbackRoute 56 56 } 57 57 export interface FileRouteTypes { 58 58 fileRoutesByFullPath: FileRoutesByFullPath 59 - fullPaths: '/' | '/cabinet' | '/login' | '/oauth/callback' 59 + fullPaths: '/' | '/cabinet' | '/login' | '/oauth/cli-callback' 60 60 fileRoutesByTo: FileRoutesByTo 61 - to: '/' | '/cabinet' | '/login' | '/oauth/callback' 62 - id: '__root__' | '/' | '/cabinet' | '/login' | '/oauth/callback' 61 + to: '/' | '/cabinet' | '/login' | '/oauth/cli-callback' 62 + id: '__root__' | '/' | '/cabinet' | '/login' | '/oauth/cli-callback' 63 63 fileRoutesById: FileRoutesById 64 64 } 65 65 export interface RootRouteChildren { 66 66 IndexRoute: typeof IndexRoute 67 67 CabinetRoute: typeof CabinetRoute 68 68 LoginRoute: typeof LoginRoute 69 - OauthCallbackRoute: typeof OauthCallbackRoute 69 + OauthCliCallbackRoute: typeof OauthCliCallbackRoute 70 70 } 71 71 72 72 declare module '@tanstack/react-router' { ··· 92 92 preLoaderRoute: typeof IndexRouteImport 93 93 parentRoute: typeof rootRouteImport 94 94 } 95 - '/oauth/callback': { 96 - id: '/oauth/callback' 97 - path: '/oauth/callback' 98 - fullPath: '/oauth/callback' 99 - preLoaderRoute: typeof OauthCallbackRouteImport 95 + '/oauth/cli-callback': { 96 + id: '/oauth/cli-callback' 97 + path: '/oauth/cli-callback' 98 + fullPath: '/oauth/cli-callback' 99 + preLoaderRoute: typeof OauthCliCallbackRouteImport 100 100 parentRoute: typeof rootRouteImport 101 101 } 102 102 } ··· 106 106 IndexRoute: IndexRoute, 107 107 CabinetRoute: CabinetRoute, 108 108 LoginRoute: LoginRoute, 109 - OauthCallbackRoute: OauthCallbackRoute, 109 + OauthCliCallbackRoute: OauthCliCallbackRoute, 110 110 } 111 111 export const routeTree = rootRouteImport 112 112 ._addFileChildren(rootRouteChildren)
-52
web/src/routes/oauth.callback.tsx
··· 1 - import { useEffect } from "react"; 2 - import { createFileRoute, useNavigate } from "@tanstack/react-router"; 3 - import { useAuthStore } from "@/stores/auth"; 4 - 5 - function OAuthCallbackPage() { 6 - const navigate = useNavigate(); 7 - const phase = useAuthStore((s) => s.phase); 8 - const completeLogin = useAuthStore((s) => s.completeLogin); 9 - 10 - useEffect(() => { 11 - const params = new URLSearchParams(window.location.search); 12 - const code = params.get("code"); 13 - const state = params.get("state"); 14 - const error = params.get("error"); 15 - 16 - if (error) { 17 - const description = params.get("error_description") ?? error; 18 - navigate({ to: "/login", search: { error: description } }); 19 - return; 20 - } 21 - 22 - if (!code || !state) { 23 - navigate({ to: "/login", search: { error: "Missing OAuth parameters" } }); 24 - return; 25 - } 26 - 27 - completeLogin(code, state); 28 - }, [completeLogin, navigate]); 29 - 30 - useEffect(() => { 31 - if (phase === "ready") { 32 - navigate({ to: "/cabinet" }); 33 - } else if (phase === "error") { 34 - const state = useAuthStore.getState(); 35 - const message = "message" in state ? state.message : "Authentication failed"; 36 - navigate({ to: "/login", search: { error: message } }); 37 - } 38 - }, [phase, navigate]); 39 - 40 - return ( 41 - <div className="flex min-h-screen items-center justify-center bg-base-300 font-sans"> 42 - <div className="flex flex-col items-center gap-4"> 43 - <span className="loading loading-spinner loading-lg" /> 44 - <p className="text-sm text-text-muted">Completing authentication…</p> 45 - </div> 46 - </div> 47 - ); 48 - } 49 - 50 - export const Route = createFileRoute("/oauth/callback")({ 51 - component: OAuthCallbackPage, 52 - });
+104
web/src/routes/oauth.cli-callback.tsx
··· 1 + import { CheckIcon } from "@phosphor-icons/react"; 2 + import { useEffect, useState } from "react"; 3 + import { createFileRoute } from "@tanstack/react-router"; 4 + import { OpakeLogo } from "@/components/OpakeLogo"; 5 + 6 + type CallbackState = "loading" | "success" | "error"; 7 + 8 + function OAuthCallbackPage() { 9 + const [state, setState] = useState<CallbackState>("loading"); 10 + const [errorMessage, setErrorMessage] = useState<string>(""); 11 + 12 + useEffect(() => { 13 + const params = new URLSearchParams(window.location.search); 14 + const error = params.get("error"); 15 + 16 + // Strip query params from URL 17 + window.history.replaceState({}, "", window.location.pathname); 18 + 19 + if (error) { 20 + setErrorMessage(error); 21 + setState("error"); 22 + return; 23 + } 24 + 25 + // Just show success - CLI login doesn't log you into web 26 + setState("success"); 27 + }, []); 28 + 29 + return ( 30 + <div className="flex min-h-screen items-center justify-center bg-base-300 font-sans"> 31 + <div className="flex w-full max-w-md flex-col items-center gap-8 px-6 py-12"> 32 + <OpakeLogo size="lg" /> 33 + 34 + {state === "loading" && ( 35 + <div className="flex flex-col items-center gap-4"> 36 + <span className="loading loading-spinner loading-lg text-primary" /> 37 + <p className="text-sm text-base-content/60">Completing authentication…</p> 38 + </div> 39 + )} 40 + 41 + {state === "success" && ( 42 + <div className="flex flex-col items-center gap-6 text-center"> 43 + <div className="flex h-16 w-16 items-center justify-center rounded-full bg-success/20"> 44 + <CheckIcon size={32} /> 45 + </div> 46 + 47 + <div className="flex flex-col gap-2"> 48 + <h1 className="text-2xl font-semibold text-base-content"> 49 + CLI login successful 50 + </h1> 51 + </div> 52 + 53 + <div className="mt-2 flex flex-col gap-3 text-sm text-base-content/50"> 54 + <p> 55 + You can close this tab and return to your terminal. 56 + </p> 57 + <p> 58 + <span className="font-medium text-base-content/70">Note:</span> This 59 + logs you into the CLI only. The web app requires a separate login. 60 + </p> 61 + </div> 62 + </div> 63 + )} 64 + 65 + {state === "error" && ( 66 + <div className="flex flex-col items-center gap-6 text-center"> 67 + <div className="flex h-16 w-16 items-center justify-center rounded-full bg-error/20"> 68 + <svg 69 + className="h-8 w-8 text-error" 70 + fill="none" 71 + viewBox="0 0 24 24" 72 + stroke="currentColor" 73 + strokeWidth={2} 74 + > 75 + <path 76 + strokeLinecap="round" 77 + strokeLinejoin="round" 78 + d="M6 18L18 6M6 6l12 12" 79 + /> 80 + </svg> 81 + </div> 82 + 83 + <div className="flex flex-col gap-2"> 84 + <h1 className="text-2xl font-semibold text-base-content"> 85 + CLI login failed 86 + </h1> 87 + <p className="text-base-content/60">{errorMessage}</p> 88 + </div> 89 + 90 + <div className="mt-2 flex flex-col gap-3 text-sm text-base-content/50"> 91 + <p> 92 + Please try again from your terminal. 93 + </p> 94 + </div> 95 + </div> 96 + )} 97 + </div> 98 + </div> 99 + ); 100 + } 101 + 102 + export const Route = createFileRoute("/oauth/cli-callback")({ 103 + component: OAuthCallbackPage, 104 + });