diff --git a/CHANGELOG.md b/CHANGELOG.md index 7fac40f0..3d9f3fcd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,16 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.16.0] + +### Added + +- `tutorial`: More revisions to the tutorial documentation. + +### Changed + +- `nakago-axum`: Renamed the `auth::subject::Provide` provider for `Validator` - which didn't make sense - to `validator::Provide`. + ## [0.15.0] ### Removed @@ -228,6 +238,7 @@ Expect major changes to the Application and Lifecycle systems going forward, bui - Injection Providers - Documentation +[0.16.0]: https://github.com/bkonkle/nakago/compare/0.15.0...0.16.0 [0.15.0]: https://github.com/bkonkle/nakago/compare/0.14.1...0.15.0 [0.14.1]: https://github.com/bkonkle/nakago/compare/0.14.0...0.14.1 [0.14.0]: https://github.com/bkonkle/nakago/compare/0.13.0...0.14.0 diff --git a/examples/async-graphql/Cargo.toml b/examples/async-graphql/Cargo.toml index 735f4ef2..d11ecc57 100644 --- a/examples/async-graphql/Cargo.toml +++ b/examples/async-graphql/Cargo.toml @@ -26,7 +26,7 @@ futures = "0.3" hyper = "0.14" log = "0.4" nakago = "0.14" -nakago-axum = "0.14" +nakago-axum = "0.16" nakago-derive = "0.8" nakago-sea-orm = "0.14" nakago-async-graphql = "0.15" diff --git a/examples/async-graphql/src/init.rs b/examples/async-graphql/src/init.rs index 8d9fca2a..0c454fb8 100644 --- a/examples/async-graphql/src/init.rs +++ b/examples/async-graphql/src/init.rs @@ -1,6 +1,6 @@ use nakago::{inject, EventType}; use nakago_axum::{ - auth::{self, jwks, Validator, JWKS}, + auth::{jwks, validator, Validator, JWKS}, AxumApplication, }; @@ -21,7 +21,7 @@ pub async fn app() -> inject::Result> { app.provide(&JWKS, jwks::Provide::default().with_config_tag(&CONFIG)) .await?; - app.provide_type::(auth::subject::Provide::default()) + app.provide_type::(validator::Provide::default()) .await?; app.provide( diff --git a/examples/async-graphql/tests/utils.rs b/examples/async-graphql/tests/utils.rs index 587d6eac..5c799c23 100644 --- a/examples/async-graphql/tests/utils.rs +++ b/examples/async-graphql/tests/utils.rs @@ -6,7 +6,7 @@ use anyhow::Result; use axum::http::HeaderValue; use fake::{Fake, Faker}; use futures_util::{stream::SplitStream, Future, SinkExt, StreamExt}; -use nakago_axum::auth::{self, Validator}; +use nakago_axum::auth::{validator, Validator}; use serde::Deserialize; use tokio::{net::TcpStream, time::timeout}; use tokio_tungstenite::{ @@ -40,7 +40,7 @@ impl Utils { pub async fn init() -> Result { let app = init::app().await?; - app.replace_type_with::(auth::subject::ProvideUnverified::default()) + app.replace_type_with::(validator::ProvideUnverified::default()) .await?; let config_path = std::env::var("CONFIG_PATH_ASYNC_GRAPHQL") diff --git a/examples/simple/Cargo.toml b/examples/simple/Cargo.toml index 8336acae..bfabac1a 100644 --- a/examples/simple/Cargo.toml +++ b/examples/simple/Cargo.toml @@ -23,7 +23,7 @@ futures = "0.3" hyper = "0.14" log = "0.4" nakago = "0.14" -nakago-axum = "0.14" +nakago-axum = "0.16" nakago-derive = "0.8" pico-args = "0.5.0" pretty_env_logger = "0.5" diff --git a/examples/simple/src/init.rs b/examples/simple/src/init.rs index 1c5d7051..8948e872 100644 --- a/examples/simple/src/init.rs +++ b/examples/simple/src/init.rs @@ -1,6 +1,6 @@ use nakago::{inject, EventType}; use nakago_axum::{ - auth::{self, jwks, Validator, JWKS}, + auth::{jwks, validator, Validator, JWKS}, config, AxumApplication, }; @@ -18,7 +18,7 @@ pub async fn app() -> inject::Result> { app.provide(&JWKS, jwks::Provide::default().with_config_tag(&CONFIG)) .await?; - app.provide_type::(auth::subject::Provide::default()) + app.provide_type::(validator::Provide::default()) .await?; // Loading diff --git a/examples/simple/tests/utils.rs b/examples/simple/tests/utils.rs index 345e7786..f6b8a963 100644 --- a/examples/simple/tests/utils.rs +++ b/examples/simple/tests/utils.rs @@ -1,7 +1,7 @@ use std::ops::Deref; use anyhow::Result; -use nakago_axum::auth::{self, Validator}; +use nakago_axum::auth::{validator, Validator}; use nakago_examples_simple::{init, Config}; @@ -19,7 +19,7 @@ impl TestUtils { pub async fn init() -> Result { let app = init::app().await?; - app.replace_type_with::(auth::subject::ProvideUnverified::default()) + app.replace_type_with::(validator::ProvideUnverified::default()) .await?; let config_path = std::env::var("CONFIG_PATH_SIMPLE") diff --git a/nakago_async_graphql/Cargo.toml b/nakago_async_graphql/Cargo.toml index 5d82963f..551cc411 100644 --- a/nakago_async_graphql/Cargo.toml +++ b/nakago_async_graphql/Cargo.toml @@ -21,7 +21,7 @@ figment = { version = "0.10", features = ["env"] } hyper = "0.14" log = "0.4" nakago = "0.14" -nakago-axum = "0.14" +nakago-axum = "0.16" nakago-derive = "0.8" pretty_env_logger = "0.5" rand = "0.8" diff --git a/nakago_axum/Cargo.toml b/nakago_axum/Cargo.toml index 61af97aa..5113ce7a 100644 --- a/nakago_axum/Cargo.toml +++ b/nakago_axum/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "nakago-axum" -version = "0.14.1" +version = "0.16.0" authors = ["Brandon Konkle "] edition = "2021" description = "An Axum HTTP routes integration for Nakago" diff --git a/nakago_axum/src/auth/jwks.rs b/nakago_axum/src/auth/jwks.rs index 30eea30d..eda781bc 100644 --- a/nakago_axum/src/auth/jwks.rs +++ b/nakago_axum/src/auth/jwks.rs @@ -3,10 +3,9 @@ use std::{marker::PhantomData, sync::Arc}; use async_trait::async_trait; use axum::extract::FromRef; use biscuit::{ - jwa::SignatureAlgorithm, jwk::{AlgorithmParameters, JWKSet, JWK}, - jws::{Header, Secret}, - ClaimsSet, Empty, JWT, + jws::Secret, + Empty, }; use hyper::{self, body::to_bytes, client::HttpConnector, Body, Method, Request}; use hyper_tls::HttpsConnector; @@ -14,7 +13,7 @@ use nakago::{self, inject, Inject, Provider, Tag}; use nakago_derive::Provider; use thiserror::Error; -use super::{Config, Error}; +use super::Config; /// The JWKS Tag pub const JWKS: Tag> = Tag::new("auth::JWKS"); @@ -88,59 +87,6 @@ pub fn get_secret(jwk: JWK) -> Result { Ok(secret) } -/// A validator for JWTs that uses a JWKS key set to validate the token -#[derive(Clone)] -pub enum Validator { - /// A validator that uses a JWKS key set to validate the token - KeySet(Arc>), - - /// A validator that does not validate the token, used for testing - Unverified, -} - -impl Validator { - /// Get a validated payload from a JWT string - pub fn get_payload(&self, jwt: &str) -> Result, Error> { - match self { - Validator::KeySet(jwks) => { - // First extract without verifying the header to locate the key-id (kid) - let token = JWT::::new_encoded(jwt); - - let header: Header = token.unverified_header().map_err(Error::JWTToken)?; - - let key_id = header.registered.key_id.ok_or(Error::JWKSVerification)?; - - debug!("Fetching signing key for '{:?}'", key_id); - - // Now that we have the key, construct our RSA public key secret - let secret = get_secret_from_key_set(jwks, &key_id) - .map_err(|_err| Error::JWKSVerification)?; - - // Now fully verify and extract the token - let token = token - .into_decoded(&secret, SignatureAlgorithm::RS256) - .map_err(Error::JWTToken)?; - - let payload = token.payload().map_err(Error::JWTToken)?; - - debug!( - "Successfully verified token with subject: {:?}", - payload.registered.subject - ); - - Ok(payload.clone()) - } - Validator::Unverified => { - let token = JWT::::new_encoded(jwt); - - let payload = &token.unverified_payload().map_err(Error::JWTToken)?; - - Ok(payload.clone()) - } - } - } -} - /// Possible errors during jwks retrieval #[derive(Debug, Error)] pub enum ClientError { diff --git a/nakago_axum/src/auth/mod.rs b/nakago_axum/src/auth/mod.rs index d0725096..336ff285 100644 --- a/nakago_axum/src/auth/mod.rs +++ b/nakago_axum/src/auth/mod.rs @@ -12,7 +12,11 @@ pub mod jwks; /// JWT authentication pub mod subject; +/// Validation +pub mod validator; + pub use config::Config; pub use errors::Error; -pub use jwks::{Validator, JWKS}; +pub use jwks::JWKS; pub use subject::Subject; +pub use validator::Validator; diff --git a/nakago_axum/src/auth/subject.rs b/nakago_axum/src/auth/subject.rs index 47748821..87d14c50 100644 --- a/nakago_axum/src/auth/subject.rs +++ b/nakago_axum/src/auth/subject.rs @@ -1,26 +1,12 @@ -#![allow(unused_imports)] -use std::sync::Arc; - use async_trait::async_trait; -use axum::{ - extract::{FromRef, FromRequestParts}, - Extension, -}; -use biscuit::{ - jwa::SignatureAlgorithm, - jwk::JWKSet, - jws::{Compact, Header}, - ClaimsSet, Empty, JWT, -}; +use axum::extract::FromRequestParts; use http::{header::AUTHORIZATION, request::Parts, HeaderMap, HeaderValue}; -use nakago::{inject, Dependency, Inject, Provider, Tag}; -use nakago_derive::Provider; use crate::State; use super::{ - jwks::{get_secret_from_key_set, Validator, JWKS}, Error::{self, InvalidAuthHeader, MissingValidator}, + Validator, }; const BEARER: &str = "Bearer "; @@ -83,42 +69,3 @@ pub fn jwt_from_header(headers: &HeaderMap) -> Result, Ok(Some(auth_header.trim_start_matches(BEARER))) } - -/// Provide the State needed in order to use the `Subject` extractor in an Axum handler -/// -/// **Provides:** `Validator` -/// -/// **Depends on:** -/// - `Tag(auth::JWKS)` -#[derive(Default)] -pub struct Provide {} - -#[Provider] -#[async_trait] -impl Provider for Provide { - async fn provide(self: Arc, i: Inject) -> inject::Result> { - let jwks = i.get(&JWKS).await?; - - let validator = Validator::KeySet(jwks); - - Ok(Arc::new(validator)) - } -} - -/// Provide the test ***unverified*** AuthState used in testing, which trusts any token given to it -/// -/// **WARNING: This is insecure and should only be used in testing** -/// -/// **Provides:** `Validator` -#[derive(Default)] -pub struct ProvideUnverified {} - -#[Provider] -#[async_trait] -impl Provider for ProvideUnverified { - async fn provide(self: Arc, _i: Inject) -> inject::Result> { - let validator = Validator::Unverified; - - Ok(Arc::new(validator)) - } -} diff --git a/nakago_axum/src/auth/validator.rs b/nakago_axum/src/auth/validator.rs new file mode 100644 index 00000000..21a73e33 --- /dev/null +++ b/nakago_axum/src/auth/validator.rs @@ -0,0 +1,103 @@ +use std::sync::Arc; + +use async_trait::async_trait; +use biscuit::{jwa::SignatureAlgorithm, jwk::JWKSet, jws::Header, ClaimsSet, Empty, JWT}; +use nakago::{inject, Inject, Provider}; +use nakago_derive::Provider; + +use super::{ + jwks::{get_secret_from_key_set, JWKS}, + Error, +}; + +/// A validator for JWTs that uses a JWKS key set to validate the token +#[derive(Clone)] +pub enum Validator { + /// A validator that uses a JWKS key set to validate the token + KeySet(Arc>), + + /// A validator that does not validate the token, used for testing + Unverified, +} + +impl Validator { + /// Get a validated payload from a JWT string + pub fn get_payload(&self, jwt: &str) -> Result, Error> { + match self { + Validator::KeySet(jwks) => { + // First extract without verifying the header to locate the key-id (kid) + let token = JWT::::new_encoded(jwt); + + let header: Header = token.unverified_header().map_err(Error::JWTToken)?; + + let key_id = header.registered.key_id.ok_or(Error::JWKSVerification)?; + + debug!("Fetching signing key for '{:?}'", key_id); + + // Now that we have the key, construct our RSA public key secret + let secret = get_secret_from_key_set(jwks, &key_id) + .map_err(|_err| Error::JWKSVerification)?; + + // Now fully verify and extract the token + let token = token + .into_decoded(&secret, SignatureAlgorithm::RS256) + .map_err(Error::JWTToken)?; + + let payload = token.payload().map_err(Error::JWTToken)?; + + debug!( + "Successfully verified token with subject: {:?}", + payload.registered.subject + ); + + Ok(payload.clone()) + } + Validator::Unverified => { + let token = JWT::::new_encoded(jwt); + + let payload = &token.unverified_payload().map_err(Error::JWTToken)?; + + Ok(payload.clone()) + } + } + } +} + +/// Provide the State needed in order to use the `Subject` extractor in an Axum handler +/// +/// **Provides:** `Validator` +/// +/// **Depends on:** +/// - `Tag(auth::JWKS)` +#[derive(Default)] +pub struct Provide {} + +#[Provider] +#[async_trait] +impl Provider for Provide { + async fn provide(self: Arc, i: Inject) -> inject::Result> { + let jwks = i.get(&JWKS).await?; + + let validator = Validator::KeySet(jwks); + + Ok(Arc::new(validator)) + } +} + +/// Provide the test ***unverified*** AuthState used in testing, which trusts any token given to it +/// +/// **WARNING: This is insecure and should only be used in testing** +/// +/// **Provides:** `Validator` +#[derive(Default)] +pub struct ProvideUnverified {} + +#[Provider] +#[async_trait] +impl Provider for ProvideUnverified { + async fn provide(self: Arc, _i: Inject) -> inject::Result> { + let validator = Validator::Unverified; + + Ok(Arc::new(validator)) + } +} diff --git a/website/docs/tutorial.md b/website/docs/tutorial.md index 6023bd4c..f4676a07 100644 --- a/website/docs/tutorial.md +++ b/website/docs/tutorial.md @@ -48,7 +48,7 @@ It includes a barebones `init::app()` function that will load your configuration The `main.rs` uses the [pico-args](https://docs.rs/pico-args/0.5.0/pico_args/) to parse a simple command-line argument to specify an alternate config path, which is useful for many deployment scenarios that dynamically map a config file to a certain mount point within a container filesystem. -In the `http/` folder, you'll find an empty AppState with a dependency injection Provider that you can fill in with your own dependencies. The router maps a simple `GET /health` route to a handler that returns a JSON response with a success message. +In the `http/` folder, you'll find an Axum handler and a router initialization hook. The router maps a simple `GET /health` route to a handler that returns a JSON response with a success message. You now have a simple foundation to build on. Let's add some more functionality! @@ -60,7 +60,7 @@ Follow the Installation instructions in the `README.md` to prepare your new loca One of the first things you'll probably want to add to your application is authentication, which establishes the user's identity. This is separate and distinct from authorization, which determines what the user is allowed to do. -The only currently supported method of authentication is through JWT with JWKS keys. The `nakago-axum` library provides a request extension for for Axum that will use [biscuit](https://docs.rs/biscuit/0.6.0/biscuit/) to decode a JWT from the `Authorization` header, validate it with a JWKS key from the `/.well-known/jwks.json` path on the auth url, and then return the value of the `sub` claim from the payload. +The only currently supported method of authentication is through JWT with JWKS keys, though other methods will be added in the future. The `nakago-axum` library provides a request extractor for for Axum that uses [biscuit](https://docs.rs/biscuit/0.6.0/biscuit/) with your Nakago application Config to decode a JWT from the `Authorization` header, validate it with a JWKS key from the `/.well-known/jwks.json` path on the auth url, and then return the value of the `sub` claim from the payload. *Configurable claims and other authentication methods will be added in the future.* @@ -80,13 +80,7 @@ pub struct Config { } ``` -This auth `Config` is automatically loaded as part of the default config loaders in the `nakago-axum` crate, so this line in the `init.rs` ensures that it is populated from environment variables or the currently chosen config file: - -```rust -use nakago_axum::config; - -app.on(&EventType::Load, config::AddLoaders::default()); -``` +This auth `Config` is automatically loaded as part of the default config loaders in the `nakago-axum` crate, which you'll see below. Next, add the following values to your `config/local.toml.example` file as a hint, so that new developers know they need to reach out to you for real values when they create their own `config/local.toml` file: @@ -100,11 +94,29 @@ id = "client_id" secret = "client_secret" ``` -Add the real details to your own `config/local.toml` file, which should be excluded from git via the `.gitignore` file. If you don't have real values yet, leave them as the dummy values above. You can still run integration tests without having a real OAuth2 provider running, if you want. +Add the real details to your own `config.toml` file, which should be excluded from git via the `.gitignore` file. If you don't have real values yet, leave them as the dummy values above. You can still run integration tests without having a real OAuth2 provider running, if you want. ### Initialization -In your top-level `init.rs` file, you should use `jwks::Provide` and `auth::subject::Provide` providers to inject the pieces that Nakago-Axum's `Subject` extractor will use to retrieve the authentication data for a request. +You're now ready to head over to your initialization routine. This is where you will provide all of the dependencies and lifecycle hooks your app needs in order to start up. + +This line already in the top-level `init.rs` ensures that your config is populated from environment variables or the currently chosen config file, along with the auth property you added above: + +```rust +// This line should already be in your `init.rs` file +app.on(&EventType::Load, config::AddLoaders::default()); +``` + +First, add the default JWKS Validator from `nakago_axum`'s `auth` module using the `provide_type` method, which uses the type as the key for the Inject container: + +```rust +app.provide_type::(validator::Provide::default()) + .await?; +``` + +This will be overridden in your tests to use the unverified variant, but we'll get to that later. + +Next you should use the `jwks::Provide` to inject the JWKS config with a tag, so we use the `provide` method rather than `provide_type`. This uses the tag as the key for the Inject container. ```rust use nakago_axum::auth::{self, jwks, Validator, JWKS}; @@ -114,11 +126,10 @@ use nakago_axum::auth::{self, jwks, Validator, JWKS}; app.provide(&JWKS, jwks::Provide::default().with_config_tag(&CONFIG)) .await?; -app.provide_type::(auth::subject::Provide::default()) - .await?; +// ... ``` -The `.with_config_tag(&CONFIG)` provides the custom Tag for your `AppConfig`, which will be unique to your app. +The `.with_config_tag(&CONFIG)` provides the custom Tag for your app's custom `Config`. ### Axum Route