Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 56 additions & 36 deletions nexus/src/external_api/console_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -496,27 +496,33 @@ pub struct LoginUrlQuery {
/// Generate URL to IdP login form. Optional `state` param is included in query
/// string if present, and will typically represent the URL to send the user
/// back to after successful login.
pub fn get_login_url(state: Option<String>) -> String {
// assume state is not URL encoded, so no risk of double encoding (dropshot
// decodes it on the way in)
// TODO this does not know anything about IdPs, and it should. When the user is
// logged out and hits an auth-gated route, if there are multiple IdPs and we
// don't known which one they want to use, we need to send them to a page that
// will allow them to choose among discoverable IdPs. However, there may be ways
// to give ourselves a hint about which one they want, for example, by storing
// that info in a browser cookie when they log in. When their session ends, we
// will not be able to look at the dead session to find the silo or IdP (well,
// maybe we can but we probably shouldn't) but we can look at the cookie and
// default to sending them to the IdP indicated (though if they don't want that
// one we need to make sure they can get to a different one). If there is no
// cookie, we send them to the selector page. In any case, none of this is done
// here yet. We go to /spoof_login no matter what.
fn get_login_url(state: Option<String>) -> String {
// assume state is not already URL encoded
let query = match state {
Some(state) if state.is_empty() => None,
Some(state) => Some(
Some(state) if !state.is_empty() => {
serde_urlencoded::to_string(LoginUrlQuery { state: Some(state) })
// unwrap is safe because query.state was just deserialized out
// of a query param, so we know it's serializable
.unwrap(),
),
None => None,
.ok() // in the strange event it's not serializable, no query
}
_ => None,
};
// Once we have IdP integration, this will be a URL for the IdP login page.
// For now we point to our own placeholder login page.
let mut url = "/spoof_login".to_string();
if let Some(query) = query {
url.push('?');
url.push_str(query.as_str());
match query {
Some(query) => format!("/spoof_login?{query}"),
None => "/spoof_login".to_string(),
}
url
}

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

pub async fn console_index_or_login_redirect(
rqctx: Arc<RequestContext<Arc<ServerContext>>>,
) -> Result<Response<Body>, HttpError> {
let opctx = OpContext::for_external_api(&rqctx).await;

// if authed, serve console index.html with JS bundle in script tag
if let Ok(opctx) = opctx {
if opctx.authn.actor().is_some() {
return serve_console_index(rqctx.context()).await;
}
}

// otherwise redirect to idp

// put the current URI in the query string to redirect back to after login
let uri = rqctx.request.lock().await.uri().to_string();
Copy link
Contributor Author

@david-crespo david-crespo Jun 30, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

a) is this the logic we want
b) in tests this leaves off the domain, but will it always do that? not sure if we care either way
c) assuming this is what we want, is this really the least ugly way to get it

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

a) I think so
b) Technically, Location redirects should use absolute URIs, but I'm not sure it actually matters much in the modern world.
c) Seems like, since we can't depend on any particular set of parameters being passed into this function.

Copy link
Contributor Author

@david-crespo david-crespo Jul 1, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Eventually this will get a bit fancier (well, the fanciness will probably live in get_login_url) because I think the value of the state param is supposed to be hashed together with some kind of token so it can mitigate request forgery.


Ok(Response::builder()
.status(StatusCode::FOUND)
.header(http::header::LOCATION, get_login_url(Some(uri)))
.body("".into())?)
}

// Dropshot does not have route match ranking and does not allow overlapping
// route definitions, so we cannot have a catchall `/*` route for console pages
// and then also define, e.g., `/api/blah/blah` and give the latter priority
Expand All @@ -575,28 +604,19 @@ pub async fn console_page(
rqctx: Arc<RequestContext<Arc<ServerContext>>>,
_path_params: Path<RestPathParam>,
) -> Result<Response<Body>, HttpError> {
let opctx = OpContext::for_external_api(&rqctx).await;

// if authed, serve HTML page with bundle in script tag

// HTML doesn't need to be static -- we'll probably find a reason to do some
// minimal templating, e.g., putting a CSRF token in the page

// amusingly, at least to start out, I don't think we care about the path
// because the real routing is all client-side. we serve the same HTML
// regardless, the app starts on the client and renders the right page and
// makes the right API requests.
if let Ok(opctx) = opctx {
if opctx.authn.actor().is_some() {
return serve_console_index(rqctx.context()).await;
}
}
console_index_or_login_redirect(rqctx).await
}

// otherwise redirect to idp
Ok(Response::builder()
.status(StatusCode::FOUND)
.header(http::header::LOCATION, get_login_url(None))
.body("".into())?)
#[endpoint {
method = GET,
path = "/settings/{path:.*}",
unpublished = true,
}]
pub async fn console_settings_page(
rqctx: Arc<RequestContext<Arc<ServerContext>>>,
_path_params: Path<RestPathParam>,
) -> Result<Response<Body>, HttpError> {
console_index_or_login_redirect(rqctx).await
}

/// Fetch a static asset from `<static_dir>/assets`. 404 on virtually all
Expand Down
33 changes: 13 additions & 20 deletions nexus/src/external_api/device_auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
//! are for requesting access tokens that will be managed and used by
//! the client to make other API requests.

use super::console_api::{get_login_url, serve_console_index};
use super::console_api::console_index_or_login_redirect;
use super::views::{DeviceAccessTokenGrant, DeviceAuthResponse};
use crate::context::OpContext;
use crate::db::model::DeviceAccessToken;
Expand All @@ -21,7 +21,6 @@ use http::{header, Response, StatusCode};
use hyper::Body;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use serde_urlencoded;
use std::sync::Arc;
use uuid::Uuid;

Expand Down Expand Up @@ -126,26 +125,20 @@ pub struct DeviceAuthVerify {
}]
pub async fn device_auth_verify(
rqctx: Arc<RequestContext<Arc<ServerContext>>>,
params: Query<DeviceAuthVerify>,
_params: Query<DeviceAuthVerify>,
) -> Result<Response<Body>, HttpError> {
// If the user is authenticated, serve the console verification page.
if let Ok(opctx) = OpContext::for_external_api(&rqctx).await {
if opctx.authn.actor().is_some() {
return serve_console_index(rqctx.context()).await;
}
}
console_index_or_login_redirect(rqctx).await
}

// Otherwise, redirect for authentication.
let params = params.into_inner();
let state_params = serde_urlencoded::to_string(serde_json::json!({
"user_code": params.user_code
}))
.map_err(|e| HttpError::for_internal_error(e.to_string()))?;
let state = Some(format!("/device/verify?{}", state_params));
Ok(Response::builder()
.status(StatusCode::FOUND)
.header(http::header::LOCATION, get_login_url(state))
.body("".into())?)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@plotnick I just noticed my version does not automatically put the current path + query in state like yours does (I thought it did). I will fix that

#[endpoint {
method = GET,
path = "/device/success",
unpublished = true,
}]
pub async fn device_auth_success(
rqctx: Arc<RequestContext<Arc<ServerContext>>>,
) -> Result<Response<Body>, HttpError> {
console_index_or_login_redirect(rqctx).await
}

/// Confirm an OAuth 2.0 Device Authorization Grant
Expand Down
1 change: 1 addition & 0 deletions nexus/src/external_api/http_entrypoints.rs
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,7 @@ pub fn external_api() -> NexusApiDescription {

api.register(device_auth::device_auth_request)?;
api.register(device_auth::device_auth_verify)?;
api.register(device_auth::device_auth_success)?;
api.register(device_auth::device_auth_confirm)?;
api.register(device_auth::device_access_token)?;

Expand Down
5 changes: 4 additions & 1 deletion nexus/tests/integration_tests/console_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,10 @@ async fn test_console_pages(cptestctx: &ControlPlaneTestContext) {
// request to console page route without auth should redirect to IdP
let _ = RequestBuilder::new(&testctx, Method::GET, "/orgs/irrelevant-path")
.expect_status(Some(StatusCode::FOUND))
.expect_response_header(header::LOCATION, "/spoof_login")
.expect_response_header(
header::LOCATION,
"/spoof_login?state=%2Forgs%2Firrelevant-path",
)
.execute()
.await
.expect("failed to redirect to IdP on auth failure");
Expand Down