Improve code documentation
Signed-off-by: Olivier 'reivilibre <olivier@librepush.net>
This commit is contained in:
parent
9bbae5411b
commit
8121a8996e
@ -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<String, OidcClientConfiguration>,
|
||||
}
|
||||
|
||||
/// 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<SocketAddr>,
|
||||
|
||||
/// 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 <https://docs.rs/sqlx-postgres/0.7.2/sqlx_postgres/struct.PgConnectOptions.html>
|
||||
#[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<String>,
|
||||
}
|
||||
|
||||
/// 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<String, String>,
|
||||
}
|
||||
|
||||
/// 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<Self> {
|
||||
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,
|
||||
}
|
||||
|
||||
|
@ -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;
|
||||
|
@ -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<bool> {
|
||||
let argon2 = Argon2::new(
|
||||
Algorithm::Argon2id,
|
||||
|
54
src/store.rs
54
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<Self> {
|
||||
// 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<String>,
|
||||
/// 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<String>,
|
||||
/// 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<Option<User>> {
|
||||
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<Vec<UserInfo>> {
|
||||
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],
|
||||
|
15
src/web.rs
15
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<T> = Result<T, InternalEyreError>;
|
||||
|
||||
/// Perform housekeeping on the ratelimiters every 60 seconds.
|
||||
const HOUSEKEEPING_EVERY: u32 = 60 >> 2;
|
||||
|
||||
type IpRateLimiter = RateLimiter<IpAddr, DashMapStateStore<IpAddr>, 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),
|
||||
|
@ -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<R>(
|
||||
&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<String>,
|
||||
}
|
||||
|
||||
/// `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<LoginSession>,
|
||||
Query(query): Query<LoginQuery>,
|
||||
@ -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<LoginQuery>,
|
||||
@ -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<String>) -> Response {
|
||||
match then {
|
||||
Some(then) => match PathAndQuery::try_from(then) {
|
||||
|
@ -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<Jwk>,
|
||||
}
|
||||
|
||||
/// `GET /oidc/jwks`
|
||||
pub async fn oidc_jwks(Extension(secrets): Extension<Arc<SecretConfig>>) -> 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<Arc<SecretConfig>>) -> 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
|
||||
/// <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<String>,
|
||||
}
|
||||
|
||||
/// `GET /.well-known/openid-configuration`
|
||||
pub async fn oidc_discovery_configuration(
|
||||
Extension(config): Extension<Arc<Configuration>>,
|
||||
) -> impl IntoResponse {
|
||||
|
@ -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?
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
/// <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<Query<AuthorisationQuery>, QueryRejection>,
|
||||
login_session: Option<LoginSession>,
|
||||
@ -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<AuthorisationQuery>,
|
||||
login_session: Option<LoginSession>,
|
||||
@ -406,6 +412,7 @@ fn extend_uri_with_params(uri: Uri, params: impl Serialize) -> eyre::Result<Uri>
|
||||
/// 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,
|
||||
|
@ -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<String>,
|
||||
/// 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<Notify>,
|
||||
@ -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,
|
||||
},
|
||||
}
|
||||
|
@ -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<String>,
|
||||
}
|
||||
|
||||
// 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<TypedHeader<Authorization<Basic>>>,
|
||||
Extension(config): Extension<Arc<Configuration>>,
|
||||
@ -399,6 +406,10 @@ fn make_id_token(id_token: IdToken, secrets: &SecretConfig) -> eyre::Result<Stri
|
||||
.context("failed to sign ID Token")
|
||||
}
|
||||
|
||||
/// Given a code challenge method and the code challenge verifier, compute
|
||||
/// the 'code challenge'.
|
||||
///
|
||||
/// TODO(doc): provide spec reference for this
|
||||
pub fn compute_code_challenge(method: &str, verifier: &str) -> Option<String> {
|
||||
match method {
|
||||
"plain" => Some(verifier.to_owned()),
|
||||
@ -413,6 +424,11 @@ pub fn compute_code_challenge(method: &str, verifier: &str) -> Option<String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
|
@ -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
|
||||
|
Loading…
x
Reference in New Issue
Block a user