diff --git a/oxide-auth-axum/examples/axum-example/Cargo.toml b/oxide-auth-axum/examples/axum-example/Cargo.toml new file mode 100644 index 00000000..7a2346d2 --- /dev/null +++ b/oxide-auth-axum/examples/axum-example/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "axum-example" +version = "0.0.0" +authors = ["Andreas Molzer "] +edition = "2018" + +[dependencies] +env_logger = "0.9" +futures = "0.3" +oxide-auth = { version = "0.6.0", path = "./../../../oxide-auth" } +reqwest = { version = "0.11.10", features = ["blocking"] } +serde = "1.0" +serde_json = "1.0" +url = "2" +serde_urlencoded = "0.7" +tokio = "1.16.1" +axum = { version = "0.8.4", features = ["macros"] } +oxide-auth-axum = { version = "0.6.0", path = "./../.." } +tower = "0.5.2" +tower-http = { version = "0.6.4", features = ["trace"] } +tracing-subscriber = "0.3.19" diff --git a/oxide-auth-axum/examples/axum-example/src/main.rs b/oxide-auth-axum/examples/axum-example/src/main.rs new file mode 100644 index 00000000..7beb3969 --- /dev/null +++ b/oxide-auth-axum/examples/axum-example/src/main.rs @@ -0,0 +1,241 @@ +mod support; + +use axum::{ + extract::{Query, State}, + http::StatusCode, + response::{Html, IntoResponse, Response}, + routing::{get, post}, + Router, +}; +use oxide_auth::{ + endpoint::{AccessTokenFlow, AuthorizationFlow, Authorizer, Issuer, OwnerConsent, QueryParameter, RefreshFlow, Registrar, ResourceFlow, Solicitation}, + frontends::simple::endpoint::{FnSolicitor, Generic, Vacant}, + primitives::{grant::Grant, prelude::{AuthMap, Client, ClientMap, RandomGenerator, Scope, TokenMap}}, +}; +use oxide_auth_axum::{OAuthRequest, OAuthResource, OAuthResponse, WebError}; +use std::{collections::HashMap, iter::FromIterator, sync::Arc}; +use std::sync::Mutex; +use tower::ServiceBuilder; +use tower_http::trace::TraceLayer; +use oxide_auth::frontends::dev::WebResponse; + +static DENY_TEXT: &str = " +This page should be accessed via an oauth token from the client in the example. Click + +here to begin the authorization process. + +"; + +fn ok_response() -> OAuthResponse { + let mut response = OAuthResponse::default(); + response.ok().unwrap(); + response +} + +struct OAuthState { + registrar: Mutex, + authorizer: Mutex>, + issuer: Mutex>, +} + +impl OAuthState { + pub fn preconfigured() -> Self { + let client_map = ClientMap::from_iter( + vec![Client::confidential( + "LocalClient", + "http://localhost:8021/endpoint" + .parse::() + .unwrap() + .into(), + "default-scope".parse().unwrap(), + "SecretSecret".as_bytes(), + )] + ); + OAuthState { + registrar: Mutex::new(client_map), + authorizer: Mutex::new(AuthMap::new(RandomGenerator::new(16))), + issuer: Mutex::new(TokenMap::new(RandomGenerator::new(16))), + } + } + + /// In larger app, you'd likey wrap it in your own Endpoint instead of `Generic`. + pub fn endpoint(&self) -> Generic> { + Generic { + registrar: self.registrar.lock().unwrap(), + authorizer: self.authorizer.lock().unwrap(), + issuer: self.issuer.lock().unwrap(), + // Solicitor configured later. + solicitor: Vacant, + scopes: vec!["default-scope".parse().unwrap()], + // OAuthResponse is Default + response: Vacant, + } + } +} + +type SharedState = Arc; + + + +#[axum::debug_handler] +async fn get_authorize( + State(state): State, + req: OAuthRequest, +) -> Result { + let solicitor = FnSolicitor(move |_: &mut OAuthRequest, pre_grant: Solicitation| { + // This will display a page to the user asking for his permission to proceed. The submitted form + // will then trigger the other authorization handler which actually completes the flow. + OwnerConsent::InProgress( + ok_response() + .content_type("text/html") + .unwrap() + .body(&crate::support::consent_page_html("/authorize".into(), pre_grant)) + ) + }); + let endpoint = state.endpoint().with_solicitor(solicitor); + + AuthorizationFlow::prepare(endpoint) + .expect("Failed to prepare authorization flow") + .execute(req) + .map(|resp| resp.into_response()) + .map_err(WebError::from) +} + +#[axum::debug_handler] +async fn post_authorize( + State(state): State, + Query(params): Query>, + req: OAuthRequest, +) -> Result { + let allow = params.get("allow").is_some(); + let solicitor = FnSolicitor(move |_: &mut OAuthRequest, _: Solicitation| { + if allow { + // TODO: change this from dummy user to the actual user + OwnerConsent::Authorized("dummy user".to_owned()) + } else { + OwnerConsent::Denied + } + }); + let endpoint = state.endpoint().with_solicitor(solicitor); + + AuthorizationFlow::prepare(endpoint) + .expect("Failed to prepare authorization flow") + .execute(req) + .map(|resp| resp.into_response()) + .map_err(WebError::from) +} + +#[axum::debug_handler] +async fn token( + State(state): State, + req: OAuthRequest, +) -> Result { + let grant_type = req.body().and_then(|body| body.unique_value("grant_type")); + + // Different grant types determine which flow to perform. + match grant_type.as_deref() { + Some("client_credentials") => { + let solicitor = FnSolicitor(move |_: &mut OAuthRequest, solicitation: Solicitation| { + // For the client credentials flow, the solicitor is consulted + // to ensure that the resulting access token is issued to the + // correct owner. This may be the client itself, if clients + // and resource owners are from the same set of entities, but + // may be distinct if that is not the case. + OwnerConsent::Authorized(solicitation.pre_grant().client_id.clone()) + }); + let endpoint = state.endpoint().with_solicitor(solicitor); + + AccessTokenFlow::prepare(endpoint) + .expect("Failed to prepare access token flow") + .execute(req) + .map(|resp| resp.into_response()) + .map_err(WebError::from) + } + // Each flow will validate the grant_type again, so we can let one case handle + // any incorrect or unsupported options. + _ => { + let endpoint = state.endpoint(); + AccessTokenFlow::prepare(endpoint) + .expect("Failed to prepare access token flow") + .execute(req) + .map(|resp| resp.into_response()) + .map_err(WebError::from) + } + } +} + +#[axum::debug_handler] +async fn refresh( + State(state): State, + req: OAuthRequest, +) -> Result { + RefreshFlow::prepare(state.endpoint())? + .execute(req) + .map(|resp| resp.into_response()) + .map_err(WebError::from) +} + +fn resource_flow(endpoint: Generic>, req: OAuthResource) -> Result> { + ResourceFlow::prepare(endpoint) + .expect("Failed to prepare resource flow") + .execute(req.into()) + .map_err(|r| r.map_err(WebError::from)) +} + +#[axum::debug_handler] +async fn index( + State(state): State, + req: OAuthResource, +) -> Result { + let endpoint = state.endpoint(); + match resource_flow(endpoint, req) { + Ok(_grant) => Ok("Hello world!".into_response()), + Err(_) => Ok(Html(DENY_TEXT).into_response()), // TODO: we still have a Result here + } +} + +async fn start_browser() { + let _ = tokio::spawn(async { support::open_in_browser(8020) }); +} + +#[tokio::main(flavor = "current_thread")] +pub async fn main() -> std::io::Result<()> { + std::env::set_var( + "RUST_LOG", + "axum_example=info,tower_http=info", + ); + tracing_subscriber::fmt::init(); + + // Start, then open in browser, don't care about this finishing. + tokio::spawn(start_browser()); + + let state = Arc::new(OAuthState::preconfigured()); + + // Create the main server instance + let app = Router::new() + .route("/", get(index)) + .route("/authorize", get(get_authorize).post(post_authorize)) + .route("/token", post(token)) + .route("/refresh", post(refresh)) + .layer( + ServiceBuilder::new() + .layer(TraceLayer::new_for_http()) + ) + .with_state(state); + + let listener = tokio::net::TcpListener::bind("localhost:8020") + .await + .expect("Failed to bind to socket"); + + println!("Server running on http://localhost:8020"); + + let server = axum::serve(listener, app); + let client = support::dummy_client(); + + tokio::select! { + result = server => result.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?, + result = client => result, + } + + Ok(()) +} \ No newline at end of file diff --git a/oxide-auth-axum/examples/axum-example/src/support.rs b/oxide-auth-axum/examples/axum-example/src/support.rs new file mode 100644 index 00000000..971cb51c --- /dev/null +++ b/oxide-auth-axum/examples/axum-example/src/support.rs @@ -0,0 +1,146 @@ +#[rustfmt::skip] +#[path = "../../../../examples/support/generic.rs"] +mod generic; + +use std::collections::HashMap; + +pub use self::generic::{consent_page_html, open_in_browser, Client, ClientConfig, ClientError}; + +use axum::{ + extract::{Query, State}, + http::StatusCode, + response::{Html, IntoResponse, Redirect, Response}, + routing::{get, post}, + Router, +}; +use std::sync::Arc; +use tower::ServiceBuilder; +use tower_http::trace::TraceLayer; + +pub async fn dummy_client() { + let client = Arc::new(Client::new(ClientConfig { + client_id: "LocalClient".into(), + client_secret: Some("SecretSecret".to_owned()), + protected_url: "http://localhost:8020/".into(), + token_url: "http://localhost:8020/token".into(), + refresh_url: "http://localhost:8020/refresh".into(), + redirect_uri: "http://localhost:8021/endpoint".into(), + })); + + let app = Router::new() + .route("/", get(get_with_token)) + .route("/endpoint", get(endpoint_impl)) + .route("/refresh", post(refresh)) + .layer( + ServiceBuilder::new() + .layer(TraceLayer::new_for_http()) + ) + .with_state(client); + + let listener = tokio::net::TcpListener::bind("localhost:8021") + .await + .expect("Failed to start dummy client"); + + println!("Dummy client running on http://localhost:8021"); + + axum::serve(listener, app) + .await + .expect("Failed to run dummy client"); +} + +async fn endpoint_impl( + Query(query): Query>, + State(client): State>, +) -> Response { + if let Some(cause) = query.get("error") { + return ( + StatusCode::BAD_REQUEST, + format!("Error during owner authorization: {:?}", cause) + ).into_response(); + } + + let code = match query.get("code") { + None => { + return ( + StatusCode::BAD_REQUEST, + "Missing code" + ).into_response(); + } + Some(code) => code.clone(), + }; + + let auth_handle = tokio::task::spawn_blocking(move || { + client.authorize(&code) + }); + + match auth_handle.await { + Ok(Ok(())) => Redirect::to("/").into_response(), + Ok(Err(err)) => ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("{}", err) + ).into_response(), + Err(err) => ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Task join error: {}", err) + ).into_response(), + } +} + +async fn refresh(State(client): State>) -> Response { + let refresh_handle = tokio::task::spawn_blocking(move || { + client.refresh() + }); + + match refresh_handle.await { + Ok(Ok(())) => Redirect::to("/").into_response(), + Ok(Err(err)) => ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("{}", err) + ).into_response(), + Err(err) => ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Task join error: {}", err) + ).into_response(), + } +} + +async fn get_with_token(State(client): State>) -> Response { + let html = client.as_html(); + let client_clone = client.clone(); + + let protected_page_handle = tokio::task::spawn_blocking(move || { + client_clone.retrieve_protected_page() + }); + + let protected_page = match protected_page_handle.await { + Ok(Ok(page)) => page, + Ok(Err(err)) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("{}", err) + ).into_response(); + } + Err(err) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Task join error: {}", err) + ).into_response(); + } + }; + + let display_page = format!( + " +
+ Used token to access + http://localhost:8020/. + Its contents are: +
{}
+
+
", html, protected_page); + + Html(display_page).into_response() +} \ No newline at end of file