Skip to content

Commit e14d689

Browse files
committed
feat: auth login --no-browser via OAuth2 device authorization grant
The existing `auth login` uses an authorization-code-with-PKCE flow that binds a localhost HTTP server to receive the OIDC redirect. On a remote machine over SSH, in CI, or in a container, that pattern is unreachable — the browser running on the operator's laptop can't reach a port bound on the remote box without a separate SSH port-forward. The standard escape hatch is RFC 8628 OAuth2 device authorization, which is what datumctl's own `login --no-browser` uses. Mirror that here: - StatelessClient::login_device_code() — fetches the OIDC discovery JSON directly (openidconnect's CoreProviderMetadata doesn't surface device_authorization_endpoint), rebuilds the OIDC client with set_device_authorization_url(), starts the grant, hands the DeviceCodeInfo (verification URL + user code + expiry) to a caller- supplied display callback, and polls exchange_device_access_token via tokio::time::sleep. Token-response parsing reuses the existing parse_token_response with a nonce verifier that allows missing-nonce (device flow doesn't bind one). - AuthClient::login_device_code() — always performs a fresh login; callers wanting refresh-eligible token reuse should use the normal login() instead. - DatumCloudClient::login_device_code() — top-level entry point. - DeviceCodeInfo re-exported from lib::datum_cloud so the CLI doesn't take a direct dep on openidconnect's Core types. CLI side, AuthCommands::Login and AuthCommands::Switch both gain a --no-browser flag that routes to the new method. The display callback prints the verification URL + user code prominently to stderr so it doesn't tangle with structured stdout (relevant for future plugin modes). Verified against the production auth server's OIDC discovery (Datum's Zitadel exposes device_authorization_endpoint and lists urn:ietf:params:oauth:grant-type:device_code in grant_types_supported).
1 parent 7fbd576 commit e14d689

3 files changed

Lines changed: 215 additions & 12 deletions

File tree

cli/src/main.rs

Lines changed: 58 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ use rustls::crypto::ring as rustls_ring;
99
use lib::{
1010
Advertisment, AdvertismentTicket, ConnectNode, DiscoveryMode, HeartbeatAgent, ListenNode,
1111
ProgressStepKind, ProxyState, Repo, SelectedContext, StepStatus, TcpProxyData, TunnelService,
12-
datum_cloud::{ApiEnv, DatumCloudClient, LoginState},
12+
datum_cloud::{ApiEnv, DatumCloudClient, DeviceCodeInfo, LoginState},
1313
};
1414
use n0_error::StdResultExt;
1515
use std::{
@@ -221,7 +221,16 @@ pub enum AuthCommands {
221221
Status,
222222

223223
/// Log in to Datum Cloud (opens browser for OAuth).
224-
Login,
224+
Login {
225+
/// Skip the localhost-redirect flow and use the OAuth2 device
226+
/// authorization grant instead. Required when running on a
227+
/// remote machine over SSH, in CI, or in a container — anywhere
228+
/// the localhost-redirect can't reach a browser. The CLI prints
229+
/// a verification URL + user code; complete authorization on
230+
/// another device.
231+
#[clap(long)]
232+
no_browser: bool,
233+
},
225234

226235
/// Log out and clear stored credentials.
227236
Logout,
@@ -230,7 +239,12 @@ pub enum AuthCommands {
230239
List,
231240

232241
/// Switch to a different authenticated user (clears current and prompts for new login).
233-
Switch,
242+
Switch {
243+
/// Same as `auth login --no-browser`: use the OAuth2 device
244+
/// authorization grant for the fresh login after logout.
245+
#[clap(long)]
246+
no_browser: bool,
247+
},
234248
}
235249

236250
#[derive(Parser, Debug)]
@@ -394,8 +408,12 @@ async fn main() -> n0_error::Result<()> {
394408
println!("Not authenticated");
395409
}
396410
}
397-
AuthCommands::Login => {
398-
datum.login().await?;
411+
AuthCommands::Login { no_browser } => {
412+
if no_browser {
413+
datum.login_device_code(display_device_code).await?;
414+
} else {
415+
datum.login().await?;
416+
}
399417
if let Ok(state) = datum.auth_state().get() {
400418
println!(
401419
"Logged in as {} ({})",
@@ -425,10 +443,14 @@ async fn main() -> n0_error::Result<()> {
425443
println!();
426444
println!("Note: Multi-user storage not yet implemented. Use 'auth switch' to log in as a different user.");
427445
}
428-
AuthCommands::Switch => {
446+
AuthCommands::Switch { no_browser } => {
429447
datum.logout().await?;
430448
println!("Switching users...");
431-
datum.login().await?;
449+
if no_browser {
450+
datum.login_device_code(display_device_code).await?;
451+
} else {
452+
datum.login().await?;
453+
}
432454
if let Ok(state) = datum.auth_state().get() {
433455
println!(
434456
"Switched to {} ({})",
@@ -1088,6 +1110,35 @@ fn resolve_project_context(
10881110
None
10891111
}
10901112

1113+
/// Renders an OAuth2 device authorization grant prompt for `auth login
1114+
/// --no-browser` / `auth switch --no-browser`. Prints the verification
1115+
/// URL + user code prominently to stderr (so it doesn't get tangled
1116+
/// with the structured-output JSON future plugin modes may emit to
1117+
/// stdout), then returns so the polling loop in lib can proceed.
1118+
async fn display_device_code(info: DeviceCodeInfo) {
1119+
eprintln!();
1120+
eprintln!("================================================================");
1121+
eprintln!(" Open this URL on another device to authorize:");
1122+
eprintln!();
1123+
eprintln!(" {}", info.verification_uri);
1124+
eprintln!();
1125+
eprintln!(" Enter this code when prompted:");
1126+
eprintln!();
1127+
eprintln!(" {}", info.user_code);
1128+
if let Some(complete) = &info.verification_uri_complete {
1129+
eprintln!();
1130+
eprintln!(" (Or open this pre-filled URL to skip the code step:");
1131+
eprintln!(" {})", complete);
1132+
}
1133+
eprintln!();
1134+
eprintln!(
1135+
" Code expires in {} seconds. Waiting for authorization…",
1136+
info.expires_in.as_secs(),
1137+
);
1138+
eprintln!("================================================================");
1139+
eprintln!();
1140+
}
1141+
10911142
/// Result of streaming the tunnel-setup progress to stdout. All conditions
10921143
/// reached `Ready` (or we bailed before that for a terminal failure).
10931144
struct SetupResult {

lib/src/datum_cloud.rs

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ use crate::http_user_agent::datum_http_user_agent;
1414
use crate::{ProjectControlPlaneClient, Repo, SelectedContext};
1515

1616
pub use self::{
17-
auth::{AuthClient, AuthState, LoginState, MaybeAuth, UserProfile},
17+
auth::{AuthClient, AuthState, DeviceCodeInfo, LoginState, MaybeAuth, UserProfile},
1818
env::ApiEnv,
1919
};
2020

@@ -105,6 +105,18 @@ impl DatumCloudClient {
105105
self.auth.login().await
106106
}
107107

108+
/// `--no-browser` login via the OAuth2 device authorization grant.
109+
/// The CLI's `auth login --no-browser` flag routes here; the `display`
110+
/// callback shows the verification URL + user code to the operator
111+
/// who completes authorization on another device.
112+
pub async fn login_device_code<F, Fut>(&self, display: F) -> Result<()>
113+
where
114+
F: FnOnce(DeviceCodeInfo) -> Fut,
115+
Fut: std::future::Future<Output = ()>,
116+
{
117+
self.auth.login_device_code(display).await
118+
}
119+
108120
pub async fn logout(&self) -> Result<()> {
109121
self.auth.logout().await
110122
}

lib/src/datum_cloud/auth.rs

Lines changed: 144 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,12 @@ use arc_swap::ArcSwap;
1111
use chrono::Utc;
1212
use n0_error::{Result, StackResultExt, StdResultExt, anyerr, stack_error};
1313
use openidconnect::{
14-
AccessToken, AccessTokenHash, AuthorizationCode, ClientId, ClientSecret, CsrfToken, IssuerUrl,
15-
Nonce, NonceVerifier, OAuth2TokenResponse, PkceCodeChallenge, RefreshToken, Scope,
16-
TokenResponse,
17-
core::{CoreAuthenticationFlow, CoreClient, CoreProviderMetadata},
14+
AccessToken, AccessTokenHash, AuthorizationCode, ClientId, ClientSecret, CsrfToken,
15+
DeviceAuthorizationUrl, IssuerUrl, Nonce, NonceVerifier, OAuth2TokenResponse,
16+
PkceCodeChallenge, RefreshToken, Scope, TokenResponse,
17+
core::{
18+
CoreAuthenticationFlow, CoreClient, CoreDeviceAuthorizationResponse, CoreProviderMetadata,
19+
},
1820
};
1921
use serde::{Deserialize, Serialize};
2022
use tokio::sync::watch;
@@ -31,6 +33,25 @@ const LOGIN_TIMEOUT: Duration = Duration::from_secs(60);
3133
/// Refresh auth or relogin if access token is valid for less than 30min
3234
const REFRESH_AUTH_WHEN: Duration = Duration::from_secs(60 * 30);
3335

36+
/// Surface of an in-flight OAuth2 device authorization grant. The
37+
/// `display` callback passed to [`StatelessClient::login_device_code`]
38+
/// receives one of these and is responsible for showing the verification
39+
/// URL + user code to the operator.
40+
#[derive(Debug, Clone)]
41+
pub struct DeviceCodeInfo {
42+
/// URL the user opens on their other device to authorize.
43+
pub verification_uri: String,
44+
/// Short code the user enters at `verification_uri`.
45+
pub user_code: String,
46+
/// Optional URL that pre-fills the user code, so the user only has
47+
/// to follow one link.
48+
pub verification_uri_complete: Option<String>,
49+
/// How long the user has before the device code expires.
50+
pub expires_in: Duration,
51+
/// Operator-side polling interval recommended by the auth server.
52+
pub interval: Duration,
53+
}
54+
3455
pub struct AuthProvider {
3556
pub issuer_url: String,
3657
pub client_id: String,
@@ -241,6 +262,107 @@ impl StatelessClient {
241262
Ok(state)
242263
}
243264

265+
/// OAuth2 Device Authorization grant (RFC 8628). Used by `datum-connect
266+
/// auth login --no-browser` and any other headless context where the
267+
/// localhost-redirect flow can't reach back to a browser (SSH, CI,
268+
/// containers). The caller receives a `DeviceCodeInfo` via the
269+
/// `display` callback and is responsible for showing the verification
270+
/// URL + user code to the operator; this method then polls the token
271+
/// endpoint until the user completes authorization.
272+
pub async fn login_device_code<F, Fut>(&self, display: F) -> Result<AuthState>
273+
where
274+
F: FnOnce(DeviceCodeInfo) -> Fut,
275+
Fut: Future<Output = ()>,
276+
{
277+
// `openidconnect::CoreProviderMetadata` doesn't surface the
278+
// `device_authorization_endpoint` from discovery, so refetch the
279+
// raw JSON to find it.
280+
let discovery_url = format!(
281+
"{}/.well-known/openid-configuration",
282+
self.env.auth_provider().issuer_url.trim_end_matches('/'),
283+
);
284+
#[derive(Deserialize)]
285+
struct DiscoveryDoc {
286+
device_authorization_endpoint: Option<String>,
287+
}
288+
let discovery: DiscoveryDoc = self
289+
.http
290+
.get(&discovery_url)
291+
.send()
292+
.await
293+
.std_context("Failed to fetch OIDC discovery document")?
294+
.error_for_status()
295+
.std_context("OIDC discovery returned a non-success status")?
296+
.json()
297+
.await
298+
.std_context("Failed to parse OIDC discovery document")?;
299+
let device_endpoint = discovery.device_authorization_endpoint.context(
300+
"Auth server does not advertise a device_authorization_endpoint; \
301+
--no-browser is unsupported against this provider",
302+
)?;
303+
304+
// Rebuild a CoreClient with the device URL set. The crate's
305+
// typestate prevents mutating the cached `self.oidc` in place,
306+
// and the discovery the constructor performs is cheap enough to
307+
// accept the duplication.
308+
let provider = self.env.auth_provider();
309+
let issuer = IssuerUrl::new(provider.issuer_url.clone())
310+
.std_context("Invalid OIDC provider issuer URL")?;
311+
let metadata = CoreProviderMetadata::discover_async(issuer, &self.http)
312+
.await
313+
.std_context("Failed to discover OIDC provider metadata")?;
314+
let oidc = CoreClient::from_provider_metadata(
315+
metadata,
316+
ClientId::new(provider.client_id),
317+
provider.client_secret.clone().map(ClientSecret::new),
318+
)
319+
.set_device_authorization_url(
320+
DeviceAuthorizationUrl::new(device_endpoint).std_context(
321+
"Invalid device_authorization_endpoint in OIDC discovery",
322+
)?,
323+
);
324+
325+
let details: CoreDeviceAuthorizationResponse = oidc
326+
.exchange_device_code()
327+
.add_scope(Scope::new("openid".to_string()))
328+
.add_scope(Scope::new("profile".to_string()))
329+
.add_scope(Scope::new("email".to_string()))
330+
.add_scope(Scope::new("offline_access".to_string()))
331+
.request_async(&self.http)
332+
.await
333+
.std_context("Failed to start device authorization")?;
334+
335+
let info = DeviceCodeInfo {
336+
verification_uri: details.verification_uri().to_string(),
337+
user_code: details.user_code().secret().to_string(),
338+
verification_uri_complete: details
339+
.verification_uri_complete()
340+
.map(|u| u.secret().to_string()),
341+
expires_in: Duration::from_secs(details.expires_in().as_secs()),
342+
interval: Duration::from_secs(details.interval().as_secs()),
343+
};
344+
display(info).await;
345+
346+
let tokens = oidc
347+
.exchange_device_access_token(&details)
348+
.std_context("Device-flow client misconfigured")?
349+
.request_async(&self.http, tokio::time::sleep, None)
350+
.await
351+
.std_context("Failed to exchange device access token")?;
352+
353+
// Device flow doesn't bind a nonce (no user-agent round-trip carrying
354+
// one), so accept an absent nonce in the ID token.
355+
let nonce_verifier =
356+
|_received: Option<&Nonce>| -> std::result::Result<(), String> { Ok(()) };
357+
let state = self.parse_token_response(tokens, nonce_verifier, None).await?;
358+
info!(
359+
email=%state.profile.email,
360+
expires_at=%state.tokens.expires_at(),
361+
"device-code login successful"
362+
);
363+
Ok(state)
364+
}
365+
244366
pub async fn refresh(
245367
&self,
246368
tokens: &AuthTokens,
@@ -808,6 +930,24 @@ impl AuthClient {
808930
Ok(())
809931
}
810932

933+
/// `--no-browser` analog of [`AuthClient::login`]. Skips the auth-code
934+
/// localhost-redirect flow (which doesn't work over SSH because the
935+
/// remote machine's bound port can't be reached by a browser running
936+
/// on the operator's laptop) and uses the OAuth2 device authorization
937+
/// grant instead. Always performs a fresh login — if a refresh-eligible
938+
/// token exists the caller can use [`AuthClient::login`] without
939+
/// `--no-browser` to use it.
940+
pub async fn login_device_code<F, Fut>(&self, display: F) -> Result<()>
941+
where
942+
F: FnOnce(DeviceCodeInfo) -> Fut,
943+
Fut: Future<Output = ()>,
944+
{
945+
let client = self.ensure_fresh_client().await?;
946+
let auth = client.login_device_code(display).await?;
947+
self.state.set(Some(auth)).await?;
948+
Ok(())
949+
}
950+
811951
pub async fn refresh(&self) -> Result<()> {
812952
let auth = self.state.load();
813953
let auth = auth.get()?;

0 commit comments

Comments
 (0)