diff --git a/crates/cli/src/commands/auth.rs b/crates/cli/src/commands/auth.rs index 08a513982..00e0b1a0b 100644 --- a/crates/cli/src/commands/auth.rs +++ b/crates/cli/src/commands/auth.rs @@ -3,6 +3,7 @@ //! `nemo-relay auth`: sign in to the provider named by `[upstream._oidc]`. +use std::path::Path; use std::process::ExitCode; use clap::{Args, Subcommand, ValueEnum}; @@ -10,7 +11,7 @@ use clap::{Args, Subcommand, ValueEnum}; use super::serve::ServerArgs; use crate::configuration::GatewayConfig; use crate::error::CliError; -use crate::oidc::OidcSource; +use crate::oidc::{BrowserApproval, LoginMethod, OidcSource}; #[derive(Debug, Clone, Args)] pub(crate) struct AuthCommand { @@ -20,8 +21,8 @@ pub(crate) struct AuthCommand { #[derive(Debug, Clone, Subcommand)] pub(crate) enum AuthSubcommand { - /// Sign in to the configured OpenID Connect provider with a code shown in the terminal. - Login(ProviderArgs), + /// Sign in to the configured OpenID Connect provider in a browser. + Login(LoginArgs), /// Print a current access token, refreshing it near expiry. For scripts; the gateway reads /// the sign-in itself. Token(ProviderArgs), @@ -35,6 +36,23 @@ pub(crate) enum ProviderArg { Anthropic, } +#[derive(Debug, Clone, Default, Args)] +pub(crate) struct LoginArgs { + #[command(flatten)] + pub(crate) provider: ProviderArgs, + /// Print the sign-in address instead of opening a browser. + #[arg(long, conflicts_with = "device")] + pub(crate) no_browser: bool, + /// Loopback port for the browser redirect, for example one forwarded over SSH. A free port + /// is picked by default. + #[arg(long, default_value_t = 0, conflicts_with = "device")] + pub(crate) callback_port: u16, + /// Sign in with a code entered on the issuer's own page (OAuth device authorization) instead + /// of the browser redirect. + #[arg(long)] + pub(crate) device: bool, +} + /// Without flags, the provider comes from `[upstream.openai_oidc]` or /// `[upstream.anthropic_oidc]` in the effective configuration, including `--config`. #[derive(Debug, Clone, Default, Args)] @@ -60,28 +78,47 @@ pub(super) async fn execute( let dir = crate::oidc::sessions_dir()?; let load = || configured(server); match command.command { - AuthSubcommand::Login(args) => { - let source = select_source(args, load)?; - crate::oidc::login(&dir, &source, |prompt| println!("{prompt}")).await?; - println!("Signed in to {}.", source.issuer); - } - AuthSubcommand::Token(args) => { - let source = select_source(args, load)?; - let token = crate::oidc::current_token(&dir, &source, &crate::oidc::now_secs).await?; - println!("{token}"); - } - AuthSubcommand::Logout(args) => { - let source = select_source(args, load)?; - if crate::oidc::logout(&dir, &source).await? { - println!("Removed the sign-in for {}.", source.issuer); - } else { - println!("No sign-in is stored for {}.", source.issuer); - } - } + AuthSubcommand::Login(args) => login(&dir, args, load).await?, + AuthSubcommand::Token(args) => print_token(&dir, select_source(args, load)?).await?, + AuthSubcommand::Logout(args) => logout(&dir, select_source(args, load)?).await?, } Ok(ExitCode::SUCCESS) } +async fn login( + dir: &Path, + args: LoginArgs, + load: impl FnOnce() -> Result, +) -> Result<(), CliError> { + let source = select_source(args.provider, load)?; + let method = if args.device { + LoginMethod::Device + } else { + LoginMethod::Browser(BrowserApproval::interactive( + !args.no_browser, + args.callback_port, + )) + }; + crate::oidc::login(dir, &source, method, |prompt| println!("{prompt}")).await?; + println!("Signed in to {}.", source.issuer); + Ok(()) +} + +async fn print_token(dir: &Path, source: OidcSource) -> Result<(), CliError> { + let token = crate::oidc::current_token(dir, &source, &crate::oidc::now_secs).await?; + println!("{token}"); + Ok(()) +} + +async fn logout(dir: &Path, source: OidcSource) -> Result<(), CliError> { + if crate::oidc::logout(dir, &source).await? { + println!("Removed the sign-in for {}.", source.issuer); + } else { + println!("No sign-in is stored for {}.", source.issuer); + } + Ok(()) +} + /// The effective configuration, including the user layer that `--config` replaces. fn configured(server: &ServerArgs) -> Result { crate::configuration::resolve_daemon_config(&server.to_runtime()) diff --git a/crates/cli/src/error.rs b/crates/cli/src/error.rs index 24fd3e45d..a5bea0f5f 100644 --- a/crates/cli/src/error.rs +++ b/crates/cli/src/error.rs @@ -112,6 +112,9 @@ pub(crate) enum CliError { /// The upstream needs an OpenID Connect sign-in that is missing or no longer valid. #[error("sign-in required: {0}")] SignInRequired(String), + /// A browser sign-in did not complete. + #[error(transparent)] + SignIn(#[from] crate::oidc::SignInError), /// The daemon definitively rejected the route credential; retrying cannot succeed. #[error("route credential rejected: {0}")] RouteCredentialRejected(String), @@ -181,6 +184,7 @@ impl CliError { Self::PayloadTooLarge(_) => "payload_too_large", Self::Unauthorized(_) => "unauthorized", Self::SignInRequired(_) => "sign_in_required", + Self::SignIn(_) => "sign_in", Self::RouteCredentialRejected(_) => "route_credential_rejected", Self::Upstream(_) | Self::UpstreamTransport(_) => "upstream", Self::ProviderFailure(_) => "provider_failure", diff --git a/crates/cli/src/oidc/browser.rs b/crates/cli/src/oidc/browser.rs new file mode 100644 index 000000000..e211f98d7 --- /dev/null +++ b/crates/cli/src/oidc/browser.rs @@ -0,0 +1,324 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! The browser sign-in: an authorization code with PKCE (RFC 7636), returned to a loopback +//! redirect (RFC 8252) or pasted back into the terminal when the browser runs on another device. + +use std::time::Duration; + +use axum::Router; +use axum::extract::{Query, State}; +use axum::response::Html; +use axum::routing::get; +use base64::Engine; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use ring::rand::{SecureRandom, SystemRandom}; +use sha2::{Digest, Sha256}; +use tokio::sync::{mpsc, oneshot}; + +use super::client::OidcClient; +use super::error::SignInError; +use super::session::Session; +use crate::error::CliError; + +/// How long the command waits for the user to approve the sign-in. +pub(crate) const APPROVAL_TIMEOUT: Duration = Duration::from_secs(300); +/// Bytes of randomness in the PKCE verifier and in the OAuth state. +const RANDOM_BYTES: usize = 32; +const CALLBACK_HOST: &str = "127.0.0.1"; +const CALLBACK_PATH: &str = "/callback"; + +const PAGE_RECEIVED: &str = "NeMo Relay

The sign-in response was received. Return to the terminal to see the result.

"; +const PAGE_NOT_COMPLETED: &str = "NeMo Relay

The sign-in was not completed. Return to the terminal.

"; +const PAGE_FOREIGN_ATTEMPT: &str = "NeMo Relay

This page belongs to another sign-in attempt. Use the link the terminal printed.

"; + +const PASTE_FOREIGN_ATTEMPT: &str = "That address belongs to another sign-in attempt. Paste the address of the tab this command opened."; +const PASTE_NOT_A_REDIRECT: &str = "That is not a sign-in redirect. Paste the whole http://127.0.0.1:/callback?code=...&state=... address from the browser."; + +/// Opens the authorization address; returns whether a browser started. +pub(crate) type Opener = dyn Fn(&str) -> bool + Send + Sync; + +/// How a browser sign-in reaches the user and comes back. +pub(crate) struct BrowserApproval<'a> { + /// Loopback port for the redirect; 0 picks a free one. + pub(crate) port: u16, + pub(crate) open: &'a Opener, + /// Redirect addresses pasted into the terminal. + pub(crate) pasted: mpsc::Receiver, + pub(crate) timeout: Duration, +} + +impl BrowserApproval<'static> { + /// The approval `nemo-relay auth login` uses: the system browser unless `open_browser` is + /// false, and redirect addresses typed into the terminal. + pub(crate) fn interactive(open_browser: bool, port: u16) -> Self { + Self { + port, + open: if open_browser { + &open_in_system_browser + } else { + &print_only + }, + pasted: terminal_lines(), + timeout: APPROVAL_TIMEOUT, + } + } +} + +/// Runs the browser sign-in and returns the issued tokens. +pub(crate) async fn sign_in( + client: &OidcClient, + scope: &str, + state: &str, + approval: BrowserApproval<'_>, + mut show: impl FnMut(String), +) -> Result { + let verifier = random_value()?; + let callback = Callback::listen(approval.port, state).await?; + let redirect_uri = callback.redirect_uri.clone(); + let url = client.authorization_url(scope, &redirect_uri, state, &code_challenge(&verifier))?; + announce(&url, (approval.open)(&url), &redirect_uri, &mut show); + let redirect = callback + .wait(approval.pasted, &mut show, approval.timeout) + .await?; + let code = redirect.into_code(client.metadata_issuer())?; + client.exchange_code(&code, &redirect_uri, &verifier).await +} + +fn announce(url: &str, opened: bool, redirect_uri: &str, show: &mut impl FnMut(String)) { + show(if opened { + format!("Opened the browser to sign in. If it did not open, visit:\n{url}") + } else { + format!("To sign in, open this address in a browser on any device:\n{url}") + }); + show(format!( + "If the browser runs on another device, its last page fails to load. Paste that page's address ({redirect_uri}?code=...) here and press Enter." + )); +} + +/// Unpadded base64url of fresh random bytes: the PKCE verifier and the OAuth state value. +pub(crate) fn random_value() -> Result { + let mut bytes = [0u8; RANDOM_BYTES]; + SystemRandom::new() + .fill(&mut bytes) + .map_err(|_| SignInError::NoRandomness)?; + Ok(URL_SAFE_NO_PAD.encode(bytes)) +} + +/// The S256 code challenge for a verifier. +pub(crate) fn code_challenge(verifier: &str) -> String { + URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes())) +} + +/// The query of a redirect back from the issuer. +#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Deserialize)] +pub(crate) struct Redirect { + pub(crate) code: Option, + pub(crate) state: Option, + pub(crate) iss: Option, + pub(crate) error: Option, + pub(crate) error_description: Option, +} + +impl Redirect { + /// The authorization code, once the redirect is known to come from `issuer` and approve. + /// RFC 9207 compares `iss` with the metadata issuer as an exact string. + fn into_code(self, issuer: &str) -> Result { + if self.iss.is_some_and(|iss| iss != issuer) { + return Err(SignInError::ForeignIssuer); + } + if let Some(error) = &self.error { + return Err(SignInError::from_redirect_error( + error, + self.error_description.as_deref(), + )); + } + self.code.ok_or(SignInError::MissingCode) + } +} + +/// A line typed into the terminal while the sign-in waits. +#[derive(Debug, PartialEq, Eq)] +pub(crate) enum Paste { + Blank, + NotARedirect, + ForeignAttempt, + Accepted(Redirect), +} + +/// Reads a pasted redirect address, with or without angle brackets around it. +pub(crate) fn read_paste(line: &str, expected_state: &str) -> Paste { + let text = line.trim().trim_start_matches('<').trim_end_matches('>'); + if text.is_empty() { + return Paste::Blank; + } + let Some(redirect) = text + .parse::() + .ok() + .and_then(|uri| Query::::try_from_uri(&uri).ok()) + .map(|Query(redirect)| redirect) + .filter(|redirect| redirect.code.is_some() || redirect.error.is_some()) + else { + return Paste::NotARedirect; + }; + if redirect.state.as_deref() == Some(expected_state) { + Paste::Accepted(redirect) + } else { + Paste::ForeignAttempt + } +} + +/// The loopback listener the issuer redirects to. +pub(crate) struct Callback { + pub(crate) redirect_uri: String, + expected_state: String, + received: mpsc::Receiver, + stop: oneshot::Sender<()>, +} + +#[derive(Clone)] +struct CallbackState { + expected_state: String, + sender: mpsc::Sender, +} + +impl Callback { + /// Listens on `127.0.0.1:`; port 0 picks a free one. + pub(crate) async fn listen(port: u16, expected_state: &str) -> Result { + let listener = tokio::net::TcpListener::bind((CALLBACK_HOST, port)) + .await + .map_err(|source| SignInError::CallbackUnavailable { port, source })?; + let bound_port = listener + .local_addr() + .map_err(|source| SignInError::CallbackUnavailable { port, source })? + .port(); + let (sender, received) = mpsc::channel(4); + let (stop, stopped) = oneshot::channel::<()>(); + let state = CallbackState { + expected_state: expected_state.to_owned(), + sender, + }; + let app = Router::new() + .route(CALLBACK_PATH, get(receive)) + .with_state(state); + tokio::spawn(serve_until_stopped(listener, app, stopped)); + Ok(Self { + redirect_uri: format!("http://{CALLBACK_HOST}:{bound_port}{CALLBACK_PATH}"), + expected_state: expected_state.to_owned(), + received, + stop, + }) + } + + /// Waits for the redirect of this attempt, from the browser or pasted, then stops listening. + pub(crate) async fn wait( + mut self, + pasted: mpsc::Receiver, + show: impl FnMut(String), + timeout: Duration, + ) -> Result { + let first = tokio::time::timeout(timeout, self.first_redirect(pasted, show)).await; + let _ = self.stop.send(()); + first.map_err(|_| SignInError::TimedOut) + } + + async fn first_redirect( + &mut self, + mut pasted: mpsc::Receiver, + mut show: impl FnMut(String), + ) -> Redirect { + let mut paste_open = true; + loop { + tokio::select! { + Some(redirect) = self.received.recv() => return redirect, + line = pasted.recv(), if paste_open => match line.map(|line| read_paste(&line, &self.expected_state)) { + None => paste_open = false, + Some(Paste::Accepted(redirect)) => return redirect, + Some(Paste::Blank) => {} + Some(Paste::ForeignAttempt) => show(PASTE_FOREIGN_ATTEMPT.into()), + Some(Paste::NotARedirect) => show(PASTE_NOT_A_REDIRECT.into()), + }, + else => std::future::pending::<()>().await, + } + } + } +} + +async fn serve_until_stopped( + listener: tokio::net::TcpListener, + app: Router, + stopped: oneshot::Receiver<()>, +) { + let shutdown = async { + let _ = stopped.await; + }; + let _ = axum::serve(listener, app) + .with_graceful_shutdown(shutdown) + .await; +} + +/// Answers the browser; only a redirect with this attempt's state reaches the command. +async fn receive( + State(state): State, + Query(redirect): Query, +) -> Html<&'static str> { + if redirect.state.as_deref() != Some(state.expected_state.as_str()) { + return Html(PAGE_FOREIGN_ATTEMPT); + } + let page = if redirect.code.is_some() { + PAGE_RECEIVED + } else { + PAGE_NOT_COMPLETED + }; + let _ = state.sender.send(redirect).await; + Html(page) +} + +/// Lines typed into the terminal, read on a thread of their own so a blocked read never holds +/// the runtime open. The channel closes at end of input. +fn terminal_lines() -> mpsc::Receiver { + let (sender, receiver) = mpsc::channel(4); + std::thread::spawn(move || { + for line in std::io::stdin().lines() { + let Ok(line) = line else { break }; + if sender.blocking_send(line).is_err() { + break; + } + } + }); + receiver +} + +/// Opens the address in the default browser; returns whether one started. +fn open_in_system_browser(url: &str) -> bool { + let Some(mut command) = system_browser_command() else { + return false; + }; + command + .arg(url) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn() + .is_ok() +} + +/// The platform's command that opens an address. On Linux, only with a graphical display. +fn system_browser_command() -> Option { + if cfg!(target_os = "macos") { + return Some(std::process::Command::new("open")); + } + if cfg!(windows) { + let mut command = std::process::Command::new("rundll32"); + command.arg("url.dll,FileProtocolHandler"); + return Some(command); + } + let has_display = + std::env::var_os("DISPLAY").is_some() || std::env::var_os("WAYLAND_DISPLAY").is_some(); + has_display.then(|| std::process::Command::new("xdg-open")) +} + +/// Prints the address only, for `--no-browser`. +fn print_only(_url: &str) -> bool { + false +} diff --git a/crates/cli/src/oidc/client.rs b/crates/cli/src/oidc/client.rs index daa96e13c..7251f593a 100644 --- a/crates/cli/src/oidc/client.rs +++ b/crates/cli/src/oidc/client.rs @@ -1,7 +1,8 @@ // SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -//! Requests to the OpenID Connect issuer: discovery, the device sign-in and token refresh. +//! Requests to the OpenID Connect issuer: discovery, the device sign-in, the authorization code +//! exchange and token refresh. use std::time::Duration; @@ -9,6 +10,7 @@ use http::header::{ACCEPT, CONTENT_TYPE}; use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode}; use serde::Deserialize; +use super::error::SignInError; use super::session::Session; use super::{Clock, HTTP_TIMEOUT, OidcSource, auth_error, login_required, now_secs}; use crate::error::CliError; @@ -22,9 +24,13 @@ const SLOW_DOWN_SECS: u64 = 5; struct Discovery { issuer: String, token_endpoint: String, + authorization_endpoint: Option, device_authorization_endpoint: Option, } +const AUTHORIZATION_CODE_GRANT: &str = "authorization_code"; +const PKCE_METHOD: &str = "S256"; + #[derive(Deserialize)] pub(crate) struct DeviceStart { pub(crate) device_code: String, @@ -65,8 +71,10 @@ enum Grant { pub(crate) struct OidcClient { http: reqwest::Client, issuer: String, + metadata_issuer: String, client_id: String, pub(super) token_endpoint: String, + authorization_endpoint: Option, device_endpoint: Option, } @@ -93,7 +101,8 @@ impl OidcClient { } // Endpoints on another origin could send the refresh token to an unintended host. let endpoints = std::iter::once(&discovery.token_endpoint) - .chain(discovery.device_authorization_endpoint.as_ref()); + .chain(discovery.device_authorization_endpoint.as_ref()) + .chain(discovery.authorization_endpoint.as_ref()); for endpoint in endpoints { if secure_origin(endpoint)? != origin { return Err(auth_error(format!( @@ -104,12 +113,74 @@ impl OidcClient { Ok(Self { http, issuer: issuer.to_owned(), + metadata_issuer: discovery.issuer, client_id: source.client_id.clone(), token_endpoint: discovery.token_endpoint, + authorization_endpoint: discovery.authorization_endpoint, device_endpoint: discovery.device_authorization_endpoint, }) } + /// The issuer exactly as discovery published it, which RFC 9207 compares `iss` with. + pub(crate) fn metadata_issuer(&self) -> &str { + &self.metadata_issuer + } + + /// The address the user opens to approve the sign-in. + pub(crate) fn authorization_url( + &self, + scope: &str, + redirect_uri: &str, + state: &str, + challenge: &str, + ) -> Result { + let endpoint = self.authorization_endpoint.as_deref().ok_or_else(|| { + SignInError::BrowserSignInNotOffered { + issuer: self.issuer.clone(), + } + })?; + let mut url = reqwest::Url::parse(endpoint).map_err(|_| SignInError::InvalidEndpoint { + endpoint: endpoint.to_owned(), + })?; + url.query_pairs_mut() + .append_pair("response_type", "code") + .append_pair("client_id", &self.client_id) + .append_pair("redirect_uri", redirect_uri) + .append_pair("scope", scope) + .append_pair("state", state) + .append_pair("code_challenge", challenge) + .append_pair("code_challenge_method", PKCE_METHOD); + Ok(url.into()) + } + + /// Exchanges an approved authorization code and its PKCE verifier for tokens. + pub(crate) async fn exchange_code( + &self, + code: &str, + redirect_uri: &str, + verifier: &str, + ) -> Result { + let grant = self + .token_request(&[ + ("grant_type", AUTHORIZATION_CODE_GRANT), + ("code", code), + ("redirect_uri", redirect_uri), + ("client_id", &self.client_id), + ("code_verifier", verifier), + ]) + .await; + match grant { + Grant::Issued(reply) => self.session(reply, None, now_secs()), + Grant::Refused { detail, .. } | Grant::Unavailable(detail) => { + Err(SignInError::TokenNotIssued { + issuer: self.issuer.clone(), + detail, + } + .into()) + } + } + } + pub(crate) async fn device_login( &self, scope: &str, diff --git a/crates/cli/src/oidc/error.rs b/crates/cli/src/oidc/error.rs new file mode 100644 index 000000000..e643ffb5f --- /dev/null +++ b/crates/cli/src/oidc/error.rs @@ -0,0 +1,48 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Why a browser sign-in did not complete. No variant holds a token. + +use super::browser::APPROVAL_TIMEOUT; + +#[derive(Debug, thiserror::Error)] +pub(crate) enum SignInError { + #[error("the system random number generator failed")] + NoRandomness, + #[error("cannot listen on 127.0.0.1:{port} for the sign-in redirect: {source}")] + CallbackUnavailable { port: u16, source: std::io::Error }, + #[error("{issuer} does not offer browser sign-in (no authorization_endpoint); use --device")] + BrowserSignInNotOffered { issuer: String }, + #[error("{endpoint} is not a valid URL")] + InvalidEndpoint { endpoint: String }, + #[error( + "timed out after {} minutes waiting for the sign-in; run the login again", + APPROVAL_TIMEOUT.as_secs() / 60 + )] + TimedOut, + #[error("the sign-in redirect names another issuer")] + ForeignIssuer, + #[error("the sign-in redirect carries no code")] + MissingCode, + #[error("the sign-in was declined")] + Declined, + #[error("the sign-in was not completed: {detail}")] + NotCompleted { detail: String }, + #[error("{issuer} did not issue a token for the approved sign-in: {detail}")] + TokenNotIssued { issuer: String, detail: String }, +} + +impl SignInError { + /// An OAuth error code from the redirect (RFC 6749 section 4.1.2.1). + pub(crate) fn from_redirect_error(code: &str, description: Option<&str>) -> Self { + match (code, description.filter(|text| !text.trim().is_empty())) { + ("access_denied", _) => Self::Declined, + (code, Some(text)) => Self::NotCompleted { + detail: format!("{code} ({text})"), + }, + (code, None) => Self::NotCompleted { + detail: code.to_owned(), + }, + } + } +} diff --git a/crates/cli/src/oidc/mod.rs b/crates/cli/src/oidc/mod.rs index 35ccc7f61..ab66aaf42 100644 --- a/crates/cli/src/oidc/mod.rs +++ b/crates/cli/src/oidc/mod.rs @@ -4,13 +4,16 @@ //! OpenID Connect sign-in for upstream providers. //! //! `[upstream.openai_oidc]` and `[upstream.anthropic_oidc]` name an issuer and a client. A user -//! signs in once with the OAuth 2.0 device authorization grant (RFC 8628); the sign-in is kept in +//! signs in once in the browser with an authorization code and PKCE (RFC 7636, RFC 8252), or with +//! the device authorization grant (RFC 8628) on request; the sign-in is kept in //! the per-user NeMo Relay config directory with owner-only access. The gateway reads it for each //! upstream request and refreshes the access token near expiry, so the token's real expiry and a //! `logout` apply to the next request. Tokens are written only to that file and to the standard //! output of `nemo-relay auth token`; they are never logged or included in an error. +mod browser; mod client; +mod error; mod session; use std::path::Path; @@ -24,6 +27,8 @@ use self::session::{ }; use crate::error::CliError; +pub(crate) use self::browser::BrowserApproval; +pub(crate) use self::error::SignInError; pub(crate) use self::session::sessions_dir; /// Refresh an access token this many seconds before it expires. @@ -162,14 +167,29 @@ pub(crate) async fn current_token( } } +/// How `login` asks the user to approve the sign-in. +pub(crate) enum LoginMethod<'a> { + /// Authorization code with PKCE, returned to a loopback redirect or pasted back. + Browser(BrowserApproval<'a>), + /// The device authorization grant: a code entered on the issuer's own page. + Device, +} + /// Signs in and stores the session. pub(crate) async fn login( dir: &Path, source: &OidcSource, + method: LoginMethod<'_>, show: impl FnMut(String), ) -> Result<(), CliError> { let client = OidcClient::discover(source).await?; - let session = client.device_login(&source.scope, show).await?; + let session = match method { + LoginMethod::Browser(approval) => { + let state = browser::random_value()?; + browser::sign_in(&client, &source.scope, &state, approval, show).await? + } + LoginMethod::Device => client.device_login(&source.scope, show).await?, + }; ensure_sessions_dir(dir)?; let path = session_path(dir, source); let _lock = SessionLock::acquire(&path).await?; diff --git a/crates/cli/tests/coverage/shared/oidc_tests.rs b/crates/cli/tests/coverage/shared/oidc_tests.rs index bed327438..528c80f7b 100644 --- a/crates/cli/tests/coverage/shared/oidc_tests.rs +++ b/crates/cli/tests/coverage/shared/oidc_tests.rs @@ -28,6 +28,8 @@ struct Issuer { base: String, advertised_issuer: Option, token_endpoint: Option, + authorization_endpoint: Option, + omit_authorization_endpoint: bool, /// Replies to device-code polls, in order; the last one repeats. device_replies: VecDeque<&'static str>, refresh_status: Option, @@ -36,6 +38,8 @@ struct Issuer { /// A refresh moves this clock forward by the given seconds, like a slow issuer. refresh_takes: Option<(Arc, u64)>, grants: Vec>, + /// The `code_challenge` of the last authorization address the browser opened. + challenge: Option, } type Shared = Arc>; @@ -57,11 +61,19 @@ fn parse_form(body: &str) -> HashMap { async fn discovery(State(issuer): State) -> Json { let state = issuer.lock().unwrap(); let base = state.base.clone(); - Json(json!({ + let mut document = json!({ "issuer": state.advertised_issuer.clone().unwrap_or_else(|| base.clone()), "token_endpoint": state.token_endpoint.clone().unwrap_or_else(|| format!("{base}/token")), + "authorization_endpoint": state.authorization_endpoint.clone().unwrap_or_else(|| format!("{base}/authorize")), "device_authorization_endpoint": format!("{base}/device"), - })) + }); + if state.omit_authorization_endpoint { + document + .as_object_mut() + .unwrap() + .remove("authorization_endpoint"); + } + Json(document) } async fn device(body: String) -> Response { @@ -118,6 +130,14 @@ async fn token(State(issuer): State, body: String) -> Response { error => refused(error), } } + "authorization_code" => { + let challenge = super::browser::code_challenge(&form["code_verifier"]); + if form["code"] == "code-1" && state.challenge.as_deref() == Some(challenge.as_str()) { + issued("access-1", "refresh-1") + } else { + refused("invalid_grant") + } + } "refresh_token" => { if let Some((clock, seconds)) = &state.refresh_takes { clock.fetch_add(*seconds, Ordering::SeqCst); @@ -460,6 +480,15 @@ async fn discovery_rejects_a_token_endpoint_on_another_origin() { assert!(error.contains("another origin"), "{error}"); } +#[tokio::test] +async fn discovery_rejects_an_authorization_endpoint_on_another_origin() { + let (source, issuer) = start_issuer().await; + issuer.lock().unwrap().authorization_endpoint = + Some("https://login.elsewhere.example/authorize".into()); + let error = error_text(OidcClient::discover(&source).await); + assert!(error.contains("another origin"), "{error}"); +} + #[test] fn issuer_urls_must_use_https_unless_they_are_loopback() { assert!(secure_origin("https://clerk.openrouter.ai").is_ok()); @@ -616,3 +645,280 @@ async fn a_sign_in_for_one_scope_is_never_used_for_another() { assert!(error.contains("nemo-relay auth login"), "{error}"); assert_eq!(saved(dir.path(), &source).access_token, "access-1"); } + +/// The query of the authorization address, by name. +fn query(url: &str) -> HashMap { + reqwest::Url::parse(url) + .unwrap() + .query_pairs() + .map(|(key, value)| (key.into_owned(), value.into_owned())) + .collect() +} + +/// A browser that approves: it records the challenge, then follows the redirect with `code` +/// and the given state (`None` keeps the attempt's own). +fn approving_browser( + issuer: Shared, + opened: Arc>>, + states: Vec>, +) -> impl Fn(&str) -> bool + Send + Sync { + move |url: &str| { + let params = query(url); + issuer.lock().unwrap().challenge = Some(params["code_challenge"].clone()); + opened.lock().unwrap().push(url.to_owned()); + let states = states.clone(); + tokio::spawn(async move { + for state in states { + let state = state.map_or_else(|| params["state"].clone(), str::to_owned); + let mut redirect = reqwest::Url::parse(¶ms["redirect_uri"]).unwrap(); + redirect + .query_pairs_mut() + .append_pair("code", "code-1") + .append_pair("state", &state); + reqwest::get(redirect).await.unwrap(); + } + }); + true + } +} + +fn approval<'a>( + open: &'a super::browser::Opener, + pasted: tokio::sync::mpsc::Receiver, +) -> BrowserApproval<'a> { + BrowserApproval { + port: 0, + open, + pasted, + timeout: Duration::from_secs(10), + } +} + +fn no_paste() -> tokio::sync::mpsc::Receiver { + tokio::sync::mpsc::channel(1).1 +} + +#[tokio::test] +async fn browser_login_exchanges_the_loopback_code_with_the_pkce_verifier() { + let (source, issuer) = start_issuer().await; + let dir = tempfile::tempdir().unwrap(); + let opened = Arc::default(); + let browser = approving_browser(issuer.clone(), Arc::clone(&opened), vec![None]); + + login( + dir.path(), + &source, + LoginMethod::Browser(approval(&browser, no_paste())), + |_| {}, + ) + .await + .unwrap(); + + assert_eq!(saved(dir.path(), &source).access_token, "access-1"); + let url = opened.lock().unwrap()[0].clone(); + let params = query(&url); + assert!( + url.starts_with(&format!("{}/authorize?", source.issuer)), + "{url}" + ); + assert_eq!(params["response_type"], "code"); + assert_eq!(params["client_id"], CLIENT_ID); + assert_eq!(params["scope"], "openid offline_access"); + assert_eq!(params["code_challenge_method"], "S256"); + assert!(params["redirect_uri"].starts_with("http://127.0.0.1:")); + assert!(params["redirect_uri"].ends_with("/callback")); + assert_eq!(params["state"].len(), 43); + let grant = issuer.lock().unwrap().grants[0].clone(); + assert_eq!(grant["grant_type"], "authorization_code"); + assert_eq!(grant["redirect_uri"], params["redirect_uri"]); +} + +#[tokio::test] +async fn a_redirect_for_another_attempt_does_not_end_the_sign_in() { + let (source, issuer) = start_issuer().await; + let opened = Arc::default(); + let browser = approving_browser(issuer.clone(), opened, vec![Some("forged"), None]); + let client = OidcClient::discover(&source).await.unwrap(); + + let session = super::browser::sign_in( + &client, + "openid", + "attempt-state", + approval(&browser, no_paste()), + |_| {}, + ) + .await + .unwrap(); + + assert_eq!(session.access_token, "access-1"); + assert_eq!(issuer.lock().unwrap().grants.len(), 1); +} + +#[tokio::test] +async fn a_pasted_redirect_completes_a_sign_in_without_a_local_browser() { + let (source, issuer) = start_issuer().await; + let client = OidcClient::discover(&source).await.unwrap(); + let captured = issuer.clone(); + let printed = move |url: &str| { + captured.lock().unwrap().challenge = Some(query(url)["code_challenge"].clone()); + false + }; + let (paste, pasted) = tokio::sync::mpsc::channel(8); + for line in [ + "not a url\n".to_owned(), + "http://127.0.0.1:1/callback?code=code-1&state=other-attempt\n".to_owned(), + "\n".to_owned(), + ] { + paste.send(line).await.unwrap(); + } + let mut shown = Vec::new(); + + let session = super::browser::sign_in( + &client, + "openid", + "attempt-state", + approval(&printed, pasted), + |line| shown.push(line), + ) + .await + .unwrap(); + + assert_eq!(session.refresh_token.as_deref(), Some("refresh-1")); + let shown = shown.join("\n"); + assert!(shown.contains("open this address in a browser"), "{shown}"); + assert!(shown.contains("not a sign-in redirect"), "{shown}"); + assert!(shown.contains("another sign-in attempt"), "{shown}"); +} + +#[tokio::test] +async fn a_declined_browser_sign_in_reports_it() { + let (source, _issuer) = start_issuer().await; + let client = OidcClient::discover(&source).await.unwrap(); + let printed = |_: &str| false; + let (paste, pasted) = tokio::sync::mpsc::channel(1); + paste + .send("http://127.0.0.1:1/callback?error=access_denied&state=attempt-state".into()) + .await + .unwrap(); + + let error = error_text( + super::browser::sign_in( + &client, + "openid", + "attempt-state", + approval(&printed, pasted), + |_| {}, + ) + .await, + ); + + assert!(error.contains("declined"), "{error}"); +} + +#[tokio::test] +async fn an_unanswered_browser_sign_in_times_out() { + let (source, _issuer) = start_issuer().await; + let client = OidcClient::discover(&source).await.unwrap(); + let printed = |_: &str| false; + let mut waiting = approval(&printed, no_paste()); + waiting.timeout = Duration::from_millis(50); + + let error = error_text(super::browser::sign_in(&client, "openid", "s", waiting, |_| {}).await); + + assert!(error.contains("timed out"), "{error}"); +} +async fn pasted_sign_in_error(client: &OidcClient, redirect: &str) -> String { + let printed = |_: &str| false; + let (paste, pasted) = tokio::sync::mpsc::channel(1); + paste.send(redirect.to_owned()).await.unwrap(); + error_text( + super::browser::sign_in( + client, + "openid", + "attempt-state", + approval(&printed, pasted), + |_| {}, + ) + .await, + ) +} +#[tokio::test] +async fn a_redirect_that_names_another_issuer_is_refused() { + let (source, issuer) = start_issuer().await; + let client = OidcClient::discover(&source).await.unwrap(); + let error = pasted_sign_in_error( + &client, + "http://127.0.0.1:1/callback?code=code-1&state=attempt-state&iss=https%3A%2F%2Fother.example", + ) + .await; + assert!(error.contains("names another issuer"), "{error}"); + assert!(issuer.lock().unwrap().grants.is_empty()); +} +#[tokio::test] +async fn browser_sign_in_without_an_authorization_endpoint_points_to_device() { + let (source, issuer) = start_issuer().await; + issuer.lock().unwrap().omit_authorization_endpoint = true; + let client = OidcClient::discover(&source).await.unwrap(); + let error = pasted_sign_in_error( + &client, + "http://127.0.0.1:1/callback?code=code-1&state=attempt-state", + ) + .await; + assert!(error.contains("does not offer browser sign-in"), "{error}"); + assert!(error.contains("--device"), "{error}"); + assert!(issuer.lock().unwrap().grants.is_empty()); +} +#[tokio::test] +async fn a_refused_code_exchange_reports_that_no_token_was_issued() { + let (source, issuer) = start_issuer().await; + let client = OidcClient::discover(&source).await.unwrap(); + let error = pasted_sign_in_error( + &client, + "http://127.0.0.1:1/callback?code=unknown-code&state=attempt-state", + ) + .await; + assert!(error.contains("did not issue a token"), "{error}"); + assert!(error.contains("invalid_grant"), "{error}"); + assert_eq!(issuer.lock().unwrap().grants.len(), 1); +} +#[tokio::test] +async fn a_redirect_iss_must_match_the_metadata_issuer_exactly() { + let (source, issuer) = start_issuer().await; + let client = OidcClient::discover(&source).await.unwrap(); + let slashed = reqwest::Url::parse_with_params( + "http://127.0.0.1:1/callback", + &[ + ("code", "code-1"), + ("state", "attempt-state"), + ("iss", &format!("{}/", source.issuer)), + ], + ) + .unwrap(); + let error = pasted_sign_in_error(&client, slashed.as_str()).await; + assert!(error.contains("names another issuer"), "{error}"); + assert!(issuer.lock().unwrap().grants.is_empty()); +} +#[tokio::test] +async fn a_redirect_iss_is_compared_with_the_published_issuer() { + let (source, issuer) = start_issuer().await; + let published = format!("{}/", source.issuer); + issuer.lock().unwrap().advertised_issuer = Some(published.clone()); + let client = OidcClient::discover(&source).await.unwrap(); + let redirect_naming = |iss: &str| { + reqwest::Url::parse_with_params( + "http://127.0.0.1:1/callback", + &[ + ("code", "unknown-code"), + ("state", "attempt-state"), + ("iss", iss), + ], + ) + .unwrap() + }; + let error = pasted_sign_in_error(&client, redirect_naming(&published).as_str()).await; + assert!(error.contains("did not issue a token"), "{error}"); + assert_eq!(issuer.lock().unwrap().grants.len(), 1); + let error = pasted_sign_in_error(&client, redirect_naming(&source.issuer).as_str()).await; + assert!(error.contains("names another issuer"), "{error}"); + assert_eq!(issuer.lock().unwrap().grants.len(), 1); +} diff --git a/docs/nemo-relay-cli/authentication.mdx b/docs/nemo-relay-cli/authentication.mdx index 0d2a6ae96..ccd32a29e 100644 --- a/docs/nemo-relay-cli/authentication.mdx +++ b/docs/nemo-relay-cli/authentication.mdx @@ -236,10 +236,26 @@ nemo-relay auth login The command reads the provider from the effective configuration, including a file passed with `--config`. When both providers have an `oidc` section with -different values, add `--provider openai` or `--provider anthropic`. The command -uses the OAuth 2.0 device authorization grant. It prints a URL and a code. Open -the URL on any device, enter the code, and approve the sign-in. The command does -not need a local browser, so it also works over SSH. +different values, add `--provider openai` or `--provider anthropic`. + +The command signs in through the browser with an OAuth 2.0 authorization code +and PKCE. It listens on `http://127.0.0.1:/callback`, opens the issuer's +sign-in page in the default browser, and waits up to five minutes for the +issuer to redirect back. The OAuth client must allow that loopback redirect on +any port. The `state` of each attempt is random, and the command accepts only a +redirect that carries it. + +Without a local browser, for example over SSH, the command prints the address. +Open it in a browser on any device and approve the sign-in. The browser's last +page, `http://127.0.0.1:/callback?code=...`, then fails to load, because +the redirect points at the device the browser runs on. Copy that page's address, +paste it into the terminal, and press Enter. Add `--no-browser` to print the +address without opening a browser, and `--callback-port ` to listen on a +fixed port, for example one you forward over SSH. + +`--device` uses the OAuth 2.0 device authorization grant instead: the command +prints a URL and a code to enter on the issuer's own page. It needs no redirect. +The issuer must offer the device grant to the client. `nemo-relay auth login` stores the sign-in with owner-only access in `$XDG_CONFIG_HOME/nemo-relay/oidc/` when `XDG_CONFIG_HOME` is an absolute path, @@ -265,9 +281,9 @@ each configured sign-in exists, is valid, has expired but can refresh, or needs a new sign-in. `nemo-relay auth token` prints the current access token for scripts. -The issuer must use `https`, or `http` on a loopback address. Its token and -device endpoints must have the same origin as the issuer. Relay never writes -tokens to logs or error messages. +The issuer must use `https`, or `http` on a loopback address. Its +authorization, token and device endpoints must have the same origin as the +issuer. Relay never writes tokens to logs or error messages. `nemo-relay auth token` prints an access token. Treat its output as a credential