From 8121a8996ebe7c17e265e3a8caae220c1b235e5b Mon Sep 17 00:00:00 2001 From: Olivier 'reivilibre Date: Wed, 26 Jun 2024 19:31:22 +0100 Subject: [PATCH] Improve code documentation Signed-off-by: Olivier 'reivilibre --- src/config.rs | 49 ++++++++++++++- src/lib.rs | 7 +++ src/passwords.rs | 16 +++++ src/store.rs | 54 ++++++++++++++++ src/web.rs | 15 +++++ src/web/login.rs | 61 +++++++++++++++++++ src/web/oauth_openid.rs | 8 ++- .../application_session_access_token.rs | 8 ++- src/web/oauth_openid/authorisation.rs | 9 ++- src/web/oauth_openid/ext_codes.rs | 45 +++++++++++++- src/web/oauth_openid/token.rs | 18 +++++- src/web/sessionless_xsrf.rs | 1 + 12 files changed, 282 insertions(+), 9 deletions(-) diff --git a/src/config.rs b/src/config.rs index 4f9b3b5..d237d70 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,3 +1,11 @@ +//! # idCoop Configuration +//! +//! [`confique`] is used to allow for a layered configuration. +//! +//! We have two files: a configuration file and a 'secrets' file. +//! The secrets file only contains secrets and is suitable for keeping encrypted and +//! separate from the main configuration in deployment tools. + use std::{ collections::{BTreeMap, BTreeSet}, net::SocketAddr, @@ -12,24 +20,34 @@ use governor::Quota; use josekit::jwk::alg::rsa::RsaKeyPair; use serde::{de::Error, Deserialize}; +/// Root of the main configuration file. +/// +/// This is split into several sections. #[derive(Config)] pub struct Configuration { + /// Section of configuration controlling how the web interface listens. #[config(nested)] pub listen: ListenConfiguration, + /// Section of configuration controlling how we connect to Postgres. #[config(nested)] pub postgres: PostgresConfiguration, + /// Section of configuration for setting up OpenID Connect clients. #[config(nested)] pub oidc: OidcConfiguration, + /// Section of configuration for configuring how passwords are hashed. #[config(nested)] pub password_hashing: PasswordHashingConfig, + /// Section of configuration for configuring how requests are rate limited + /// to prevent overload of the system or exceeding system requirements. #[config(nested)] pub ratelimits: RatelimitsConfig, } +/// Configuration of values and clients for OpenID Connect. #[derive(Config)] pub struct OidcConfiguration { /// Our identity as an OAuth 2.1 / OIDC issuer. @@ -37,6 +55,7 @@ pub struct OidcConfiguration { /// Should really be unique. pub issuer: String, + /// Path to an RSA keypair in PEM format. pub rsa_keypair: PathBuf, /// Map from client_id to OpenID Connect Relying Party (client) configuration. @@ -44,26 +63,39 @@ pub struct OidcConfiguration { pub clients: BTreeMap, } +/// Configuration of how the HTTP interface should listen. #[derive(Config)] pub struct ListenConfiguration { + /// What host and port to bind to. e.g. `127.0.0.1:8177` #[config(env = "IC_LISTEN_BIND")] pub bind: Option, + /// The public base URI, e.g. `https://idcoop.example.org`. + /// This is how users will access idCoop. + /// Please use a HTTPS URI in real-world circumstances. pub public_base_uri: String, + /// How to resolve client IP addresses. + /// + /// See [`SecureClientIpSource`] for the options. + /// The default uses the IP address of the TCP client, which assumes a reverse proxy is not in use. + /// A popular alternative would be to use the `X-Forwarded-For` header and configure your reverse proxy + /// (e.g. nginx) to set that. #[config(default = "ConnectInfo")] pub client_ip_source: SecureClientIpSource, } +/// Configuration for connecting to Postgres #[derive(Config)] pub struct PostgresConfiguration { /// Postgres connection string in the form: /// `postgres://user:pass@host:port/db_name?application-name=idcoop` - /// See https://docs.rs/sqlx-postgres/0.7.2/sqlx_postgres/struct.PgConnectOptions.html + /// See #[config(env = "IC_POSTGRES")] pub connect: String, } +/// Configuration for one individual OpenID Connect client (application / relying party). #[derive(Deserialize)] pub struct OidcClientConfiguration { /// Whether to skip the consent screen. Usually desirable for SSO where you control the service and the identity server. @@ -92,6 +124,7 @@ pub struct OidcClientConfiguration { pub secret: Option, } +/// Content of the secret configuration file; separate from the main configuration file. #[derive(Config)] pub struct SeparateSecretConfiguration { /// Client secrets for the OIDC clients. @@ -99,11 +132,16 @@ pub struct SeparateSecretConfiguration { pub oidc_secrets: BTreeMap, } +/// Loaded secrets pub struct SecretConfig { + /// The RSA key pair used for OpenID Connect. pub rsa_key_pair: RsaKeyPair, } impl SecretConfig { + /// Attempt to load the secrets. + /// + /// Fails if we can't read the RSA key for example. pub async fn try_new(config: &Configuration) -> eyre::Result { let rsa_keypair_bytes = tokio::fs::read(&config.oidc.rsa_keypair) .await @@ -132,13 +170,22 @@ pub struct PasswordHashingConfig { pub parallelism: u32, } +/// Configuration for rate-limits. #[derive(Config)] pub struct RatelimitsConfig { + /// How many login attempts to allow from one source IP address. #[config(default = "10 per hour, 5 burst")] pub login: RatelimiterConfig, } +/// Configuration for one rate limit. +/// +/// Is read from the configuration file in one of these formats: +/// - "5 Hz, 20 burst" +/// - "5 per second, 10 burst" +/// - "10 per hour, 5 burst" pub struct RatelimiterConfig { + /// The inner [`Quota`], which this struct is just a wrapper for. pub quota: Quota, } diff --git a/src/lib.rs b/src/lib.rs index c406667..c888d47 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,3 +1,10 @@ +//! # the idCoop crate +//! +//! idCoop is an application but this crate is exposed to allow for easier integration testing. +//! (This is just personal style: the entrypoint is in `src/bin` and is only a thin wrapper around the `idcoop` crate.) + +#![deny(missing_docs)] + pub mod config; pub mod passwords; pub mod store; diff --git a/src/passwords.rs b/src/passwords.rs index 98f1258..89e8687 100644 --- a/src/passwords.rs +++ b/src/passwords.rs @@ -1,3 +1,7 @@ +//! Password handling utilities for idCoop. +//! +//! This file is particularly security-sensitive. + use argon2::{ password_hash::SaltString, Algorithm, Argon2, Params, PasswordHash, PasswordHasher, PasswordVerifier, Version, @@ -7,6 +11,11 @@ use rand::rngs::OsRng; use crate::config::PasswordHashingConfig; +/// Create a password hash from the given password string and configuration. +/// +/// The returned result should only be STORED AS-IS and then used with [`check_hash`] later. +/// +/// It is not valid to compare results of this function directly. pub fn create_password_hash( password: &str, config: &PasswordHashingConfig, @@ -24,6 +33,13 @@ pub fn create_password_hash( .map(|h| h.to_string()) } +/// Check a password hash, which was created with [`create_password_hash`] beforehand, +/// against a user-entered password string. +/// +/// Under no circumstances should the user be able to control the password hash. +/// +/// Returns true if the password is valid. +/// Returns false if the wrong password was supplied. pub fn check_hash(password: &str, hash: &str) -> eyre::Result { let argon2 = Argon2::new( Algorithm::Argon2id, diff --git a/src/store.rs b/src/store.rs index 196dcb8..0ae2db3 100644 --- a/src/store.rs +++ b/src/store.rs @@ -1,3 +1,7 @@ +//! # idCoop database access +//! +//! This file contains PostgreSQL queries. + use chrono::DateTime; use chrono::NaiveDateTime; use chrono::Utc; @@ -12,11 +16,20 @@ use crate::web::login::{ use crate::web::oauth_openid::application_session_access_token::ApplicationSession; /// Postgres-backed storage for IdCoop +/// A connection pool is in use. pub struct IdCoopStore { db_pool: PgPool, } impl IdCoopStore { + /// Connect to a Postgres database using the given Postgres connection URI. + /// + /// Once connected, apply database migrations to update the database. + /// + /// The simplest example of a connection URI is `postgres:`, + /// which will connect according to libpq environment variables and defaults. + /// + /// A description of the exact behaviour should be sought from the SQLx documentation: [`sqlx::postgres::PgConnectOptions`]. pub async fn connect(conn_str: &str) -> eyre::Result { // TODO(sqlite) We might support SQLite as well, so we might want to use an 'AnyPool' after // installing the requisite drivers here @@ -56,32 +69,53 @@ impl IdCoopStore { } } +/// Representation of a user #[derive(Debug)] pub struct User { + /// The unique ID for the user. + /// Should never change. pub user_id: Uuid, + /// The unique system name for the user. + /// It is ill-advised to change this but real-world constraints may require it. pub user_name: String, + /// The time when this user was created, in UTC. pub created_at_utc: NaiveDateTime, + /// The password hash of the user. See [`crate::passwords`]. pub password_hash: Option, + /// Whether the user is locked and is therefore not allowed to log in. pub locked: bool, } +/// Representation of the action of creating a user. pub struct CreateUser { + /// The system name for the user. pub user_login_name: String, + /// The password hash of the user. See [`crate::passwords`]. pub password_hash: Option, + /// Whether the user is locked and is therefore not allowed to log in. pub locked: bool, } +/// Basic information about a user pub struct UserInfo { + /// The unique system name for the user. + /// It is ill-advised to change this but real-world constraints may require it. pub user_name: String, + /// The unique ID for the user. + /// Should never change. pub user_id: Uuid, + /// Whether the user is locked and is therefore not allowed to log in. pub locked: bool, } +/// A wrapper around a database transaction with some database methods on it. pub struct IdCoopStoreTxn<'a, 'txn> { txn: &'a mut Transaction<'txn, Postgres>, } impl<'a, 'txn> IdCoopStoreTxn<'a, 'txn> { + /// Given the hash of an access token and a hash of a refresh token, + /// invalidates both the access and refresh tokens. pub async fn invalidate_access_token_by_hash( &mut self, access_token_hash: &[u8], @@ -147,6 +181,9 @@ impl<'a, 'txn> IdCoopStoreTxn<'a, 'txn> { Ok(Some(row.session_id)) } + /// Given its hash, issue a new access token. + /// + /// The access token is associated with the given session ID and expires at the given time. pub async fn issue_access_token( &mut self, access_token_hash: &[u8], @@ -167,6 +204,9 @@ impl<'a, 'txn> IdCoopStoreTxn<'a, 'txn> { Ok(()) } + /// Given its hash, issue a new refresh token. + /// + /// The refresh token is associated with the given session ID and expires at the given time. pub async fn issue_refresh_token( &mut self, refresh_token_hash: &[u8], @@ -199,6 +239,7 @@ impl<'a, 'txn> IdCoopStoreTxn<'a, 'txn> { Ok(r.user_id) } + /// Given a user's system name, return their user record if they exist. pub async fn lookup_user_by_name(&mut self, name: String) -> eyre::Result> { sqlx::query_as!( User, @@ -209,6 +250,7 @@ impl<'a, 'txn> IdCoopStoreTxn<'a, 'txn> { .await.context("failed to lookup user from DB") } + /// Set a given user's password. pub async fn change_user_password( &mut self, user_id: Uuid, @@ -225,6 +267,7 @@ impl<'a, 'txn> IdCoopStoreTxn<'a, 'txn> { Ok(()) } + /// Set the locked status of a user. pub async fn set_user_locked(&mut self, user_id: Uuid, locked: bool) -> eyre::Result<()> { sqlx::query!( "UPDATE users SET locked = $1 WHERE user_id = $2", @@ -237,6 +280,10 @@ impl<'a, 'txn> IdCoopStoreTxn<'a, 'txn> { Ok(()) } + /// Delete a user. + /// + /// Note that this can sometimes be undesirable, as downstream clients won't realise + /// the system name has been freed. pub async fn delete_user(&mut self, user_id: Uuid) -> eyre::Result<()> { sqlx::query!("DELETE FROM users WHERE user_id = $1", user_id) .execute(&mut **self.txn) @@ -245,6 +292,7 @@ impl<'a, 'txn> IdCoopStoreTxn<'a, 'txn> { Ok(()) } + /// List all users, in order of their user name, returning brief information about them. pub async fn list_user_info(&mut self) -> eyre::Result> { sqlx::query_as!( UserInfo, @@ -255,6 +303,10 @@ impl<'a, 'txn> IdCoopStoreTxn<'a, 'txn> { .context("failed to list users") } + /// Create a login (~'web UI') session for the given user, with the given hash of the login session's token. + /// + /// The XSRF secret is generated specifically for this session + /// and is used to prevent cross-site request forgery (XSRF) attacks. pub async fn create_login_session( &mut self, login_session_token_hash: &[u8; LOGIN_SESSION_TOKEN_HASH_BYTES], @@ -272,6 +324,7 @@ impl<'a, 'txn> IdCoopStoreTxn<'a, 'txn> { Ok(()) } + /// Given the login session's token hash, looks up its corresponding login session. pub async fn lookup_login_session( &mut self, login_session_token_hash: &[u8; LOGIN_SESSION_TOKEN_HASH_BYTES], @@ -301,6 +354,7 @@ impl<'a, 'txn> IdCoopStoreTxn<'a, 'txn> { })) } + /// Given an access token's hash, looks up the corresponding application session. pub async fn lookup_application_session( &mut self, access_token_hash: &[u8; 32], diff --git a/src/web.rs b/src/web.rs index 6a850c8..4475987 100644 --- a/src/web.rs +++ b/src/web.rs @@ -1,3 +1,13 @@ +//! # idCoop Web Interface +//! +//! This large module exposes the web/HTTP interface of idCoop. +//! +//! The web interface is used for: +//! +//! - users for logging in and managing their own account +//! - administrators for managing idCoop and their users +//! - applications making OpenID Connect requests to check users + use std::{ net::{IpAddr, SocketAddr}, sync::{ @@ -170,13 +180,17 @@ impl IntoResponse for InternalEyreError { } } +/// A convenient `Result` type for web requests. pub type WebResult = Result; /// Perform housekeeping on the ratelimiters every 60 seconds. const HOUSEKEEPING_EVERY: u32 = 60 >> 2; type IpRateLimiter = RateLimiter, QuantaClock>; + +/// Constructed rate limiters pub struct Ratelimiters { + /// The login ratelimiter for `/login` pub login: IpRateLimiter, /// Time of last housekeeping operation, since Unix epoch, but shifted right by 2 places @@ -184,6 +198,7 @@ pub struct Ratelimiters { } impl Ratelimiters { + /// Create a new bunch of rate limiters from configuration pub fn new_from_config(config: &RatelimitsConfig) -> Self { Self { login: Self::governor_from_config(&config.login), diff --git a/src/web/login.rs b/src/web/login.rs index a602dfe..93bfc25 100644 --- a/src/web/login.rs +++ b/src/web/login.rs @@ -1,3 +1,5 @@ +//! `/login` and related utilities + use std::{ collections::BTreeMap, net::IpAddr, @@ -46,6 +48,9 @@ pub struct PasswordHashInflightLimiter { } impl PasswordHashInflightLimiter { + /// Create a new password hashing in-flight limiter. + /// + /// - `limit` is the maximum number of allowed concurrent password hashing operations. pub fn new(limit: usize) -> Self { Self { overall: Semaphore::new(limit), @@ -53,6 +58,14 @@ impl PasswordHashInflightLimiter { } } + /// Call a function `f` that is subject to this limit. + /// The source IP address `src_ip` is the IP address of the client on whose behalf this computation + /// is being performed. + /// + /// A single source IP address will not be allowed to hog more than one slot. + /// + /// Some level of fairness is intended here: a requester can't add more requests to the queue + /// whilst one of its requests is being handled, so other requesters get a fair chance in the queue. pub async fn do_limited( &self, src_ip: IpAddr, @@ -115,10 +128,15 @@ pub const LOGIN_SESSION_TOKEN_HASH_BYTES: usize = 32; /// e.g. perhaps the persona should be a hash of the user's UUID? pub const LOGIN_SESSION_XSRF_SECRET_BYTES: usize = 32; +/// Represents a login session, which is effectively just a 'web UI' session for a user. pub struct LoginSession { + /// The system name of the user who is logged in to the idCoop web UI pub user_name: String, + /// The UUID of the user who is logged in to the idCoop web UI pub user_id: Uuid, + /// The ID of this login session pub login_session_id: i32, + /// The XSRF (cross-site request forgery) prevention secret of this login session pub xsrf_secret: [u8; LOGIN_SESSION_XSRF_SECRET_BYTES], } @@ -230,11 +248,22 @@ where } } +/// Query string parameters for `GET /login` #[derive(Deserialize)] pub struct LoginQuery { + /// Which page to follow after the login has completed? + /// This is the path and query component of a URI. + /// + /// Security-wise, this must be 'local' to idCoop; + /// i.e. we don't allow redirecting users to other places after they log in. then: Option, } +/// `GET /login?then=...` +/// +/// If logged in, redirects to `then` (if safe to do so) immediately. +/// +/// If not logged in, shows a login form. pub async fn get_login( current_session: Option, Query(query): Query, @@ -249,13 +278,21 @@ pub async fn get_login( } } +/// Body parameters for `POST /login` #[derive(Deserialize)] pub struct PostLoginForm { + /// User-supplied username username: String, + /// User-supplied password password: String, + /// Hidden XSRF token, used to check this request isn't being made from another site + /// (i.e. protects against cross-site request forgery) xsrf: String, } +/// Perform a password hash operation, even though it's not needed. +/// +/// This aims to prevent side-channel attacks such as timing attacks, to discern the existence of a user. fn dummy_password_hash( password: String, password_hash_config: &PasswordHashingConfig, @@ -266,10 +303,29 @@ fn dummy_password_hash( Ok(render_login_retry_form()) } +/// Render the form to retry a login. Currently not implemented. fn render_login_retry_form() -> Response { (StatusCode::UNAUTHORIZED, "Wrong username or password!").into_response() // TODO(ui): this should re-render the login form for another go } +/// `POST /login?then=...` +/// +/// Performs a login attempt. +/// +/// The login attempt is rate-limited, to prevent password brute-force attacks. +/// +/// The form is XSRF protected using [`crate::web::sessionless_xsrf`] +/// (given we don't have a working login session to use sessionful XSRF protection). +/// +/// We prevent requesters from trying to discern if given usernames exist or not; +/// whether or not a user exists, a password hash operation will be performed which takes +/// a certain amount of time. +/// +/// The response also does not point out that a username doesn't exist. +/// +/// If the login is successful, a new login session is created, which is stored in +/// browser cookies and the user is redirected to what they were trying to access +/// that needed the login in the first place. #[allow(clippy::too_many_arguments)] pub async fn post_login( Query(query): Query, @@ -377,6 +433,11 @@ pub async fn post_login( .into_response()) } +/// Make a redirect for once the user has logged in. +/// +/// We verify that the redirect URI is just a path and query component, +/// to ensure we don't redirect cross-domain or any silly nonsense like that. +/// This is because cross-domain redirects could be a security hazard. fn make_post_login_redirect(then: Option) -> Response { match then { Some(then) => match PathAndQuery::try_from(then) { diff --git a/src/web/oauth_openid.rs b/src/web/oauth_openid.rs index 2b4f56f..59c8ab9 100644 --- a/src/web/oauth_openid.rs +++ b/src/web/oauth_openid.rs @@ -1,3 +1,5 @@ +//! OpenID Connect / OAuth 2.1 support + use std::sync::Arc; use axum::{http::StatusCode, response::IntoResponse, Extension, Json}; @@ -22,6 +24,7 @@ struct UserInfoResponse { // TODO more... } +/// `GET /oidc/userinfo` pub async fn oidc_userinfo(application_session: ApplicationSession) -> impl IntoResponse { Json(UserInfoResponse { sub: application_session.user_id, @@ -35,6 +38,7 @@ struct JsonWebKeySet { keys: Vec, } +/// `GET /oidc/jwks` pub async fn oidc_jwks(Extension(secrets): Extension>) -> impl IntoResponse { let mut jwk = secrets.rsa_key_pair.to_jwk_public_key(); jwk.set_key_use("sig"); @@ -46,11 +50,12 @@ pub async fn oidc_jwks(Extension(secrets): Extension>) -> impl (StatusCode::OK, Json(key_set)) } +/// `GET /.well-known/webfinger` pub async fn oidc_discovery_webfinger() -> impl IntoResponse { (StatusCode::NOT_IMPLEMENTED, "NOT YET IMPLEMENTED") } -/// https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata +/// #[derive(Serialize)] struct OidcDiscoveryConfiguration { /// REQUIRED. URL using the https scheme with no query or fragment component that the OP asserts as its Issuer Identifier. @@ -112,6 +117,7 @@ struct OidcDiscoveryConfiguration { //claims_supported: Vec, } +/// `GET /.well-known/openid-configuration` pub async fn oidc_discovery_configuration( Extension(config): Extension>, ) -> impl IntoResponse { diff --git a/src/web/oauth_openid/application_session_access_token.rs b/src/web/oauth_openid/application_session_access_token.rs index 5deae2a..2d014ca 100644 --- a/src/web/oauth_openid/application_session_access_token.rs +++ b/src/web/oauth_openid/application_session_access_token.rs @@ -1,3 +1,5 @@ +//! Middleware for extracting application sessions (access tokens) from requests. + use std::sync::Arc; use async_trait::async_trait; @@ -14,9 +16,13 @@ use tracing::error; use crate::store::IdCoopStore; +/// Session between a user and an OpenID Connect client (application / relying party). pub struct ApplicationSession { + /// The ID of this session pub application_session_id: i32, + /// The system user name of the user pub user_name: String, + /// The user ID of the user pub user_id: Uuid, } @@ -61,7 +67,5 @@ where )); } } - - // TODO do we want a middleware to renew the cookie? } } diff --git a/src/web/oauth_openid/authorisation.rs b/src/web/oauth_openid/authorisation.rs index fb92c4e..b3a011e 100644 --- a/src/web/oauth_openid/authorisation.rs +++ b/src/web/oauth_openid/authorisation.rs @@ -1,3 +1,5 @@ +//! `/oidc/auth` + use std::sync::Arc; use axum::{ @@ -24,6 +26,7 @@ use crate::{ use super::ext_codes::VolatileCodeStore; +/// Query string parameters for the OIDC authorisation request. #[derive(Deserialize, Debug)] pub struct AuthorisationQuery { /// REQUIRED. OpenID Connect requests MUST contain the openid scope value. @@ -57,9 +60,10 @@ pub struct AuthorisationQuery { /// Sadly this is not specified in the spec, but 384 seems like a real-world practical value. /// (Some clients have been known to generate nonces at least 300 bytes long!) -/// https://github.com/openid-certification/oidctest/issues/134 +/// pub const MAX_NONCE_LENGTH: usize = 384; +/// `GET /oidc/auth` pub async fn oidc_authorisation( query: Result, QueryRejection>, login_session: Option, @@ -121,12 +125,14 @@ pub async fn oidc_authorisation( .await } +/// Body parameters for OIDC authorisation consent. #[derive(Deserialize)] pub struct PostConsentForm { action: String, xsrf: String, } +/// `POST /oidc/auth` pub async fn post_oidc_authorisation_consent( Query(query): Query, login_session: Option, @@ -406,6 +412,7 @@ fn extend_uri_with_params(uri: Uri, params: impl Serialize) -> eyre::Result /// OpenID Connect Core: https://openid.net/specs/openid-connect-core-1_0.html#AuthError /// (Not all errors from OpenID Connect Core were included.) #[derive(Copy, Clone, Serialize, Deserialize)] +#[allow(missing_docs)] pub enum AuthorisationRedirectableError { #[serde(rename = "invalid_request")] InvalidRequest, diff --git a/src/web/oauth_openid/ext_codes.rs b/src/web/oauth_openid/ext_codes.rs index 92a2794..a92a0d7 100644 --- a/src/web/oauth_openid/ext_codes.rs +++ b/src/web/oauth_openid/ext_codes.rs @@ -1,3 +1,5 @@ +//! Authorisation codes + use std::{ collections::{BTreeSet, HashMap}, fmt::Display, @@ -16,9 +18,13 @@ use tokio::sync::Notify; #[derive(Clone, Hash, PartialEq, Eq, Ord, PartialOrd)] pub struct AuthCode([u8; 24]); +/// Access token pub type AccessToken = [u8; 32]; +/// Hash of an access token pub type AccessTokenHash = [u8; 32]; +/// Refresh token pub type RefreshToken = [u8; 32]; +/// Hash of a refresh token pub type RefreshTokenHash = [u8; 32]; impl Display for AuthCode { @@ -47,26 +53,44 @@ impl FromStr for AuthCode { } impl AuthCode { + /// Generate a new authorisation code using the thread's RNG pub fn generate_new_random() -> Self { Self(rand::thread_rng().gen::<[u8; 24]>()) } } +/// Binding between an authorisation code (ready to be redeemed) +/// and both the user that authenticated to produce it +/// as well as the OpenID Connect client that it is for. pub struct AuthCodeBinding { + /// ID of the OpenID Connect client pub client_id: String, - // TODO(memory): would be nice to intern this, since it is likely to be repeated a lot... + /// Redirect URI that was used to send this authorisation code back to the OpenID Connect + /// client. The client must present the same Redirect URI when redeeming the code. + /// TODO(memory): would be nice to intern this, since it is likely to be repeated a lot... pub redirect_uri: String, + /// A 'number used once', as was specified in the authorisation request + /// Technically optional in the protocol but it's good practice to use one! pub nonce: Option, + /// The method for using the code challenge pub code_challenge_method: String, + /// The code challenge pub code_challenge: String, + /// The UUID of the user that authenticated to produce this authorisation code pub user_id: Uuid, + /// The login session ID of the user that authenticated. + /// The authorisation code will not be considered valid when the login session is no longer valid. pub user_login_session_id: i32, } +/// The representation of an auth code that was redeemed by a client. +/// The auth code is not allowed to be doubly redeemed, so by remembering this, +/// we can revoke the tokens. pub struct RedeemedAuthCode { /// The access token hash of whoever redeemed the auth code. /// Is used to invalidate the access token if an auth code is double-redeemed. access_token_hash: AccessTokenHash, + /// The refresh token hash of the redeemer. refresh_token_hash: RefreshTokenHash, } @@ -140,6 +164,8 @@ impl VolatileCodeStoreInner { } } +/// Stores authorisation codes, but is volatile (not persisted to disk). +/// This is OK because they should be used practically immediately anyway. #[derive(Clone)] pub struct VolatileCodeStore { poke: Arc, @@ -194,6 +220,9 @@ impl VolatileCodeStore { } } + /// Redeem an authorisation code. + /// + /// See [`CodeRedemption`] for a description of the possible outcomes here. pub fn redeem( &self, auth_code: &AuthCode, @@ -204,6 +233,7 @@ impl VolatileCodeStore { inner.redeem(auth_code, access_token_hash, refresh_token_hash) } + /// Add a new redeemable authorisation code. pub fn add_redeemable(&self, auth_code: AuthCode, binding: AuthCodeBinding, expires_at: u64) { let mut inner = self.inner.lock().unwrap(); inner.add_redeemable(auth_code, binding, expires_at); @@ -213,15 +243,24 @@ impl VolatileCodeStore { } } +/// Possible outcomes of an attempt to redeem an authorisation code. pub enum CodeRedemption { /// That auth code was not active Invalid, /// That auth code is valid and has been redeemed. - Valid { binding: AuthCodeBinding }, + Valid { + /// The binding, which describes what this authorisation code is for. + binding: AuthCodeBinding, + }, /// That auth code had already been redeemed: please invalidate the given access token and reject this redemption. Conflicted { - // TODO(refresh) what if the token was refreshed since? + /// The hash of the access token that already redeemed this. + /// The access token should be invalidated. access_token_to_invalidate: AccessTokenHash, + /// The hash of the refresh token that already redeemed this. + /// The refresh token should be invalidated. + /// + /// TODO(refresh) what if the token was refreshed since? refresh_token_to_invalidate: RefreshTokenHash, }, } diff --git a/src/web/oauth_openid/token.rs b/src/web/oauth_openid/token.rs index e730fdc..7a00e57 100644 --- a/src/web/oauth_openid/token.rs +++ b/src/web/oauth_openid/token.rs @@ -1,3 +1,5 @@ +//! `/oidc/token` + use std::{ str::FromStr, sync::Arc, @@ -35,6 +37,7 @@ use super::ext_codes::{ VolatileCodeStore, }; +/// Body parameters for `POST /token` #[derive(Deserialize)] pub struct TokenFormParams { grant_type: String, @@ -45,7 +48,11 @@ pub struct TokenFormParams { // client_id: Option, } -// TODO auth_header can be one alternative +/// `POST /oidc/token` +/// +/// OpenID Connect clients call this to exchange an authorisation code they received for an access token. +/// +/// TODO auth_header can be one alternative auth method pub async fn oidc_token( basic_auth: Option>>, Extension(config): Extension>, @@ -399,6 +406,10 @@ fn make_id_token(id_token: IdToken, secrets: &SecretConfig) -> eyre::Result Option { match method { "plain" => Some(verifier.to_owned()), @@ -413,6 +424,11 @@ pub fn compute_code_challenge(method: &str, verifier: &str) -> Option { } } +/// OpenID Connect ID Token +/// +/// This is the payload of a JWT that is passed to the client/application. +/// +/// TODO(doc): provide spec reference for this. #[derive(Serialize)] pub struct IdToken { /// REQUIRED. Issuer Identifier for the Issuer of the response. diff --git a/src/web/sessionless_xsrf.rs b/src/web/sessionless_xsrf.rs index c317ccb..c06c4db 100644 --- a/src/web/sessionless_xsrf.rs +++ b/src/web/sessionless_xsrf.rs @@ -12,6 +12,7 @@ use subtle::ConstantTimeEq; use time::Duration; use tower_cookies::{Cookie, Cookies}; +/// Name of the cookie for the sessionless cross-site request forgery prevention cookie. pub const COOKIE_NAME: &str = "__Host-SessionlessXsrf"; /// Gets the Sessionless XSRF token to put into a form request