Get to the point where token exchange is possible with the dummy account
This commit is contained in:
parent
2fda23dac1
commit
2acdf96813
2353
Cargo.lock
generated
2353
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
17
Cargo.toml
17
Cargo.toml
@ -8,14 +8,29 @@ edition = "2021"
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[dependencies]
|
||||
axum = { version = "0.6.20", features = ["tracing"] }
|
||||
async-trait = "0.1.74"
|
||||
axum = { version = "0.6.20", features = ["tracing", "macros", "headers"] }
|
||||
axum_csrf = { version = "0.7.2", features = ["layer"] }
|
||||
base64 = "0.21.5"
|
||||
chrono = "0.4.31"
|
||||
clap = { version = "4.4.6", features = ["derive"] }
|
||||
confique = { version = "0.2.4", features = ["toml"], default-features = false }
|
||||
eyre = "0.6.8"
|
||||
futures = "0.3.29"
|
||||
hornbeam = "0.0.1"
|
||||
josekit = "0.8.4"
|
||||
metrics = "0.21.1"
|
||||
metrics-exporter-prometheus = "0.12.1"
|
||||
metrics-process = "1.0.12"
|
||||
rand = "0.8.5"
|
||||
serde = { version = "1.0.188", features = ["derive"] }
|
||||
serde_json = "1.0.108"
|
||||
serde_urlencoded = "0.7.1"
|
||||
sha2 = "0.10.8"
|
||||
sqlx = { version = "0.7.2", features = ["postgres", "runtime-tokio-rustls", "macros", "migrate"] }
|
||||
subtle = "2.5.0"
|
||||
time = "0.3.30"
|
||||
tokio = { version = "1.33.0", features = ["rt", "macros"] }
|
||||
tower-http = { version = "0.4.4", features = ["trace"] }
|
||||
tracing = "0.1.37"
|
||||
tracing-subscriber = { version = "0.3.17", features = ["env-filter"] }
|
||||
|
5
build.rs
Normal file
5
build.rs
Normal file
@ -0,0 +1,5 @@
|
||||
// generated by `sqlx migrate build-script`
|
||||
fn main() {
|
||||
// trigger recompilation when a new migration is added
|
||||
println!("cargo:rerun-if-changed=migrations");
|
||||
}
|
17
config.toml
Normal file
17
config.toml
Normal file
@ -0,0 +1,17 @@
|
||||
[listen]
|
||||
bind = "127.0.0.1:8080"
|
||||
public_base_uri = "http://127.0.0.1:8080"
|
||||
|
||||
[oidc]
|
||||
issuer = "http://127.0.0.1:8080"
|
||||
|
||||
rsa_keypair = "keypair.pem"
|
||||
|
||||
[oidc.clients.x]
|
||||
name = "some service"
|
||||
redirect_uris = ["http://localhost:9876/callback"]
|
||||
allow_user_classes = ["user"]
|
||||
secret = "lol"
|
||||
|
||||
[postgres]
|
||||
connect = "postgres:"
|
@ -75,6 +75,14 @@
|
||||
|
||||
# Useful for poking at the Postgres database
|
||||
pkgs.postgresql
|
||||
|
||||
# Might be useful as an example OAuth 2 / OIDC client
|
||||
pkgs.oauth2c
|
||||
|
||||
# Useful for generating RSA keypairs
|
||||
# also needed for our JWTs
|
||||
pkgs.openssl
|
||||
pkgs.pkg-config
|
||||
];
|
||||
|
||||
env = {
|
||||
|
54
migrations/20231030092146_users_and_roles.sql
Normal file
54
migrations/20231030092146_users_and_roles.sql
Normal file
@ -0,0 +1,54 @@
|
||||
-- Create tables to store users, roles and the assignment of roles to users.
|
||||
|
||||
CREATE TABLE users (
|
||||
user_id UUID PRIMARY KEY NOT NULL,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
password_hash BYTEA NOT NULL,
|
||||
created_at_utc TIMESTAMP NOT NULL,
|
||||
last_login_utc TIMESTAMP,
|
||||
locked BOOLEAN NOT NULL DEFAULT FALSE
|
||||
);
|
||||
|
||||
COMMENT ON TABLE users IS 'All users known to the system.';
|
||||
|
||||
COMMENT ON COLUMN users.user_id IS 'A UUID given to the user. Stays with the user even after the user is renamed.';
|
||||
|
||||
COMMENT ON COLUMN users.username IS 'A textual user name for the user. Might be kept stable but some deployments may support renames.';
|
||||
|
||||
COMMENT ON COLUMN users.password_hash IS 'Hash of the user''s password.';
|
||||
|
||||
COMMENT ON COLUMN users.created_at_utc IS 'When the user was created, in UTC.';
|
||||
|
||||
COMMENT ON COLUMN users.last_login_utc IS 'When the user last logged in, in UTC.';
|
||||
|
||||
COMMENT ON COLUMN users.locked IS 'Whether the user has been locked.';
|
||||
|
||||
CREATE TABLE roles (
|
||||
role_id TEXT PRIMARY KEY NOT NULL,
|
||||
role_name TEXT NOT NULL
|
||||
);
|
||||
|
||||
COMMENT ON TABLE roles IS 'A role is akin to a group or a permission. Users can be restricted depending on what roles they have.';
|
||||
|
||||
COMMENT ON COLUMN roles.role_id IS 'Unique code for the role. [a-zA-Z0-9_:-]+';
|
||||
|
||||
COMMENT ON COLUMN roles.role_name IS 'Human-friendly name for the role.';
|
||||
|
||||
CREATE TABLE users_roles (
|
||||
user_id UUID NOT NULL,
|
||||
role_id TEXT NOT NULL,
|
||||
|
||||
granted_at_utc TIMESTAMP NOT NULL,
|
||||
|
||||
PRIMARY KEY (user_id, role_id),
|
||||
FOREIGN KEY (user_id) REFERENCES users(user_id),
|
||||
FOREIGN KEY (role_id) REFERENCES roles(role_id)
|
||||
);
|
||||
|
||||
COMMENT ON TABLE users_roles IS 'Association of users and their roles.';
|
||||
|
||||
COMMENT ON COLUMN users_roles.user_id IS 'User ID that has been assigned a role.';
|
||||
|
||||
COMMENT ON COLUMN users_roles.role_id IS 'Role ID that has been assigned to the user.';
|
||||
|
||||
COMMENT ON COLUMN users_roles.granted_at_utc IS 'When the role was assigned to the user.';
|
@ -1,9 +1,13 @@
|
||||
use std::sync::Arc;
|
||||
use std::{net::SocketAddr, path::PathBuf};
|
||||
|
||||
use clap::Parser;
|
||||
use confique::{Config, Partial};
|
||||
use eyre::{bail, Context};
|
||||
use idcoop::config::SecretConfig;
|
||||
use idcoop::store::IdCoopStore;
|
||||
use idcoop::{config::Configuration, web};
|
||||
use tracing_subscriber::fmt::format::FmtSpan;
|
||||
use tracing_subscriber::layer::SubscriberExt;
|
||||
use tracing_subscriber::util::SubscriberInitExt;
|
||||
|
||||
@ -47,7 +51,7 @@ async fn main() -> eyre::Result<()> {
|
||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| "idcoop=debug,info".into()),
|
||||
)
|
||||
.with(tracing_subscriber::fmt::layer())
|
||||
.with(tracing_subscriber::fmt::layer().with_span_events(FmtSpan::CLOSE))
|
||||
.init();
|
||||
|
||||
let options = Options::parse();
|
||||
@ -66,14 +70,19 @@ async fn main() -> eyre::Result<()> {
|
||||
|
||||
partial = partial.with_fallback(Partial::default_values());
|
||||
|
||||
let config = Configuration::from_partial(partial).context("failed to load config")?;
|
||||
let config: Configuration =
|
||||
Configuration::from_partial(partial).context("failed to load config")?;
|
||||
|
||||
match options.subcommand {
|
||||
Subcommand::Serve { bind } => {
|
||||
let Some(bind) = bind.or(config.listen.bind) else {
|
||||
bail!("neither --bind nor listen.bind config option set. Please specify a bind address!")
|
||||
};
|
||||
web::serve(bind).await?
|
||||
let store = IdCoopStore::connect(&config.postgres.connect)
|
||||
.await
|
||||
.context("Failed to connect to Postgres")?;
|
||||
let secrets = SecretConfig::try_new(&config).await?;
|
||||
web::serve(bind, Arc::new(store), Arc::new(config), Arc::new(secrets)).await?
|
||||
}
|
||||
Subcommand::HashPassword {} => todo!(),
|
||||
Subcommand::UserAdd {} => todo!(),
|
||||
|
@ -1,6 +1,13 @@
|
||||
use std::net::SocketAddr;
|
||||
use std::{
|
||||
collections::{BTreeMap, BTreeSet},
|
||||
net::SocketAddr,
|
||||
path::PathBuf,
|
||||
};
|
||||
|
||||
use confique::Config;
|
||||
use eyre::Context;
|
||||
use josekit::jwk::alg::rsa::RsaKeyPair;
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Config)]
|
||||
pub struct Configuration {
|
||||
@ -9,13 +16,79 @@ pub struct Configuration {
|
||||
|
||||
#[config(nested)]
|
||||
pub postgres: PostgresConfiguration,
|
||||
|
||||
#[config(nested)]
|
||||
pub oidc: OidcConfiguration,
|
||||
}
|
||||
|
||||
#[derive(Config)]
|
||||
pub struct OidcConfiguration {
|
||||
/// Our identity as an OAuth 2.1 / OIDC issuer.
|
||||
/// Example: `https://id.example.com`.
|
||||
/// Should really be unique.
|
||||
pub issuer: String,
|
||||
|
||||
pub rsa_keypair: PathBuf,
|
||||
|
||||
/// Map from client_id to OpenID Connect Relying Party (client) configuration.
|
||||
/// Can't be specified in environment variables.
|
||||
pub clients: BTreeMap<String, OidcClientConfiguration>,
|
||||
}
|
||||
|
||||
#[derive(Config)]
|
||||
pub struct ListenConfiguration {
|
||||
#[config(env = "IC_LISTEN_BIND")]
|
||||
pub bind: Option<SocketAddr>,
|
||||
|
||||
pub public_base_uri: String,
|
||||
}
|
||||
|
||||
#[derive(Config)]
|
||||
pub struct PostgresConfiguration {}
|
||||
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
|
||||
#[config(env = "IC_POSTGRES")]
|
||||
pub connect: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct OidcClientConfiguration {
|
||||
/// Whether to skip the consent screen. Usually desirable for SSO where you control the service and the identity server.
|
||||
/// Defaults to false.
|
||||
#[serde(default)]
|
||||
pub skip_consent: bool,
|
||||
|
||||
/// OIDC/OAuth2 redirect_uri for the application. Can specify multiple if really needed for some reason.
|
||||
pub redirect_uris: BTreeSet<String>,
|
||||
|
||||
/// Friendly name for the service. Will be shown in the user interface.
|
||||
pub name: String,
|
||||
|
||||
/// TODO User classes to allow
|
||||
/// Must be explicit because it is security sensitive and we don't want a typo to fail open.
|
||||
/// User classes are defined by the admin but at the very least includes 'active' and 'not active' (implied if no 'active' class set).
|
||||
/// (Design subject to change)
|
||||
pub allow_user_classes: Vec<String>,
|
||||
|
||||
/// The shared secret for the client.
|
||||
/// TODO
|
||||
/// - We should consider supporting other auth methods in the future.
|
||||
/// - We should allow specifying this out of the main configuration somehow...?
|
||||
pub secret: String,
|
||||
}
|
||||
|
||||
pub struct SecretConfig {
|
||||
pub rsa_key_pair: RsaKeyPair,
|
||||
}
|
||||
|
||||
impl SecretConfig {
|
||||
pub async fn try_new(config: &Configuration) -> eyre::Result<Self> {
|
||||
let rsa_keypair_bytes = tokio::fs::read(&config.oidc.rsa_keypair)
|
||||
.await
|
||||
.context("failed to load RSA private key")?;
|
||||
let rsa_key_pair =
|
||||
RsaKeyPair::from_pem(&rsa_keypair_bytes).context("Failed to decode RSA key pair")?;
|
||||
Ok(Self { rsa_key_pair })
|
||||
}
|
||||
}
|
||||
|
@ -1,2 +1,3 @@
|
||||
pub mod config;
|
||||
pub mod store;
|
||||
pub mod web;
|
||||
|
67
src/store.rs
Normal file
67
src/store.rs
Normal file
@ -0,0 +1,67 @@
|
||||
use eyre::Context;
|
||||
use futures::future::BoxFuture;
|
||||
use sqlx::{Connection, PgPool, Postgres, Transaction};
|
||||
use tracing::error;
|
||||
|
||||
/// Postgres-backed storage for IdCoop
|
||||
pub struct IdCoopStore {
|
||||
db_pool: PgPool,
|
||||
}
|
||||
|
||||
impl IdCoopStore {
|
||||
pub async fn connect(conn_str: &str) -> eyre::Result<Self> {
|
||||
// TODO We might support SQLite as well, so we might want to use an 'AnyPool' after
|
||||
// installing the requisite drivers here
|
||||
let db_pool = PgPool::connect(conn_str)
|
||||
.await
|
||||
.context("failed to establish Postgres connection")?;
|
||||
|
||||
sqlx::migrate!()
|
||||
.run(&db_pool)
|
||||
.await
|
||||
.context("failed to apply database migrations")?;
|
||||
|
||||
Ok(Self { db_pool })
|
||||
}
|
||||
|
||||
/// Run a transaction against the database.
|
||||
/// As long as you return an eyre::Result, the return type is up to the caller.
|
||||
///
|
||||
/// If the result is Ok(R), the transaction will be committed.
|
||||
/// If the result is Err(R), the transaction will NOT be committed.
|
||||
pub async fn txn<F, R>(&self, f: F) -> eyre::Result<R>
|
||||
where
|
||||
for<'a, 'b> F: FnOnce(IdCoopStoreTxn<'a, 'b>) -> BoxFuture<'a, eyre::Result<R>>,
|
||||
{
|
||||
let mut conn = self
|
||||
.db_pool
|
||||
.acquire()
|
||||
.await
|
||||
.context("failed to acquire connection")?;
|
||||
let mut txn = conn.begin().await.context("failed to start transaction")?;
|
||||
|
||||
let r = f(IdCoopStoreTxn { txn: &mut txn }).await?;
|
||||
|
||||
txn.commit().await.context("failed to commit transaction")?;
|
||||
|
||||
Ok(r)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct IdCoopStoreTxn<'a, 'txn> {
|
||||
txn: &'a mut Transaction<'txn, Postgres>,
|
||||
}
|
||||
|
||||
impl<'a, 'txn> IdCoopStoreTxn<'a, 'txn> {
|
||||
pub async fn invalidate_access_token_by_hash(
|
||||
&mut self,
|
||||
access_token_hash: String,
|
||||
) -> eyre::Result<()> {
|
||||
todo!()
|
||||
}
|
||||
|
||||
pub async fn issue_access_token(&mut self, access_token_hash: String) -> eyre::Result<()> {
|
||||
error!("TODO: issue access token");
|
||||
Ok(())
|
||||
}
|
||||
}
|
112
src/web.rs
112
src/web.rs
@ -1,13 +1,93 @@
|
||||
use std::net::SocketAddr;
|
||||
use std::{net::SocketAddr, sync::Arc};
|
||||
|
||||
use axum::{extract::ConnectInfo, response::IntoResponse, routing::get, Router};
|
||||
use axum::{
|
||||
extract::ConnectInfo,
|
||||
http::{StatusCode, Uri},
|
||||
response::{IntoResponse, Response},
|
||||
routing::{get, post},
|
||||
Extension, Router,
|
||||
};
|
||||
use axum_csrf::{CsrfConfig, CsrfLayer};
|
||||
use eyre::Context;
|
||||
use hornbeam::{initialise_template_manager, make_template_manager};
|
||||
use rand::{distributions::Alphanumeric, thread_rng, Rng};
|
||||
use tower_http::trace::TraceLayer;
|
||||
use tracing::info;
|
||||
|
||||
use crate::{
|
||||
config::{Configuration, SecretConfig},
|
||||
store::IdCoopStore,
|
||||
web::{
|
||||
login::{get_login, post_login},
|
||||
oauth_openid::{
|
||||
authorisation::{oidc_authorisation, post_oidc_authorisation_consent},
|
||||
ext_codes::VolatileCodeStore,
|
||||
oidc_discovery_configuration, oidc_discovery_webfinger, oidc_jwks, oidc_userinfo,
|
||||
token::oidc_token,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
pub mod login;
|
||||
pub mod oauth_openid;
|
||||
|
||||
make_template_manager! {
|
||||
static ref TEMPLATING = {
|
||||
default_locale: "en",
|
||||
};
|
||||
}
|
||||
|
||||
/// Serves, on the bind address specified, the HTTP service
|
||||
/// including a user interface and any OAuth, OpenID Connect and custom APIs.
|
||||
pub async fn serve(bind: SocketAddr) -> eyre::Result<()> {
|
||||
let router = Router::new().route("/", get(hello));
|
||||
pub async fn serve(
|
||||
bind: SocketAddr,
|
||||
store: Arc<IdCoopStore>,
|
||||
config: Arc<Configuration>,
|
||||
secrets: Arc<SecretConfig>,
|
||||
) -> eyre::Result<()> {
|
||||
initialise_template_manager!(TEMPLATING);
|
||||
|
||||
let router = Router::new()
|
||||
.route("/", get(hello))
|
||||
.route("/login", get(get_login).post(post_login))
|
||||
.route("/oidc/token", post(oidc_token))
|
||||
.route("/oidc/userinfo", get(oidc_userinfo))
|
||||
.route(
|
||||
"/oidc/auth",
|
||||
get(oidc_authorisation).post(post_oidc_authorisation_consent),
|
||||
)
|
||||
.route("/oidc/jwks", get(oidc_jwks))
|
||||
.route("/.well-known/webfinger", get(oidc_discovery_webfinger))
|
||||
.route(
|
||||
"/.well-known/openid-configuration",
|
||||
get(oidc_discovery_configuration),
|
||||
)
|
||||
.layer(CsrfLayer::new(
|
||||
CsrfConfig::new()
|
||||
.with_lifetime(time::Duration::days(1))
|
||||
.with_cookie_name("CSRF")
|
||||
.with_cookie_len(64)
|
||||
.with_cookie_path("/")
|
||||
.with_http_only(true)
|
||||
.with_cookie_same_site(axum_csrf::SameSite::Strict)
|
||||
.with_secure(true)
|
||||
.with_key(Some(axum_csrf::Key::generate()))
|
||||
// the salt is unimportant as the CSRF token in the cookie is encrypted by the above key anyway
|
||||
// even without that it wouldn't really matter: the only point of the CSRF cookie is to prevent someone (not the recipient) from guessing it
|
||||
// but it's here, so do it anyway?
|
||||
.with_salt(
|
||||
thread_rng()
|
||||
.sample_iter(&Alphanumeric)
|
||||
.take(32)
|
||||
.map(char::from)
|
||||
.collect::<String>(),
|
||||
),
|
||||
))
|
||||
.layer(TraceLayer::new_for_http())
|
||||
.layer(Extension(config))
|
||||
.layer(Extension(secrets))
|
||||
.layer(Extension(store))
|
||||
.layer(Extension(VolatileCodeStore::new()));
|
||||
|
||||
info!("Listening on {bind:?}");
|
||||
axum::Server::try_bind(&bind)
|
||||
@ -18,6 +98,26 @@ pub async fn serve(bind: SocketAddr) -> eyre::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn hello(ConnectInfo(c): ConnectInfo<SocketAddr>) -> impl IntoResponse {
|
||||
format!("hi {c:?}")
|
||||
async fn hello(ConnectInfo(_): ConnectInfo<SocketAddr>) -> impl IntoResponse {
|
||||
"idCoop. TODO landing page"
|
||||
}
|
||||
|
||||
fn make_login_redirect(then_uri: Uri) -> Response {
|
||||
// Use a 302 redirect to the login page
|
||||
// This is quite common
|
||||
let login_page_uri = match then_uri.path_and_query() {
|
||||
Some(path_and_query) => {
|
||||
let encoded_params = serde_urlencoded::to_string([("then", path_and_query.as_str())])
|
||||
.expect("no reason that URL encode should fail");
|
||||
format!("/login?{}", encoded_params)
|
||||
}
|
||||
None => "/login".to_owned(),
|
||||
};
|
||||
|
||||
(
|
||||
StatusCode::FOUND,
|
||||
[("Location", &login_page_uri)],
|
||||
"You need to login. Sending you to the login page.",
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
115
src/web/login.rs
Normal file
115
src/web/login.rs
Normal file
@ -0,0 +1,115 @@
|
||||
use async_trait::async_trait;
|
||||
use axum::{
|
||||
extract::{FromRequestParts, Query},
|
||||
headers::Cookie,
|
||||
http::{request::Parts, uri::PathAndQuery, HeaderValue, StatusCode},
|
||||
response::{Html, IntoResponse, Response},
|
||||
Form, TypedHeader,
|
||||
};
|
||||
use axum_csrf::CsrfToken;
|
||||
use serde::Deserialize;
|
||||
|
||||
pub struct LoginSession {
|
||||
pub username: String,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl<S> FromRequestParts<S> for LoginSession
|
||||
where
|
||||
S: Send + Sync,
|
||||
{
|
||||
type Rejection = (StatusCode, &'static str);
|
||||
|
||||
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
|
||||
let cookie_val =
|
||||
if let Ok(cookies) = TypedHeader::<Cookie>::from_request_parts(parts, state).await {
|
||||
cookies.get("dummy").map(str::to_owned)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if cookie_val.is_some() {
|
||||
Ok(LoginSession {
|
||||
username: "dummy".to_owned(),
|
||||
})
|
||||
|
||||
// TODO do we want a middleware to renew the cookie?
|
||||
} else {
|
||||
Err((
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"Currently do not support login sessions",
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct LoginQuery {
|
||||
then: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn get_login(
|
||||
current_session: Option<LoginSession>,
|
||||
Query(query): Query<LoginQuery>,
|
||||
csrf: CsrfToken,
|
||||
) -> Response {
|
||||
match current_session {
|
||||
Some(_session) => make_post_login_redirect(query.then),
|
||||
None => {
|
||||
let csrf_token = csrf
|
||||
.authenticity_token()
|
||||
.expect("no reason a CSRF token should fail to be generated");
|
||||
(csrf, Html(format!("<form method='POST'><input type='hidden' name='csrf' value='{}'><button type='submit'>click here to login as dummy user</button></form>", csrf_token))).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct PostLoginForm {
|
||||
csrf: String,
|
||||
}
|
||||
|
||||
pub async fn post_login(
|
||||
Query(query): Query<LoginQuery>,
|
||||
csrf: CsrfToken,
|
||||
Form(form): Form<PostLoginForm>,
|
||||
) -> Response {
|
||||
if csrf.verify(&form.csrf).is_err() {
|
||||
// Invalid CSRF token: try again
|
||||
return get_login(None, Query(query), csrf).await;
|
||||
}
|
||||
let expiry_date = chrono::Utc::now() + chrono::Duration::days(500);
|
||||
let expiry_date_rfc1123 = expiry_date.format("%a, %d %b %Y %H:%M:%S GMT");
|
||||
(
|
||||
[(
|
||||
"Set-Cookie",
|
||||
HeaderValue::from_str(&format!(
|
||||
"dummy=1; Path=/; HttpOnly; SameSite=Strict; Secure; Expires={}",
|
||||
expiry_date_rfc1123
|
||||
))
|
||||
.expect("no reason we should fail to make a cookie"),
|
||||
)],
|
||||
make_post_login_redirect(query.then),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
fn make_post_login_redirect(then: Option<String>) -> Response {
|
||||
match then {
|
||||
Some(then) => match PathAndQuery::try_from(then) {
|
||||
Ok(path_and_query) => (
|
||||
StatusCode::FOUND,
|
||||
[("Location", path_and_query.as_str())],
|
||||
"Logged in. Redirecting you back to what you were doing.",
|
||||
)
|
||||
.into_response(),
|
||||
Err(_) => (StatusCode::BAD_REQUEST, "Bad redirect URL after login.").into_response(),
|
||||
},
|
||||
None => (
|
||||
StatusCode::FOUND,
|
||||
[("Location", "/")],
|
||||
"Logged in. Sending you to the home page.",
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
130
src/web/oauth_openid.rs
Normal file
130
src/web/oauth_openid.rs
Normal file
@ -0,0 +1,130 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::{http::StatusCode, response::IntoResponse, Extension, Json};
|
||||
use josekit::jwk::{Jwk, JwkSet};
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::config::{Configuration, SecretConfig};
|
||||
|
||||
pub mod authorisation;
|
||||
pub mod ext_codes;
|
||||
pub mod token;
|
||||
|
||||
pub async fn oidc_userinfo() -> impl IntoResponse {
|
||||
// TODO CORS OK
|
||||
(StatusCode::NOT_IMPLEMENTED, "NOT YET IMPLEMENTED")
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct JsonWebKeySet {
|
||||
keys: Vec<Jwk>,
|
||||
}
|
||||
|
||||
pub async fn oidc_jwks(Extension(secrets): Extension<Arc<SecretConfig>>) -> impl IntoResponse {
|
||||
// TODO CORS OK
|
||||
|
||||
let mut jwk = secrets.rsa_key_pair.to_jwk_public_key();
|
||||
jwk.set_key_use("sig");
|
||||
jwk.set_key_id("thekey");
|
||||
jwk.set_algorithm("RS256");
|
||||
|
||||
let key_set = JsonWebKeySet { keys: vec![jwk] };
|
||||
|
||||
(StatusCode::OK, Json(key_set))
|
||||
}
|
||||
|
||||
pub async fn oidc_discovery_webfinger() -> impl IntoResponse {
|
||||
// TODO CORS OK
|
||||
(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.
|
||||
/// If Issuer discovery is supported (see Section 2), this value MUST be identical to the issuer value returned by WebFinger.
|
||||
/// This also MUST be identical to the iss Claim value in ID Tokens issued from this Issuer.
|
||||
issuer: String,
|
||||
|
||||
/// REQUIRED. URL of the OP's OAuth 2.0 Authorization Endpoint
|
||||
authorization_endpoint: String,
|
||||
|
||||
/// URL of the OP's OAuth 2.0 Token Endpoint
|
||||
token_endpoint: String,
|
||||
|
||||
/// RECOMMENDED. URL of the OP's UserInfo Endpoint [OpenID.Core]. This URL MUST use the https scheme and MAY contain port, path, and query parameter components.
|
||||
userinfo_endpoint: String,
|
||||
|
||||
/// REQUIRED. URL of the OP's JSON Web Key Set [JWK] document.
|
||||
/// This contains the signing key(s) the RP uses to validate signatures from the OP.
|
||||
/// The JWK Set MAY also contain the Server's encryption key(s), which are used by RPs to encrypt requests to the Server.
|
||||
/// When both signing and encryption keys are made available, a use (Key Use) parameter value is REQUIRED for all keys in the referenced JWK Set to indicate each key's intended usage
|
||||
/// Although some algorithms allow the same key to be used for both signatures and encryption, doing so is NOT RECOMMENDED, as it is less secure.
|
||||
/// The JWK x5c parameter MAY be used to provide X.509 representations of keys provided. When used, the bare key values MUST still be present and MUST match those in the certificate.
|
||||
jwks_uri: String,
|
||||
|
||||
// RECOMMENDED. URL of the OP's Dynamic Client Registration Endpoint
|
||||
// We don't support Dynamic Client Registration in idCoop (yet?), so no point having this.
|
||||
//registration_endpoint: String,
|
||||
/// RECOMMENDED. JSON array containing a list of the OAuth 2.0 [RFC6749] scope values that this server supports
|
||||
/// The server MUST support the openid scope value.
|
||||
/// Servers MAY choose not to advertise some supported scope values even when this parameter is used, although those defined in [OpenID.Core] SHOULD be listed, if supported.
|
||||
scopes_supported: Vec<String>,
|
||||
|
||||
/// REQUIRED. JSON array containing a list of the OAuth 2.0 response_type values that this OP supports.
|
||||
/// Dynamic OpenID Providers MUST support the code, id_token, and the token id_token Response Type values.
|
||||
response_types_supported: Vec<String>,
|
||||
|
||||
/// OPTIONAL. JSON array containing a list of the OAuth 2.0 response_mode values that this OP supports, as specified in OAuth 2.0 Multiple Response Type Encoding Practices [OAuth.Responses].
|
||||
/// If omitted, the default for Dynamic OpenID Providers is ["query", "fragment"].
|
||||
response_modes_supported: Vec<String>,
|
||||
|
||||
/// OPTIONAL. JSON array containing a list of the OAuth 2.0 Grant Type values that this OP supports.
|
||||
/// Dynamic OpenID Providers MUST support the authorization_code and implicit Grant Type values and MAY support other Grant Types.
|
||||
/// If omitted, the default value is ["authorization_code", "implicit"].
|
||||
grant_types_supported: Vec<String>,
|
||||
|
||||
/// REQUIRED. JSON array containing a list of the Subject Identifier types that this OP supports. Valid types include pairwise and public.
|
||||
subject_types_supported: Vec<String>,
|
||||
|
||||
/// REQUIRED. JSON array containing a list of the JWS signing algorithms (alg values) supported by the OP for the ID Token to encode the Claims in a JWT [JWT].
|
||||
/// The algorithm RS256 MUST be included.
|
||||
/// The value none MAY be supported, but MUST NOT be used unless the Response Type used returns no ID Token from the Authorization Endpoint (such as when using the Authorization Code Flow).
|
||||
id_token_signing_alg_values_supported: Vec<String>,
|
||||
// ...
|
||||
// various OPTIONAL fields omitted here.
|
||||
// ...
|
||||
|
||||
// RECOMMENDED. JSON array containing a list of the Claim Names of the Claims that the OpenID Provider MAY be able to supply values for.
|
||||
// Note that for privacy or other reasons, this might not be an exhaustive list.
|
||||
//claims_supported: Vec<String>,
|
||||
}
|
||||
|
||||
pub async fn oidc_discovery_configuration(
|
||||
Extension(config): Extension<Arc<Configuration>>,
|
||||
) -> impl IntoResponse {
|
||||
// TODO CORS OK
|
||||
// must not include trailing slash
|
||||
let base = &config.listen.public_base_uri;
|
||||
Json(OidcDiscoveryConfiguration {
|
||||
issuer: base.to_string(),
|
||||
authorization_endpoint: format!("{base}/oidc/auth"),
|
||||
token_endpoint: format!("{base}/oidc/token"),
|
||||
userinfo_endpoint: format!("{base}/oidc/userinfo"),
|
||||
jwks_uri: format!("{base}/oidc/jwks"),
|
||||
scopes_supported: vec!["openid".to_owned()],
|
||||
// TODO this is a lie right now.
|
||||
response_types_supported: vec!["code".to_owned()],
|
||||
// TODO this is a lie right now
|
||||
response_modes_supported: vec!["query".to_owned()],
|
||||
// TODO should we support 'implicit'?
|
||||
// TODO should we support 'refresh_token'
|
||||
// TODO this is currently a lie
|
||||
grant_types_supported: vec!["authorization_code".to_owned()],
|
||||
// TODO this is currently a lie
|
||||
subject_types_supported: vec!["public".to_owned()],
|
||||
// TODO this is currently a lie
|
||||
// TODO we should support other types?
|
||||
id_token_signing_alg_values_supported: vec!["RS256".to_owned()],
|
||||
})
|
||||
}
|
410
src/web/oauth_openid/authorisation.rs
Normal file
410
src/web/oauth_openid/authorisation.rs
Normal file
@ -0,0 +1,410 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::{
|
||||
extract::{rejection::QueryRejection, OriginalUri, Query},
|
||||
http::{StatusCode, Uri},
|
||||
response::{Html, IntoResponse, Response},
|
||||
Extension, Form,
|
||||
};
|
||||
use axum_csrf::CsrfToken;
|
||||
use base64::{prelude::BASE64_URL_SAFE_NO_PAD, Engine};
|
||||
use eyre::{Context, ContextCompat};
|
||||
|
||||
use rand::Rng;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tracing::{error, info};
|
||||
|
||||
use crate::{
|
||||
config::{Configuration, OidcClientConfiguration},
|
||||
web::{
|
||||
login::LoginSession,
|
||||
make_login_redirect,
|
||||
oauth_openid::ext_codes::{AuthCode, AuthCodeBinding},
|
||||
},
|
||||
};
|
||||
|
||||
use super::ext_codes::VolatileCodeStore;
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct AuthorisationQuery {
|
||||
/// REQUIRED. OpenID Connect requests MUST contain the openid scope value.
|
||||
/// If the openid scope value is not present, the behavior is entirely unspecified.
|
||||
/// Other scope values MAY be present.
|
||||
/// Scope values used that are not understood by an implementation SHOULD be ignored.
|
||||
/// See Sections 5.4 and 11 for additional scope values defined by this specification.
|
||||
scope: String,
|
||||
/// REQUIRED. OAuth 2.0 Client Identifier valid at the Authorization Server.
|
||||
client_id: String,
|
||||
/// REQUIRED. OAuth 2.0 Response Type value that determines the authorization processing flow to be used, including what parameters are returned from the endpoints used.
|
||||
/// When using the Authorization Code Flow, this value is code.
|
||||
response_type: String,
|
||||
/// RECOMMENDED. Opaque value used to maintain state between the request and the callback.
|
||||
/// Typically, Cross-Site Request Forgery (CSRF, XSRF) mitigation is done by cryptographically binding the value of this parameter with a browser cookie.
|
||||
state: Option<String>,
|
||||
/// REQUIRED. Redirection URI to which the response will be sent.
|
||||
/// This URI MUST exactly match one of the Redirection URI values for the Client pre-registered at the OpenID Provider,
|
||||
/// with the matching performed as described in Section 6.2.1 of [RFC3986] (Simple String Comparison).
|
||||
/// When using this flow, the Redirection URI SHOULD use the https scheme; however, it MAY use the http scheme, provided that the Client Type is confidential,
|
||||
/// as defined in Section 2.1 of OAuth 2.0, and provided the OP allows the use of http Redirection URIs in this case.
|
||||
/// The Redirection URI MAY use an alternate scheme, such as one that is intended to identify a callback into a native application.
|
||||
redirect_uri: String,
|
||||
/// 'RECOMMENDED OR REQUIRED'. I'm not sure what that means although we treat it as REQUIRED here.
|
||||
code_challenge: Option<String>,
|
||||
/// OPTIONAL (default = 'plain'). We must support 'plain' and 'S256' schemes here.
|
||||
code_challenge_method: Option<String>,
|
||||
// TODO OIDC: `nonce` which must be passed all the way through to the ID token
|
||||
}
|
||||
|
||||
pub async fn oidc_authorisation(
|
||||
query: Result<Query<AuthorisationQuery>, QueryRejection>,
|
||||
login_session: Option<LoginSession>,
|
||||
Extension(config): Extension<Arc<Configuration>>,
|
||||
Extension(code_store): Extension<VolatileCodeStore>,
|
||||
csrf: CsrfToken,
|
||||
OriginalUri(uri): OriginalUri,
|
||||
) -> Response {
|
||||
let Query(query) = match query {
|
||||
Ok(query) => query,
|
||||
Err(err) => {
|
||||
// TODO this should be a pretty page
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
format!("TODO bad authorisation request: {err:?}"),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
info!("auth {query:?}");
|
||||
|
||||
let (client_id, client_config) = match validate_authorisation_basics(&query, &config) {
|
||||
Ok(x) => x,
|
||||
Err(resp) => return resp,
|
||||
};
|
||||
|
||||
// If the user isn't logged in, we need to get them to do that first and then come back here.
|
||||
let Some(login_session) = login_session else {
|
||||
return make_login_redirect(uri);
|
||||
};
|
||||
|
||||
// If the application requires consent, then we should ask for that.
|
||||
if !client_config.skip_consent {
|
||||
return show_consent_page(login_session, client_config, &config, csrf).await;
|
||||
}
|
||||
|
||||
// No consent needed: process the authorisation.
|
||||
|
||||
// TODO CORS not ok: send strict prevention headers and frame headers
|
||||
process_authorisation(
|
||||
query,
|
||||
login_session,
|
||||
client_id,
|
||||
client_config,
|
||||
&config,
|
||||
&code_store,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct PostConsentForm {
|
||||
action: String,
|
||||
}
|
||||
|
||||
pub async fn post_oidc_authorisation_consent(
|
||||
Query(query): Query<AuthorisationQuery>,
|
||||
login_session: Option<LoginSession>,
|
||||
Extension(config): Extension<Arc<Configuration>>,
|
||||
Extension(code_store): Extension<VolatileCodeStore>,
|
||||
_csrf: CsrfToken,
|
||||
OriginalUri(uri): OriginalUri,
|
||||
Form(form): Form<PostConsentForm>,
|
||||
) -> Response {
|
||||
let (client_id, client_config) = match validate_authorisation_basics(&query, &config) {
|
||||
Ok(x) => x,
|
||||
Err(resp) => return resp,
|
||||
};
|
||||
|
||||
// If the user isn't logged in, we need to get them to do that first and then come back here.
|
||||
let Some(login_session) = login_session else {
|
||||
return make_login_redirect(uri);
|
||||
};
|
||||
|
||||
match form.action.as_str() {
|
||||
"accept" => {
|
||||
// TODO CORS not ok: send strict prevention headers and frame headers
|
||||
process_authorisation(
|
||||
query,
|
||||
login_session,
|
||||
client_id,
|
||||
client_config,
|
||||
&config,
|
||||
&code_store,
|
||||
)
|
||||
.await
|
||||
}
|
||||
"deny" => fail_authorisation_with_redirect(
|
||||
&query,
|
||||
client_config,
|
||||
AuthorisationRedirectableError::AccessDenied,
|
||||
"consent denied".to_owned(),
|
||||
),
|
||||
_ => (StatusCode::BAD_REQUEST, "Bad form action").into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns an error response if the authorisation request is obviously wrong in some way.
|
||||
/// Otherwise, returns (client_id, client_config).
|
||||
fn validate_authorisation_basics<'a>(
|
||||
query: &AuthorisationQuery,
|
||||
config: &'a Configuration,
|
||||
) -> Result<(String, &'a OidcClientConfiguration), Response> {
|
||||
let Some(client_config) = config.oidc.clients.get(&query.client_id) else {
|
||||
// TODO format as pretty page
|
||||
return Err((StatusCode::BAD_REQUEST, "TODO bad client_id").into_response());
|
||||
};
|
||||
|
||||
if !client_config.redirect_uris.contains(&query.redirect_uri) {
|
||||
// TODO format as pretty page
|
||||
return Err((StatusCode::BAD_REQUEST, "TODO bad redirect_uri").into_response());
|
||||
}
|
||||
|
||||
if &query.response_type != "code" {
|
||||
return Err(fail_authorisation_with_redirect(
|
||||
query,
|
||||
&client_config,
|
||||
AuthorisationRedirectableError::UnsupportedResponseType,
|
||||
"We only support `code` authorisation responses here.".to_owned(),
|
||||
));
|
||||
}
|
||||
if query.code_challenge.is_none() {
|
||||
return Err(fail_authorisation_with_redirect(
|
||||
&query,
|
||||
client_config,
|
||||
AuthorisationRedirectableError::InvalidRequest,
|
||||
"`code_challenge` not specified.".to_owned(),
|
||||
));
|
||||
}
|
||||
match query.code_challenge_method.as_ref().map(String::as_str) {
|
||||
None | Some("S256") | Some("plain") => {
|
||||
// OK: supported (None = 'plain')
|
||||
}
|
||||
_other => {
|
||||
return Err(fail_authorisation_with_redirect(
|
||||
&query,
|
||||
client_config,
|
||||
AuthorisationRedirectableError::InvalidRequest,
|
||||
"`code_challenge_method` is not supported.".to_owned(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Ok((query.client_id.to_owned(), client_config))
|
||||
}
|
||||
|
||||
async fn show_consent_page(
|
||||
login_session: LoginSession,
|
||||
client_config: &OidcClientConfiguration,
|
||||
_config: &Configuration,
|
||||
csrf: CsrfToken,
|
||||
) -> Response {
|
||||
let csrf_token = csrf
|
||||
.authenticity_token()
|
||||
.expect("must be able to create a CSRF token");
|
||||
(
|
||||
csrf,
|
||||
Html(format!(
|
||||
"hi <u>{}</u>, consent to <u>{}</u>? <form method='POST'><input type='hidden' name='csrf' value='{}'><button type='submit' name='action' value='accept'>Accept</button> <button type='submit' name='action' value='deny'>Deny</button></form>",
|
||||
login_session.username, client_config.name, csrf_token
|
||||
))
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
/// Proceeds with the final stage of the authorisation procedure.
|
||||
///
|
||||
/// Should redirect back to the relying party with either a success or fail response.
|
||||
/// On success, will create authorisation codes in an in-memory store, ready for redemption.
|
||||
///
|
||||
/// Preconditions:
|
||||
/// - any required consent from the user has now been obtained
|
||||
/// - query.request_uri has been validated as a safe redirect URI
|
||||
async fn process_authorisation(
|
||||
query: AuthorisationQuery,
|
||||
_login_session: LoginSession,
|
||||
client_id: String,
|
||||
_client_config: &OidcClientConfiguration,
|
||||
config: &Configuration,
|
||||
code_store: &VolatileCodeStore,
|
||||
) -> Response {
|
||||
assert_eq!(
|
||||
&query.response_type, "code",
|
||||
"`code` responses only. Others should have been filtered out by this point"
|
||||
);
|
||||
let code_challenge = query
|
||||
.code_challenge
|
||||
.expect("already checked to have a code_challenge");
|
||||
|
||||
// Generate a 192-bit random code, which fits into exactly 32 base64 characters.
|
||||
// This is an arbitrary choice left to us but I feel a 192-bit value is sufficiently random.
|
||||
let code = AuthCode::generate_new_random();
|
||||
let code_base64url = code.to_string();
|
||||
|
||||
// Write down the code and other details in-memory with 10 minute expiry...
|
||||
// Limit so we only keep a certain amount per peer IP address.
|
||||
code_store.add_redeemable(
|
||||
code,
|
||||
AuthCodeBinding {
|
||||
client_id,
|
||||
code_challenge_method: query
|
||||
.code_challenge_method
|
||||
.unwrap_or_else(|| String::from("plain")),
|
||||
code_challenge,
|
||||
// TODO this should be bound to the login session
|
||||
},
|
||||
0,
|
||||
);
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct CodeResponseRedirect {
|
||||
/// Opaque authorisation code generated by us.
|
||||
/// Must expire after a short time (10 min recommended)
|
||||
/// Client MUST NOT use it more than once. (If they do, we should revoke all issued refresh tokens and access tokens from that code.)
|
||||
/// The auth code is bound to the (client_id, code_challenge, request_uri)
|
||||
code: String,
|
||||
|
||||
/// REQUIRED if they sent one to us, otherwise omit.
|
||||
/// A verbatim copy of what they sent to us.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
state: Option<String>,
|
||||
|
||||
/// OPTIONAL but I will treat this as recommended: seems to me there is no harm
|
||||
/// Our identifier as an issuer, which the client can use to prevent mixup attacks.
|
||||
iss: String,
|
||||
}
|
||||
let func = move || -> eyre::Result<Response> {
|
||||
let redirect_uri = extend_uri_with_params(
|
||||
query
|
||||
.redirect_uri
|
||||
.parse()
|
||||
.context("redirect_uri unparseable")?,
|
||||
CodeResponseRedirect {
|
||||
code: code_base64url,
|
||||
state: query.state,
|
||||
iss: config.oidc.issuer.clone(),
|
||||
},
|
||||
)
|
||||
.context("failed to build extended URI")?
|
||||
.to_string();
|
||||
|
||||
Ok((
|
||||
StatusCode::FOUND,
|
||||
[("Location", redirect_uri.as_str())],
|
||||
"Authorisation succeeded; redirecting you back.",
|
||||
)
|
||||
.into_response())
|
||||
};
|
||||
|
||||
func().unwrap_or_else(|e| {
|
||||
error!("failed to redirect failed auth: {e:?}");
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response()
|
||||
})
|
||||
}
|
||||
|
||||
/// Fails the authorisation procedure and redirects back to the Relying Party.
|
||||
///
|
||||
/// Preconditions:
|
||||
/// - query.redirect_uri has been validated as a safe redirect URI
|
||||
fn fail_authorisation_with_redirect(
|
||||
query: &AuthorisationQuery,
|
||||
_client_config: &OidcClientConfiguration,
|
||||
error: AuthorisationRedirectableError,
|
||||
error_description: String,
|
||||
) -> Response {
|
||||
#[derive(Serialize)]
|
||||
struct ErrorRedirect {
|
||||
error: AuthorisationRedirectableError,
|
||||
error_description: String,
|
||||
}
|
||||
let func = move || -> eyre::Result<Response> {
|
||||
let redirect_uri = extend_uri_with_params(
|
||||
query
|
||||
.redirect_uri
|
||||
.parse()
|
||||
.context("redirect_uri unparseable")?,
|
||||
ErrorRedirect {
|
||||
error,
|
||||
error_description,
|
||||
},
|
||||
)
|
||||
.context("failed to build extended URI")?
|
||||
.to_string();
|
||||
|
||||
Ok((
|
||||
StatusCode::FOUND,
|
||||
[("Location", redirect_uri.as_str())],
|
||||
"Authorisation failed; redirecting you back.",
|
||||
)
|
||||
.into_response())
|
||||
};
|
||||
|
||||
func().unwrap_or_else(|e| {
|
||||
error!("failed to redirect failed auth: {e:?}");
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response()
|
||||
})
|
||||
}
|
||||
|
||||
fn extend_uri_with_params(uri: Uri, params: impl Serialize) -> eyre::Result<Uri> {
|
||||
let mut uri_parts = uri.into_parts();
|
||||
let paq = uri_parts
|
||||
.path_and_query
|
||||
.as_mut()
|
||||
.context("TODO empty path and query not supported for client redirection endpoints yet")?;
|
||||
let mut query = paq.query().unwrap_or("").to_owned();
|
||||
let appendage = serde_urlencoded::to_string(params).context("failed to serialise extension")?;
|
||||
if query.is_empty() {
|
||||
query.push('?');
|
||||
} else {
|
||||
query.push('&');
|
||||
}
|
||||
query.push_str(&appendage);
|
||||
uri_parts.path_and_query = Some(format!("{}{}", paq.path(), query).parse()?);
|
||||
Uri::from_parts(uri_parts).context("failed to rebuild URI from parts")
|
||||
}
|
||||
|
||||
// pub async fn oidc_authorisation_action(
|
||||
// query: Result<Query<AuthorisationQuery>, QueryRejection>,
|
||||
// Extension(config): Extension<Arc<Configuration>>,
|
||||
// )
|
||||
|
||||
/// Errors in the authorisation procedure for which it is acceptable to redirect back to the Relying Party.
|
||||
///
|
||||
/// OAuth 2.1: https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-09#name-error-response-2
|
||||
///
|
||||
/// Errors beginning with 'Oidc' are from
|
||||
/// 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)]
|
||||
pub enum AuthorisationRedirectableError {
|
||||
#[serde(rename = "invalid_request")]
|
||||
InvalidRequest,
|
||||
#[serde(rename = "unauthorized_client")]
|
||||
UnauthorisedClient,
|
||||
#[serde(rename = "access_denied")]
|
||||
AccessDenied,
|
||||
#[serde(rename = "unsupported_response_type")]
|
||||
UnsupportedResponseType,
|
||||
#[serde(rename = "invalid_scope")]
|
||||
InvalidScope,
|
||||
#[serde(rename = "server_error")]
|
||||
ServerError,
|
||||
#[serde(rename = "temporarily_unavailable")]
|
||||
TemporarilyUnavailable,
|
||||
#[serde(rename = "interaction_required")]
|
||||
OidcInteractionRequired,
|
||||
#[serde(rename = "login_required")]
|
||||
OidcLoginRequired,
|
||||
#[serde(rename = "account_selection_required")]
|
||||
OidcAccountSelectionRequired,
|
||||
#[serde(rename = "consent_required")]
|
||||
OidcConsentRequired,
|
||||
}
|
204
src/web/oauth_openid/ext_codes.rs
Normal file
204
src/web/oauth_openid/ext_codes.rs
Normal file
@ -0,0 +1,204 @@
|
||||
use std::{
|
||||
collections::{BTreeSet, HashMap},
|
||||
fmt::Display,
|
||||
str::FromStr,
|
||||
sync::{Arc, Mutex},
|
||||
time::{Duration, Instant, SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
use base64::{display::Base64Display, prelude::BASE64_URL_SAFE_NO_PAD, Engine};
|
||||
use rand::Rng;
|
||||
use tokio::sync::Notify;
|
||||
|
||||
/// Display shows the auth code as base64 (URL-safe non-padded).
|
||||
/// FromStr/parse parses the same format.
|
||||
#[derive(Clone, Hash, PartialEq, Eq, Ord, PartialOrd)]
|
||||
pub struct AuthCode([u8; 24]);
|
||||
|
||||
pub type AccessTokenHash = String;
|
||||
|
||||
impl Display for AuthCode {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(
|
||||
f,
|
||||
"{}",
|
||||
Base64Display::new(&self.0, &BASE64_URL_SAFE_NO_PAD)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for AuthCode {
|
||||
type Err = &'static str;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
let mut out = [0u8; 24];
|
||||
let read = BASE64_URL_SAFE_NO_PAD
|
||||
.decode_slice(s, &mut out)
|
||||
.map_err(|_| "wrong size")?;
|
||||
if read != 24 {
|
||||
return Err("wrong size");
|
||||
}
|
||||
Ok(AuthCode(out))
|
||||
}
|
||||
}
|
||||
|
||||
impl AuthCode {
|
||||
pub fn generate_new_random() -> Self {
|
||||
Self(rand::thread_rng().gen::<[u8; 24]>())
|
||||
}
|
||||
}
|
||||
|
||||
pub struct AuthCodeBinding {
|
||||
pub client_id: String,
|
||||
pub code_challenge_method: String,
|
||||
pub code_challenge: String,
|
||||
// TODO info about the user...
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct VolatileCodeStoreInner {
|
||||
/// Codes that are currently redeemable
|
||||
pub redeemable_codes: HashMap<AuthCode, AuthCodeBinding>,
|
||||
|
||||
/// Codes that have been redeemed but still have not expired:
|
||||
/// they can still be 'double-claimed' and lead to a conflict being detected.
|
||||
pub conflictable_codes: HashMap<AuthCode, RedeemedAuthCode>,
|
||||
|
||||
/// Time when codes will expire
|
||||
pub expire_codes_at: BTreeSet<(u64, AuthCode)>,
|
||||
}
|
||||
|
||||
impl VolatileCodeStoreInner {
|
||||
pub fn redeem(
|
||||
&mut self,
|
||||
auth_code: &AuthCode,
|
||||
access_token_hash: AccessTokenHash,
|
||||
) -> CodeRedemption {
|
||||
if let Some(conflicted) = self.conflictable_codes.get(auth_code) {
|
||||
return CodeRedemption::Conflicted {
|
||||
access_token_to_invalidate: conflicted.access_token_hash.clone(),
|
||||
};
|
||||
}
|
||||
|
||||
let Some((auth_code, binding)) = self.redeemable_codes.remove_entry(auth_code) else {
|
||||
return CodeRedemption::Invalid;
|
||||
};
|
||||
|
||||
self.conflictable_codes
|
||||
.insert(auth_code, RedeemedAuthCode { access_token_hash });
|
||||
|
||||
CodeRedemption::Valid { binding }
|
||||
}
|
||||
|
||||
pub fn add_redeemable(
|
||||
&mut self,
|
||||
auth_code: AuthCode,
|
||||
auth_code_binding: AuthCodeBinding,
|
||||
expires_at: u64,
|
||||
) {
|
||||
self.redeemable_codes
|
||||
.insert(auth_code.clone(), auth_code_binding);
|
||||
self.expire_codes_at.insert((expires_at, auth_code));
|
||||
// TODO notify expirer that we have inserted something at `expires_at`.
|
||||
}
|
||||
|
||||
/// Removes all expired auth codes and returns the time of the earliest next expiry, if present.
|
||||
pub(self) fn handle_expiry(&mut self, now: u64) -> Option<u64> {
|
||||
loop {
|
||||
let (ts, _auth_code) = self.expire_codes_at.first()?;
|
||||
|
||||
// Remove if expired
|
||||
if *ts <= now {
|
||||
self.expire_codes_at.pop_first();
|
||||
continue;
|
||||
}
|
||||
|
||||
// Otherwise this is our stopping point
|
||||
return Some(*ts);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct VolatileCodeStore {
|
||||
poke: Arc<Notify>,
|
||||
inner: Arc<Mutex<VolatileCodeStoreInner>>,
|
||||
}
|
||||
|
||||
impl VolatileCodeStore {
|
||||
pub fn new() -> Self {
|
||||
let poke = Arc::new(Notify::new());
|
||||
let inner: Arc<Mutex<VolatileCodeStoreInner>> = Default::default();
|
||||
|
||||
{
|
||||
let poke = poke.clone();
|
||||
let inner = inner.clone();
|
||||
tokio::spawn(Self::expirer(inner, poke));
|
||||
}
|
||||
|
||||
VolatileCodeStore { inner, poke }
|
||||
}
|
||||
|
||||
async fn expirer(inner: Arc<Mutex<VolatileCodeStoreInner>>, poke: Arc<Notify>) {
|
||||
let mut next_expiry: Option<u64> = None;
|
||||
loop {
|
||||
match next_expiry {
|
||||
Some(next_expiry) => {
|
||||
let sleep_until = UNIX_EPOCH + Duration::from_secs(next_expiry);
|
||||
let now = SystemTime::now();
|
||||
let sleep_for = sleep_until
|
||||
.duration_since(now)
|
||||
.unwrap_or(Duration::from_secs(60));
|
||||
tokio::select! {
|
||||
_ = poke.notified() => {},
|
||||
_ = tokio::time::sleep(sleep_for) => {},
|
||||
}
|
||||
}
|
||||
None => {
|
||||
poke.notified().await;
|
||||
}
|
||||
}
|
||||
|
||||
let now = SystemTime::now();
|
||||
next_expiry = {
|
||||
let mut inner = inner.lock().unwrap();
|
||||
inner.handle_expiry(
|
||||
now.duration_since(UNIX_EPOCH)
|
||||
.expect("system clock before unix epoch")
|
||||
.as_secs(),
|
||||
)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
pub fn redeem(
|
||||
&self,
|
||||
auth_code: &AuthCode,
|
||||
access_token_hash: AccessTokenHash,
|
||||
) -> CodeRedemption {
|
||||
let mut inner = self.inner.lock().unwrap();
|
||||
inner.redeem(auth_code, access_token_hash)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
pub enum CodeRedemption {
|
||||
/// That auth code was not active
|
||||
Invalid,
|
||||
/// That auth code is valid and has been redeemed.
|
||||
Valid { binding: AuthCodeBinding },
|
||||
/// That auth code had already been redeemed: please invalidate the given access token and reject this redemption.
|
||||
Conflicted {
|
||||
access_token_to_invalidate: AccessTokenHash,
|
||||
},
|
||||
}
|
414
src/web/oauth_openid/token.rs
Normal file
414
src/web/oauth_openid/token.rs
Normal file
@ -0,0 +1,414 @@
|
||||
use std::{str::FromStr, sync::Arc};
|
||||
|
||||
use axum::{
|
||||
extract::rejection::FormRejection,
|
||||
headers::{authorization::Basic, Authorization},
|
||||
http::StatusCode,
|
||||
response::IntoResponse,
|
||||
Extension, Form, Json, TypedHeader,
|
||||
};
|
||||
use base64::{prelude::BASE64_URL_SAFE_NO_PAD, Engine};
|
||||
use eyre::{bail, Context};
|
||||
use josekit::{
|
||||
jws::{alg::rsassa::RsassaJwsAlgorithm::Rs256, JwsHeader},
|
||||
jwt::JwtPayload,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use subtle::ConstantTimeEq;
|
||||
use tracing::error;
|
||||
|
||||
use crate::{
|
||||
config::{Configuration, SecretConfig},
|
||||
store::IdCoopStore,
|
||||
};
|
||||
|
||||
use super::ext_codes::{AuthCode, CodeRedemption, VolatileCodeStore};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct TokenFormParams {
|
||||
grant_type: String,
|
||||
redirect_uri: String,
|
||||
code: Option<String>,
|
||||
code_verifier: Option<String>,
|
||||
|
||||
/// Used for authentication sometimes if another authentication method is not in use.
|
||||
client_id: Option<String>,
|
||||
}
|
||||
|
||||
// TODO auth_header can be one alternative
|
||||
pub async fn oidc_token(
|
||||
basic_auth: Option<TypedHeader<Authorization<Basic>>>,
|
||||
Extension(config): Extension<Arc<Configuration>>,
|
||||
Extension(secrets): Extension<Arc<SecretConfig>>,
|
||||
Extension(store): Extension<Arc<IdCoopStore>>,
|
||||
Extension(code_store): Extension<VolatileCodeStore>,
|
||||
form: Result<Form<TokenFormParams>, FormRejection>,
|
||||
) -> impl IntoResponse {
|
||||
let form = match form {
|
||||
Ok(form) => form,
|
||||
Err(err) => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(TokenError {
|
||||
code: TokenErrorCode::InvalidRequest,
|
||||
description: match err {
|
||||
FormRejection::InvalidFormContentType(_) => {
|
||||
"Wrong Content-Type for the /token endpoint".to_owned()
|
||||
}
|
||||
FormRejection::FailedToDeserializeForm(_) => {
|
||||
"Failed to deserialise form".to_owned()
|
||||
}
|
||||
FormRejection::FailedToDeserializeFormBody(err) => {
|
||||
format!("Failed to deserialise form body: {err:?}")
|
||||
}
|
||||
FormRejection::BytesRejection(err) => {
|
||||
format!("Failed to read bytes: {err:?}")
|
||||
}
|
||||
_ => format!("other unhandled problem: {err:?}"),
|
||||
},
|
||||
}),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
// Check client auth (only one auth allowed in any one request to prevent confusion)
|
||||
// WE ONLY SUPPORT `client_secret_basic` for the time being
|
||||
// TODO we should make this logic reusable for other endpoints
|
||||
let Some(TypedHeader(Authorization(basic_auth))) = basic_auth else {
|
||||
// TODO(out of spec): we must respond with 401 and WWW-Authenticate if the client tried any Authorization header
|
||||
// (See OAuth2.1 3.2.3.1).
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(TokenError {
|
||||
code: TokenErrorCode::InvalidClient,
|
||||
description: "idCoop only supports `client_secret_basic` authorisation at this time and no basic auth was available.".to_string(),
|
||||
})
|
||||
).into_response();
|
||||
};
|
||||
|
||||
let Some(unverified_client_config) = config.oidc.clients.get(basic_auth.username()) else {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(TokenError {
|
||||
code: TokenErrorCode::InvalidClient,
|
||||
description: "That `client_id` is not recognised here.".to_string(),
|
||||
})
|
||||
).into_response();
|
||||
};
|
||||
|
||||
if !bool::from(
|
||||
basic_auth
|
||||
.password()
|
||||
.as_bytes()
|
||||
.ct_eq(unverified_client_config.secret.as_bytes()),
|
||||
) {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(TokenError {
|
||||
code: TokenErrorCode::InvalidClient,
|
||||
description: "Failed to authenticate as that client.".to_string(),
|
||||
}),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
// now we have checked that the client is who they say they are...
|
||||
let client_config = unverified_client_config;
|
||||
|
||||
// TODO support other grant types, e.g. refresh tokens
|
||||
if form.grant_type != "authorization_code" {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(TokenError {
|
||||
code: TokenErrorCode::UnsupportedGrantType,
|
||||
description: "idCoop only supports the `authorization_code` grant at this time."
|
||||
.to_string(),
|
||||
}),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
let Some(auth_code) = form.code.clone() else {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(TokenError {
|
||||
code: TokenErrorCode::InvalidRequest,
|
||||
description: "`code` parameter missing.".to_string(),
|
||||
}),
|
||||
)
|
||||
.into_response();
|
||||
};
|
||||
let Some(auth_code_verifier) = form.code_verifier.clone() else {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(TokenError {
|
||||
code: TokenErrorCode::InvalidRequest,
|
||||
description: "`code_verifier` parameter missing.".to_string(),
|
||||
}),
|
||||
)
|
||||
.into_response();
|
||||
};
|
||||
let Ok(auth_code) = AuthCode::from_str(&auth_code) else {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(TokenError {
|
||||
code: TokenErrorCode::InvalidRequest,
|
||||
description: "`code` parameter malformed.".to_string(),
|
||||
}),
|
||||
)
|
||||
.into_response();
|
||||
};
|
||||
|
||||
// Create an access token but don't actually issue it yet:
|
||||
// This lets us store the hash of the access token against the redemption of the auth code,
|
||||
// so double redemptions can invalidate the access token appropriately.
|
||||
let access_token = String::new(); // TODO!!!
|
||||
let access_token_hash = String::new(); // TODO!!!
|
||||
let refresh_token_hash = String::new(); // TODO!!!
|
||||
let refresh_token = String::new(); // TODO!!!
|
||||
|
||||
// Redeem the auth code so we can check it and then maybe issue an access token.
|
||||
let binding = match code_store.redeem(&auth_code, access_token_hash.clone()) {
|
||||
CodeRedemption::Invalid => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(TokenError {
|
||||
code: TokenErrorCode::InvalidGrant,
|
||||
description: "Auth code has expired or was not valid.".to_owned(),
|
||||
}),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
CodeRedemption::Valid { binding } => binding,
|
||||
CodeRedemption::Conflicted {
|
||||
access_token_to_invalidate,
|
||||
} => {
|
||||
// Invalidate the access token that was issued before
|
||||
if let Err(err) = store
|
||||
.txn(move |mut txn| {
|
||||
Box::pin(async move {
|
||||
txn.invalidate_access_token_by_hash(access_token_to_invalidate)
|
||||
.await
|
||||
.context("failed to invalidate access token")
|
||||
})
|
||||
})
|
||||
.await
|
||||
{
|
||||
error!("failed to handle Conflicted: {err:?}");
|
||||
}
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(TokenError {
|
||||
code: TokenErrorCode::InvalidGrant,
|
||||
description: "Auth code has been redeemed multiple times! This could mean something nasty.".to_owned(),
|
||||
}),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
// 1. Check the auth code is valid for this client
|
||||
if binding.client_id != basic_auth.username() {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(TokenError {
|
||||
code: TokenErrorCode::InvalidGrant,
|
||||
description: "Auth code is not valid for your client.".to_owned(),
|
||||
}),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
// 2. Check the code challenge
|
||||
let Some(computed_code_challenge) = compute_code_challenge(&binding.code_challenge_method, &auth_code_verifier) else {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(TokenError {
|
||||
code: TokenErrorCode::InvalidRequest,
|
||||
description: "Unable to compute code challenge here.".to_owned(),
|
||||
}),
|
||||
)
|
||||
.into_response();
|
||||
};
|
||||
|
||||
if !bool::from(
|
||||
computed_code_challenge
|
||||
.as_bytes()
|
||||
.ct_eq(binding.code_challenge.as_bytes()),
|
||||
) {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(TokenError {
|
||||
code: TokenErrorCode::InvalidGrant,
|
||||
description: "Code challenge is invalid.".to_owned(),
|
||||
}),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
// Issue access token
|
||||
if let Err(err) = store
|
||||
.txn(move |mut txn| {
|
||||
Box::pin(async move {
|
||||
txn.issue_access_token(access_token_hash)
|
||||
.await
|
||||
.context("issue_access_token")
|
||||
})
|
||||
})
|
||||
.await
|
||||
{
|
||||
error!("failed to issue access token: {err:?}");
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"database error when issuing access token; ask the server administrator to check server logs"
|
||||
).into_response();
|
||||
}
|
||||
let id_token = IdToken {
|
||||
iss: config.oidc.issuer.clone(),
|
||||
sub: "USER_ID_TODO".to_owned(),
|
||||
aud: basic_auth.username().to_owned(),
|
||||
exp: 0, // TODO
|
||||
iat: 0, // TODO
|
||||
auth_time: 0, // TODO
|
||||
nonce: None, // TODO
|
||||
};
|
||||
|
||||
let id_token = match make_id_token(id_token, &secrets) {
|
||||
Ok(token) => token,
|
||||
Err(err) => {
|
||||
error!("Failed to make ID Token: {err:?}");
|
||||
return (StatusCode::INTERNAL_SERVER_ERROR, "failed to make ID Token").into_response();
|
||||
}
|
||||
};
|
||||
|
||||
// TODO CORS OK
|
||||
(
|
||||
StatusCode::OK,
|
||||
Json(TokenSuccess {
|
||||
access_token,
|
||||
token_type: "Bearer".to_owned(),
|
||||
refresh_token,
|
||||
expires_in: 60, // TODO
|
||||
// This assumes that we only support the OpenID scope at present.
|
||||
scope: "openid".to_owned(),
|
||||
id_token,
|
||||
}),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
fn make_id_token(id_token: IdToken, secrets: &SecretConfig) -> eyre::Result<String> {
|
||||
let Ok(serde_json::Value::Object(map)) = serde_json::to_value(&id_token).context("failed to serialise ID Token content") else {
|
||||
bail!("ID Token not a map");
|
||||
};
|
||||
|
||||
let id_token_header = JwsHeader::new();
|
||||
let id_token_payload =
|
||||
JwtPayload::from_map(map).context("failed to make JWT payload for ID token")?;
|
||||
// TODO
|
||||
let rs256_signer = Rs256
|
||||
.signer_from_jwk(&secrets.rsa_key_pair.to_jwk_key_pair())
|
||||
.context("failed to make RS256 signer")?;
|
||||
|
||||
josekit::jwt::encode_with_signer(&id_token_payload, &id_token_header, &rs256_signer)
|
||||
.context("failed to sign ID Token")
|
||||
}
|
||||
|
||||
pub fn compute_code_challenge(method: &str, verifier: &str) -> Option<String> {
|
||||
match method {
|
||||
"plain" => Some(verifier.to_owned()),
|
||||
"S256" => {
|
||||
// code_challenge = BASE64URL-ENCODE(SHA256(ASCII(code_verifier)))
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(verifier.as_bytes());
|
||||
let sha256_of_verifier = hasher.finalize();
|
||||
Some(BASE64_URL_SAFE_NO_PAD.encode(sha256_of_verifier))
|
||||
}
|
||||
_other => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct IdToken {
|
||||
/// REQUIRED. Issuer Identifier for the Issuer of the response.
|
||||
/// The iss value is a case sensitive URL using the https scheme that contains scheme, host, and optionally, port number and path components and no query or fragment components.
|
||||
pub iss: String,
|
||||
/// REQUIRED. Subject Identifier.
|
||||
/// A locally unique and never reassigned identifier within the Issuer for the End-User, which is intended to be consumed by the Client, e.g., 24400320 or AItOawmwtWwcT0k51BayewNvutrJUqsvl6qs7A4.
|
||||
/// It MUST NOT exceed 255 ASCII characters in length. The sub value is a case sensitive string.
|
||||
pub sub: String,
|
||||
/// REQUIRED. Audience(s) that this ID Token is intended for. It MUST contain the OAuth 2.0 client_id of the Relying Party as an audience value.
|
||||
/// It MAY also contain identifiers for other audiences. In the general case, the aud value is an array of case sensitive strings.
|
||||
/// In the common special case when there is one audience, the aud value MAY be a single case sensitive string.
|
||||
pub aud: String,
|
||||
/// REQUIRED. Expiration time on or after which the ID Token MUST NOT be accepted for processing.
|
||||
/// The processing of this parameter requires that the current date/time MUST be before the expiration date/time listed in the value.
|
||||
/// Implementers MAY provide for some small leeway, usually no more than a few minutes, to account for clock skew.
|
||||
/// Its value is a JSON number representing the number of seconds from 1970-01-01T0:0:0Z as measured in UTC until the date/time.
|
||||
/// See RFC 3339 [RFC3339] for details regarding date/times in general and UTC in particular.
|
||||
pub exp: u64,
|
||||
/// REQUIRED. Time at which the JWT was issued. Its value is a JSON number representing the number of seconds from 1970-01-01T0:0:0Z as measured in UTC until the date/time.
|
||||
pub iat: u64,
|
||||
/// Time when the End-User authentication occurred.
|
||||
/// Its value is a JSON number representing the number of seconds from 1970-01-01T0:0:0Z as measured in UTC until the date/time.
|
||||
/// When a max_age request is made or when auth_time is requested as an Essential Claim, then this Claim is REQUIRED; otherwise, its inclusion is OPTIONAL.
|
||||
/// (The auth_time Claim semantically corresponds to the OpenID 2.0 PAPE [OpenID.PAPE] auth_time response parameter.)
|
||||
pub auth_time: u64,
|
||||
/// String value used to associate a Client session with an ID Token, and to mitigate replay attacks.
|
||||
/// The value is passed through unmodified from the Authentication Request to the ID Token.
|
||||
/// If present in the ID Token, Clients MUST verify that the nonce Claim Value is equal to the value of the nonce parameter sent in the Authentication Request.
|
||||
/// If present in the Authentication Request, Authorization Servers MUST include a nonce Claim in the ID Token with the Claim Value being the nonce value sent in the Authentication Request.
|
||||
/// Authorization Servers SHOULD perform no other processing on nonce values used.
|
||||
/// The nonce value is a case sensitive string.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub nonce: Option<String>,
|
||||
// /// OPTIONAL. Authentication Context Class Reference. String specifying an Authentication Context Class Reference value that identifies the Authentication Context Class that the authentication performed satisfied. The value "0" indicates the End-User authentication did not meet the requirements of ISO/IEC 29115 [ISO29115] level 1. Authentication using a long-lived browser cookie, for instance, is one example where the use of "level 0" is appropriate. Authentications with level 0 SHOULD NOT be used to authorize access to any resource of any monetary value. (This corresponds to the OpenID 2.0 PAPE [OpenID.PAPE] nist_auth_level 0.) An absolute URI or an RFC 6711 [RFC6711] registered name SHOULD be used as the acr value; registered names MUST NOT be used with a different meaning than that which is registered. Parties using this claim will need to agree upon the meanings of the values used, which may be context-specific. The acr value is a case sensitive string.
|
||||
// pub acr: String,
|
||||
// /// OPTIONAL. Authentication Methods References. JSON array of strings that are identifiers for authentication methods used in the authentication. For instance, values might indicate that both password and OTP authentication methods were used. The definition of particular values to be used in the amr Claim is beyond the scope of this specification. Parties using this claim will need to agree upon the meanings of the values used, which may be context-specific. The amr value is an array of case sensitive strings.
|
||||
// pub amr: String,
|
||||
// /// OPTIONAL. Authorized party - the party to which the ID Token was issued. If present, it MUST contain the OAuth 2.0 Client ID of this party. This Claim is only needed when the ID Token has a single audience value and that audience is different than the authorized party. It MAY be included even when the authorized party is the same as the sole audience. The azp value is a case sensitive string containing a StringOrURI value.
|
||||
// pub azp: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct TokenSuccess {
|
||||
/// The access token issued by the authorization server.
|
||||
access_token: String,
|
||||
/// literally just "Bearer" for us
|
||||
token_type: String,
|
||||
/// OPTIONAL. The refresh token, which can be used to obtain new access tokens based on the grant passed in the corresponding token request.
|
||||
refresh_token: String,
|
||||
/// RECOMMENDED. The lifetime in seconds of the access token. For example, the value 3600 denotes that the access token will expire in one hour from the time the response was generated.
|
||||
/// If omitted, the authorization server SHOULD provide the expiration time via other means or document the default value.
|
||||
expires_in: u64,
|
||||
/// RECOMMENDED, if identical to the scope requested by the client; otherwise, REQUIRED. The scope of the access token as described by Section 3.2.2.1.
|
||||
scope: String,
|
||||
/// OpenID Connect: ID Token value associated with the authenticated session.
|
||||
id_token: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct TokenError {
|
||||
#[serde(rename = "error")]
|
||||
code: TokenErrorCode,
|
||||
|
||||
#[serde(rename = "error_description")]
|
||||
description: String,
|
||||
// OPTIONAL error_uri
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
enum TokenErrorCode {
|
||||
#[serde(rename = "invalid_request")]
|
||||
InvalidRequest,
|
||||
#[serde{rename = "invalid_client"}]
|
||||
InvalidClient,
|
||||
#[serde{rename = "invalid_grant"}]
|
||||
InvalidGrant,
|
||||
#[serde{rename = "unauthorized_client"}]
|
||||
UnauthorisedClient,
|
||||
#[serde{rename = "unsupported_grant_type"}]
|
||||
UnsupportedGrantType,
|
||||
#[serde{rename = "invalid_scope"}]
|
||||
InvalidScope,
|
||||
}
|
Loading…
Reference in New Issue
Block a user