Skip to content

Commit 24958c5

Browse files
authored
Serve console index at /settings/*, /device/verify, /device/success (#1324)
* serve console index at /settings/* * use full helper for /device/verify too * also do /device/success * first pass at always including current URI in login_url * improve code slightly, add big comment
1 parent 15ecdef commit 24958c5

File tree

4 files changed

+74
-57
lines changed

4 files changed

+74
-57
lines changed

nexus/src/external_api/console_api.rs

Lines changed: 56 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -496,27 +496,33 @@ pub struct LoginUrlQuery {
496496
/// Generate URL to IdP login form. Optional `state` param is included in query
497497
/// string if present, and will typically represent the URL to send the user
498498
/// back to after successful login.
499-
pub fn get_login_url(state: Option<String>) -> String {
500-
// assume state is not URL encoded, so no risk of double encoding (dropshot
501-
// decodes it on the way in)
499+
// TODO this does not know anything about IdPs, and it should. When the user is
500+
// logged out and hits an auth-gated route, if there are multiple IdPs and we
501+
// don't known which one they want to use, we need to send them to a page that
502+
// will allow them to choose among discoverable IdPs. However, there may be ways
503+
// to give ourselves a hint about which one they want, for example, by storing
504+
// that info in a browser cookie when they log in. When their session ends, we
505+
// will not be able to look at the dead session to find the silo or IdP (well,
506+
// maybe we can but we probably shouldn't) but we can look at the cookie and
507+
// default to sending them to the IdP indicated (though if they don't want that
508+
// one we need to make sure they can get to a different one). If there is no
509+
// cookie, we send them to the selector page. In any case, none of this is done
510+
// here yet. We go to /spoof_login no matter what.
511+
fn get_login_url(state: Option<String>) -> String {
512+
// assume state is not already URL encoded
502513
let query = match state {
503-
Some(state) if state.is_empty() => None,
504-
Some(state) => Some(
514+
Some(state) if !state.is_empty() => {
505515
serde_urlencoded::to_string(LoginUrlQuery { state: Some(state) })
506-
// unwrap is safe because query.state was just deserialized out
507-
// of a query param, so we know it's serializable
508-
.unwrap(),
509-
),
510-
None => None,
516+
.ok() // in the strange event it's not serializable, no query
517+
}
518+
_ => None,
511519
};
512520
// Once we have IdP integration, this will be a URL for the IdP login page.
513521
// For now we point to our own placeholder login page.
514-
let mut url = "/spoof_login".to_string();
515-
if let Some(query) = query {
516-
url.push('?');
517-
url.push_str(query.as_str());
522+
match query {
523+
Some(query) => format!("/spoof_login?{query}"),
524+
None => "/spoof_login".to_string(),
518525
}
519-
url
520526
}
521527

522528
/// Redirect to IdP login URL
@@ -558,6 +564,29 @@ pub async fn session_me(
558564
apictx.external_latencies.instrument_dropshot_handler(&rqctx, handler).await
559565
}
560566

567+
pub async fn console_index_or_login_redirect(
568+
rqctx: Arc<RequestContext<Arc<ServerContext>>>,
569+
) -> Result<Response<Body>, HttpError> {
570+
let opctx = OpContext::for_external_api(&rqctx).await;
571+
572+
// if authed, serve console index.html with JS bundle in script tag
573+
if let Ok(opctx) = opctx {
574+
if opctx.authn.actor().is_some() {
575+
return serve_console_index(rqctx.context()).await;
576+
}
577+
}
578+
579+
// otherwise redirect to idp
580+
581+
// put the current URI in the query string to redirect back to after login
582+
let uri = rqctx.request.lock().await.uri().to_string();
583+
584+
Ok(Response::builder()
585+
.status(StatusCode::FOUND)
586+
.header(http::header::LOCATION, get_login_url(Some(uri)))
587+
.body("".into())?)
588+
}
589+
561590
// Dropshot does not have route match ranking and does not allow overlapping
562591
// route definitions, so we cannot have a catchall `/*` route for console pages
563592
// and then also define, e.g., `/api/blah/blah` and give the latter priority
@@ -575,28 +604,19 @@ pub async fn console_page(
575604
rqctx: Arc<RequestContext<Arc<ServerContext>>>,
576605
_path_params: Path<RestPathParam>,
577606
) -> Result<Response<Body>, HttpError> {
578-
let opctx = OpContext::for_external_api(&rqctx).await;
579-
580-
// if authed, serve HTML page with bundle in script tag
581-
582-
// HTML doesn't need to be static -- we'll probably find a reason to do some
583-
// minimal templating, e.g., putting a CSRF token in the page
584-
585-
// amusingly, at least to start out, I don't think we care about the path
586-
// because the real routing is all client-side. we serve the same HTML
587-
// regardless, the app starts on the client and renders the right page and
588-
// makes the right API requests.
589-
if let Ok(opctx) = opctx {
590-
if opctx.authn.actor().is_some() {
591-
return serve_console_index(rqctx.context()).await;
592-
}
593-
}
607+
console_index_or_login_redirect(rqctx).await
608+
}
594609

595-
// otherwise redirect to idp
596-
Ok(Response::builder()
597-
.status(StatusCode::FOUND)
598-
.header(http::header::LOCATION, get_login_url(None))
599-
.body("".into())?)
610+
#[endpoint {
611+
method = GET,
612+
path = "/settings/{path:.*}",
613+
unpublished = true,
614+
}]
615+
pub async fn console_settings_page(
616+
rqctx: Arc<RequestContext<Arc<ServerContext>>>,
617+
_path_params: Path<RestPathParam>,
618+
) -> Result<Response<Body>, HttpError> {
619+
console_index_or_login_redirect(rqctx).await
600620
}
601621

602622
/// Fetch a static asset from `<static_dir>/assets`. 404 on virtually all

nexus/src/external_api/device_auth.rs

Lines changed: 13 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
//! are for requesting access tokens that will be managed and used by
1010
//! the client to make other API requests.
1111
12-
use super::console_api::{get_login_url, serve_console_index};
12+
use super::console_api::console_index_or_login_redirect;
1313
use super::views::{DeviceAccessTokenGrant, DeviceAuthResponse};
1414
use crate::context::OpContext;
1515
use crate::db::model::DeviceAccessToken;
@@ -21,7 +21,6 @@ use http::{header, Response, StatusCode};
2121
use hyper::Body;
2222
use schemars::JsonSchema;
2323
use serde::{Deserialize, Serialize};
24-
use serde_urlencoded;
2524
use std::sync::Arc;
2625
use uuid::Uuid;
2726

@@ -126,26 +125,20 @@ pub struct DeviceAuthVerify {
126125
}]
127126
pub async fn device_auth_verify(
128127
rqctx: Arc<RequestContext<Arc<ServerContext>>>,
129-
params: Query<DeviceAuthVerify>,
128+
_params: Query<DeviceAuthVerify>,
130129
) -> Result<Response<Body>, HttpError> {
131-
// If the user is authenticated, serve the console verification page.
132-
if let Ok(opctx) = OpContext::for_external_api(&rqctx).await {
133-
if opctx.authn.actor().is_some() {
134-
return serve_console_index(rqctx.context()).await;
135-
}
136-
}
130+
console_index_or_login_redirect(rqctx).await
131+
}
137132

138-
// Otherwise, redirect for authentication.
139-
let params = params.into_inner();
140-
let state_params = serde_urlencoded::to_string(serde_json::json!({
141-
"user_code": params.user_code
142-
}))
143-
.map_err(|e| HttpError::for_internal_error(e.to_string()))?;
144-
let state = Some(format!("/device/verify?{}", state_params));
145-
Ok(Response::builder()
146-
.status(StatusCode::FOUND)
147-
.header(http::header::LOCATION, get_login_url(state))
148-
.body("".into())?)
133+
#[endpoint {
134+
method = GET,
135+
path = "/device/success",
136+
unpublished = true,
137+
}]
138+
pub async fn device_auth_success(
139+
rqctx: Arc<RequestContext<Arc<ServerContext>>>,
140+
) -> Result<Response<Body>, HttpError> {
141+
console_index_or_login_redirect(rqctx).await
149142
}
150143

151144
/// Confirm an OAuth 2.0 Device Authorization Grant

nexus/src/external_api/http_entrypoints.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,7 @@ pub fn external_api() -> NexusApiDescription {
228228

229229
api.register(device_auth::device_auth_request)?;
230230
api.register(device_auth::device_auth_verify)?;
231+
api.register(device_auth::device_auth_success)?;
231232
api.register(device_auth::device_auth_confirm)?;
232233
api.register(device_auth::device_access_token)?;
233234

nexus/tests/integration_tests/console_api.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,10 @@ async fn test_console_pages(cptestctx: &ControlPlaneTestContext) {
116116
// request to console page route without auth should redirect to IdP
117117
let _ = RequestBuilder::new(&testctx, Method::GET, "/orgs/irrelevant-path")
118118
.expect_status(Some(StatusCode::FOUND))
119-
.expect_response_header(header::LOCATION, "/spoof_login")
119+
.expect_response_header(
120+
header::LOCATION,
121+
"/spoof_login?state=%2Forgs%2Firrelevant-path",
122+
)
120123
.execute()
121124
.await
122125
.expect("failed to redirect to IdP on auth failure");

0 commit comments

Comments
 (0)