diff --git a/.gitignore b/.gitignore index 607a003..7ac8b5e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ /target *.sqlite* +/oidc-example-app diff --git a/Cargo.lock b/Cargo.lock index d2dcfb0..43ed9e1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -871,6 +871,7 @@ dependencies = [ "axum", "axum-extra", "base32", + "base64", "color-eyre", "hex", "hmac", @@ -878,12 +879,14 @@ dependencies = [ "password-hash", "rand_core", "serde", + "serde_json", "sha1", "sqlx", "time", "tokio", "tracing", "tracing-subscriber", + "url", ] [[package]] diff --git a/README.md b/README.md new file mode 100644 index 0000000..be76edb --- /dev/null +++ b/README.md @@ -0,0 +1,9 @@ +# oh oh + +`git clone https://github.com/dnanexus/oidc-example-app.git` + +specs: + +- OAuth 2.0: https://datatracker.ietf.org/doc/html/rfc6749 +- OIDC 1.0: https://openid.net/specs/openid-connect-core-1_0.html +- OIDC 1.0 Discovery: https://openid.net/specs/openid-connect-discovery-1_0.html diff --git a/idp/Cargo.toml b/idp/Cargo.toml index 2ee4a24..9e3160b 100644 --- a/idp/Cargo.toml +++ b/idp/Cargo.toml @@ -9,6 +9,7 @@ askama = "0.14.0" axum = { version = "0.8.4", features = ["macros"] } axum-extra = { version = "0.10.1", features = ["cookie"] } base32 = "0.5.1" +base64 = "0.22.1" color-eyre = "0.6.5" hex = "0.4.3" hmac = "0.12.1" @@ -16,9 +17,11 @@ jiff = "0.2.15" password-hash = { version = "0.5.0" } rand_core = { version = "0.6.0", features = ["getrandom"] } serde = { version = "1.0.219", features = ["derive"] } +serde_json = "1.0.140" sha1 = "0.10.6" sqlx = { version = "0.8.6", features = ["runtime-tokio", "migrate", "macros", "sqlite"] } time = "0.3.41" tokio = { version = "1.46.1", features = ["full"] } tracing = { version = "0.1.41", features = ["attributes"] } tracing-subscriber = { version = "0.3.19", features = ["env-filter"] } +url = "2.5.4" diff --git a/idp/migrations/20250712175015_users.sql b/idp/migrations/20250712175015_users.sql index 7f65288..95f6d6a 100644 --- a/idp/migrations/20250712175015_users.sql +++ b/idp/migrations/20250712175015_users.sql @@ -2,6 +2,6 @@ CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, -- ensure no IDs are reused username TEXT NOT NULL UNIQUE, password TEXT NOT NULL -); +) STRICT; CREATE INDEX users_username ON users(username); diff --git a/idp/migrations/20250713114447_oauth-clients.sql b/idp/migrations/20250713114447_oauth-clients.sql new file mode 100644 index 0000000..5015c51 --- /dev/null +++ b/idp/migrations/20250713114447_oauth-clients.sql @@ -0,0 +1,21 @@ +CREATE TABLE oauth_clients ( + app_name TEXT NOT NULL UNIQUE, + client_id TEXT NOT NULL PRIMARY KEY, + client_secret TEXT NOT NULL, + redirect_uri TEXT NOT NULL, + client_type TEXT NOT NULL +) STRICT; + +INSERT INTO oauth_clients (app_name, client_id, client_secret, redirect_uri, client_type) +VALUES ('example', 'EUWCM5WHWTWR43AK', 'VC3PLLVMGSVKL4YE3WICL4URJQUC443I', 'http://localhost:3333/callback', 'confidential'); + +CREATE TABLE oauth_codes ( + code TEXT PRIMARY KEY, + client_id TEXT NOT NULL, + created_time_ms INTEGER NOT NULL, + user_id INTEGER NOT NULl, + used INTEGER NOT NULL DEFAULT 0, + + FOREIGN KEY(client_id) REFERENCES oauth_clients(client_id) ON DELETE CASCADE, + FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE +) STRICT; diff --git a/idp/src/main.rs b/idp/src/main.rs index 1b02a85..67a9bf5 100644 --- a/idp/src/main.rs +++ b/idp/src/main.rs @@ -1,14 +1,15 @@ +mod oidc; mod session; mod totp; mod users; -use std::str::FromStr; +use std::{str::FromStr, time::Duration}; use askama::Template; use axum::{ - Form, Router, - extract::State, - http::{HeaderMap, StatusCode}, + Form, Json, Router, + extract::{Query, State}, + http::{HeaderMap, HeaderValue, StatusCode, header}, response::{Html, IntoResponse, Redirect, Response}, routing::{get, post}, }; @@ -16,9 +17,11 @@ use axum_extra::extract::{ CookieJar, cookie::{Cookie, SameSite}, }; +use base64::Engine; use color_eyre::Result; use color_eyre::eyre::Context; use serde::Deserialize; +use serde_json::json; use session::UserSession; use sqlx::{SqlitePool, sqlite::SqliteConnectOptions}; use tracing::{error, info, level_filters::LevelFilter}; @@ -66,9 +69,21 @@ async fn main() -> Result<()> { .route("/2fa/delete", post(delete_2fa)) .route("/add-totp", get(add_totp).post(add_totp_post)) .route("/users", get(users)) + .route( + "/.well-known/openid-configuration", + get(openid_configuration), + ) + .route("/oauth-clients", get(oauth_clients)) + .route( + "/add-oauth-client", + get(add_oauth_client).post(add_oauth_client_post), + ) + .route("/connect/authorize", get(connect_authorize)) + .route("/connect/token", post(connect_token)) + .route("/jwks.json", get(jwks)) .with_state(Db { pool }); - let addr = "0.0.0.0:3000"; + let addr = "0.0.0.0:2999"; let listener = tokio::net::TcpListener::bind(addr) .await .wrap_err("binding listener")?; @@ -510,3 +525,272 @@ async fn login_2fa_post( Ok(Redirect::to("/")) } + +async fn openid_configuration() -> impl IntoResponse { + Json(serde_json::json!({ + "issuer": "http://localhost:2999", + "authorization_endpoint": "http://localhost:2999/connect/authorize", + "token_endpoint": "http://localhost:2999/connect/token", + "userinfo_endpoint": "http://localhost:2999/connect/userinfo", + "jwks_uri": "http://localhost:2999/jwks.json", + "response_types_supported": ["id_token"], + "grant_types_supported": ["authorization_code"], + "id_token_signing_alg_values_supported": ["RS256"] + })) +} + +async fn oauth_clients(State(db): State) -> Result { + let clients = oidc::list_oauth_clients(&db).await.map_err(|err| { + error!(?err, "Failed to list oauth clients"); + StatusCode::INTERNAL_SERVER_ERROR.into_response() + })?; + + #[derive(askama::Template)] + #[template(path = "oauth-clients.html")] + struct OAuthClientsTemplate { + clients: Vec, + } + + Ok(Html(OAuthClientsTemplate { clients }.render().unwrap())) +} + +#[derive(askama::Template)] +#[template(path = "add-oauth-client.html")] +struct AddOAuthClientTemplate { + error: Option, +} + +async fn add_oauth_client() -> impl IntoResponse { + Html(AddOAuthClientTemplate { error: None }.render().unwrap()) +} + +#[derive(Deserialize)] +struct AddOAuthClientForm { + app_name: String, + redirect_uri: String, + client_type: String, +} + +async fn add_oauth_client_post( + State(db): State, + Form(form): Form, +) -> Result { + if let Err(err) = url::Url::parse(&form.redirect_uri) { + return Err(Html( + AddOAuthClientTemplate { + error: Some(format!("invalid redirect URI: {err}")), + } + .render() + .unwrap(), + ) + .into_response()); + } + + oidc::insert_oauth_client(&db, &form.app_name, &form.redirect_uri, &form.client_type) + .await + .map_err(|err| { + error!(?err, "Failed to add oauth client"); + StatusCode::INTERNAL_SERVER_ERROR.into_response() + })?; + + Ok(Redirect::to("/oauth-clients")) +} + +#[derive(Deserialize)] +struct AuthorizeQuery { + client_id: String, + scope: String, + response_type: String, + redirect_uri: Option, + state: Option, +} + +async fn connect_authorize( + user: UserSession, + Query(query): Query, + State(db): State, +) -> Result { + let Some(user) = user.0 else { + return Err(Redirect::to("/login").into_response()); + }; + + let clients = oidc::list_oauth_clients(&db).await.map_err(|err| { + error!(?err, "Failed to add oauth client"); + StatusCode::INTERNAL_SERVER_ERROR.into_response() + })?; + let Some(client) = clients + .iter() + .find(|client| client.client_id == query.client_id) + else { + return Err("invalid client_id".into_response()); + }; + + if query + .redirect_uri + .is_some_and(|redirect_uri| redirect_uri != client.redirect_uri) + { + return Err("invalid redirect_uri".into_response()); + } + + if query.response_type != "code" { + return Err("unsupported response type, must be 'code'".into_response()); + } + + let mut redirect_uri = url::Url::parse(&client.redirect_uri).map_err(|err| { + error!(?err, "invalid redirect URI"); + StatusCode::INTERNAL_SERVER_ERROR.into_response() + })?; + + let code = oidc::generate_string(32); + oidc::insert_code(&db, &code, &query.client_id, user.user_id) + .await + .map_err(|err| { + error!(?err, "Failed to insert oauth authorization code"); + StatusCode::INTERNAL_SERVER_ERROR.into_response() + })?; + + redirect_uri.query_pairs_mut().append_pair("code", &code); + + if let Some(state) = query.state { + redirect_uri.query_pairs_mut().append_pair("state", &state); + } + + Ok(Redirect::to(redirect_uri.as_str())) +} + +#[derive(Deserialize)] +struct ConnectTokenForm { + grant_type: String, + code: String, +} + +async fn connect_token( + headers: HeaderMap, + State(db): State, + Form(form): Form, +) -> Result { + fn authorization(headers: HeaderMap) -> Option<(String, String)> { + let auth = str::from_utf8(headers.get(header::AUTHORIZATION)?.as_bytes()).ok()?; + let token = auth.strip_prefix("Basic ")?; + let parts = base64::prelude::BASE64_STANDARD.decode(token).ok()?; + let mut parts = str::from_utf8(&parts).ok()?.split(':'); + let username = parts.next()?; + let password = parts.next()?; + if !parts.next().is_none() { + return None; + } + Some((username.to_owned(), password.to_owned())) + } + + let Some(auth) = authorization(headers) else { + return Err(( + StatusCode::UNAUTHORIZED, + Json(json!({ + "error": "invalid_client" + })), + ) + .into_response()); + }; + + if form.grant_type != "authorization_code" { + return Err(( + StatusCode::BAD_REQUEST, + Json(json!({ + "error": "invalid_grant" + })), + ) + .into_response()); + } + + let code = oidc::find_code(&db, &form.code, &auth.0, &auth.1) + .await + .map_err(|err| { + error!(?err, "Error finding oauth code"); + StatusCode::INTERNAL_SERVER_ERROR.into_response() + })?; + + let Some(code) = code else { + return Err(( + StatusCode::BAD_REQUEST, + Json(json!({ + "error": "unauthorized_client" + })), + ) + .into_response()); + }; + + oidc::mark_code_as_used(&db, &form.code) + .await + .map_err(|err| { + error!(?err, "Error finding oauth code"); + StatusCode::INTERNAL_SERVER_ERROR.into_response() + })?; + + // TODO verify redirect_uri if present + + // fun https://datatracker.ietf.org/doc/html/rfc7519 + let id_token_headers = base64::prelude::BASE64_URL_SAFE.encode( + serde_json::to_string(&json!({ + "typ": "JWT", + "alg": "RS256" + })) + .unwrap(), + ); + + let id_token_body = base64::prelude::BASE64_URL_SAFE.encode( serde_json::to_string(&json!({ + "iss": "http://localhost:2999", + "sub": code.user_id.to_string(), + "aud": auth.0.to_string(), + "exp": jiff::Timestamp::now().checked_add(Duration::from_secs(3600)).unwrap().as_second(), + "iat": jiff::Timestamp::now().as_second(), + })) + .unwrap()); + + let id_token_signature = "yeet"; + + let id_token = format!("{id_token_headers}.{id_token_body}.{id_token_signature}"); + + Ok(( + [(header::CACHE_CONTROL, HeaderValue::from_static("no-store"))], + Json(json!({ + "access_token": "", + "token_type": "Bearer", + "expires_in": 3600, + "id_token": id_token + })), + )) +} + +fn rsa_key() -> serde_json::Value { + json!({ + "kty": "RSA", + "use": "sig", + "alg": "RS256", + "kid": "1", + "d": "BgjgM5QdqgFmL4DaDQuFW-cLpkUBsvxX2rr4-vy-d71puXN-x8T_NBfZ3oBcjpl2Aghc23ahD5gsiRBqka7BvP30NfHXEjMgBArgYowAM7Qp_e22yiBsAhU1F19ffhymu_1wAngRqHF9kPR8X3_noekffX5KwE660DkYM5l4S6O6TJfZdiynWJrM6PcZqIRRE7ughLEQpfStnDcgmtCgeoGESimnV-8WKD1PQAdJr4oj4hdFLQzXxygVKy1gilWdbMt8R-gUE6Vl22I0Bsw-LY71fG2Yw08_poNStuZ4ZRzLaGuYh5JjM4oUPWn7z-iJoUZmSfwq-zAKRhVNMGwwk79vhCSbd5pKKeCtApvztl7x8HY71M-UY5m4XyZ0aO4d9VkwzpOD9KbaDW4SIoeaYPLZFkVAVsthrh0fTblWV6w1Ko-h8M5IxDvdz8JSvkkxJlz-wucMe86RzeZ4dMOQEM2zv3To6Ru6nPXwzN5YGCvvGNUJGAPbrNDRAq4GVNKlIBPJUK0KkLHDzHuvsK-m30wOFmUlSpely-M9Rhq4wVBhg_o7zeeUCe9aNaeqTtwol7SO23n2qqzznoPUOQEIJg8h2lLW5gh-uNbRRfQh2oOvFsQVQZPZDtaxs7XhZYywxe3XiZ5wP4J3S8gyb9an6zvGsYAHKt7tg69LdrAv6JE", + "n": "o36zvtfPixDY1T_Eg2TlzJwn7kpJ3iOlvFtn0FkM20PHlC8DM_A6iGtOdNwGPWH0uoJ5r03W9knh3S1cnFf9Apz3NJoxvmgojNjIYPrkxU3AgzJYPpE2Icg_Iqe5dqY9qQbg-3Uqfdih9x2qs02xPmnD9FX1ewYmn15WpDYsWpeKWi6SC91y9R15kpL02PJOG2DcKGHte25VZIJ3ysrnrGULgF1J7kQp1j87TMho266pj_GHmcV1XThw8mfk4JXUBMC47KvdQkyRR6cKlT9IeMPk18s2LKk3UsFflkvpGMi04l5Jx8vNZ_QfsewFtA1JG5_ce3HeRzmepoeYeF4U9ClDRKRslqOBPcx-aMB3gE6nwMA2JYt8xtCxjoptSfZAeyoOLd4s739tpxwjpiXzcuNOCvhYN_zOzdaMZCTJJ1E5UQpDdw8-9u3ZR7ZPlJFY6uVA6EJ6kOz4EKBPw2A_MWoRS0SjFokPGWfOaaccONNE8Gks2tWZtGxFXXW63JTS8T9LcZ-6mwan4XRhVGUUynBCLsv6wplVhH1kOkbIXDpyfpembOhdFQe9NuwZpdutIbi2DPFfYmrEr3xyGEteR69WGo7OyIGGL6His46xs5YBj_X3_aDltz-jYI11OdfQj2XmiBI8PIAzgYKULLPfrupgFaswsJzPVUK8vrpdE58", + "e": "AQAB", + "p": "wyT2tVnlFA_mAD3SatHxaoD5OsE7kzxQb9rNx1PZ5GiK9fcyRE6xfS4pv_5RsvqHgwgZYNaydMytsg7Uq2gcQCA7gAyKD56ROw6nQCt2Tnv2US1Lu4s_DnUlQYdAaWwOYWCSOyrMH1TxtgCzveT9qO1PAknzyWJyb4wm9JMyzFdKh_Ck3nCO9_YhwmhYg3H1eT2cB-mdIG2ULc7yB8gvLccOqDea_C0LNur_iPTs9xwQnIOkD3GSKetWaHyXq2EnhFCoPStWZjUmItIrbEelcFOIIXTUWKk5eAQtOfmBjrEEimHktmVZNSppooD8zYq9cIxjyfjMfeneBDtGdK_xCQ", + "q": "1nsOGyOanwY5-JtAnf_m3wnzbAbY40bLB6DLQe3LqWb4Ow7b0XpSdEHJwV0_8jA1pNi3PouSDHSvj8G7Af9BncL2w9aTO1v6G_sHvHbP2U49RHrpRepBWrCWd2dV_CJdKJef4s2xURk44tPebZyPggvGo77qVWBal-MRkQcwnJHMgaht5QisP1LLSPjWswMkPQkuoIqqZlhFtgIkyz0hLqil1CbylU7i1ExXko8GT2fp8AG5iwdAJ6FrwyIuDTAgI2kx5tVpEdFfRDY7J2icbhvVVOFihkpjWUp-nVcp1K1ksaqU9_N3lu902L_lYusFMTxvqQ7yL5OYY4e3K-WRZw", + "dp": "PbJCDbQOKPmdzhW9oOgfW3zLTzgojbRT-glDZfGswfoLdRhiXBZFJz6hFIJjciKjFVpKK8O1SBguEk1-D3Mq-1s1dJaCT83iPLm1RyR2kvm-NowLlY_Ar-F5le4c_zealE7j7LDrODyy7sfqC--KAw6EHEUlPlZRt9KnvkuLk-9FMRV0Cp-rk9nNcplq4qP06BACdL33X3lFj_YNr0grIl381FJAPdo_4W0KvVIyWS4WUmWMSRWvEHHHL-G0Ugq1Y6_cgPpipo3HMNshv2ondAv0zh8Rw7Y85STs55dqzqJIvTeWB9SjD5wJKcd-Jb3nht3b7s8qV-TIvK3A6MN3gQ", + "dq": "Gl1WBpAB2bpyNdUfxExInPIkMgtFbeqt2moxkhEhD9nQebIB42Yd7JyJqHNGAQdcEL9zBwUxFsbhLdKqojw2XKYynzApOQq9W-MnuEsCkbvEXD6fnjCFiBhc5qCVOUEgInVA-ig-u7FWBMv2c5LjMSExcb9uHsCRYkpPRnyTxStG8Ek7-QNv6PjMdFPiUG76bWZLjQB-ocYIC6-HxlPlWE7y03lWKHRh_abEvQdHx0sGvrH3lNd3U2fMT1hMQOLBkJjFwZJKMB6Ej2X7L4T0dbSGLMDn04ohXECD_-NPCQ2naw-E8FXFRZB51IsCL36kTMEZGLb1nlOOT-3G3maB0Q", + "qi": "gakrrA_MsPcjAFsE_I9amgoTI4pLe3Da3WAA24iBMNBv6M0xZV7GGKv0pMvSfZzsQeQH4eqZwbSRjLeUz9dU4eW02k9RvfASImvyCyhstAj6oGtrqcKuPOR9n4Wci0tXbRawbXhDR7y6Kyj7LHEketqJGVciGmYgcZEC017LOR0lJhcb_WwgcFnqBa2qx6wYknI6EsTyaxjJzTm1bPusi8oe5RQ_-SqG36yfPBdjNLDm0XvNRXZkQC26MzESL4AU-dakUvFsUl7WG8lIevponmooNlR0KTVmCJE9fM5H8dap_CyrPfDtUxm75YBPuk5EvZNShyo6JdN7eltT-5JRCQ" + }) +} + +async fn jwks() -> impl IntoResponse { + // https://datatracker.ietf.org/doc/html/rfc7517 + + let key = rsa_key(); + + Json(json!({ + "keys": [{ + "kty": "RSA", + "use": "sig", + "alg": "RS256", + "kid": "1", + "n": key["n"], + "e": key["e"] + }], + })) +} diff --git a/idp/src/oidc.rs b/idp/src/oidc.rs new file mode 100644 index 0000000..4529a74 --- /dev/null +++ b/idp/src/oidc.rs @@ -0,0 +1,110 @@ +use std::time::Duration; + +use crate::Db; +use color_eyre::{Result, eyre::Context}; +use rand_core::RngCore; + +pub fn generate_string(length: usize) -> String { + let mut bytes = vec![0_u8; length]; + rand_core::OsRng.fill_bytes(&mut bytes); + base32::encode(base32::Alphabet::Rfc4648 { padding: false }, &bytes) +} + +pub async fn insert_oauth_client( + db: &Db, + app_name: &str, + redirect_uri: &str, + client_type: &str, +) -> Result<()> { + let client_id = generate_string(10); + let client_secret = generate_string(20); + + sqlx::query( + "insert into oauth_clients (app_name, client_id, client_secret, redirect_uri, client_type)\ + values (?, ?, ?, ?, ?)", + ) + .bind(app_name) + .bind(client_id) + .bind(client_secret) + .bind(redirect_uri) + .bind(client_type) + .execute(&db.pool) + .await + .wrap_err("inserting oauth client")?; + Ok(()) +} + +#[derive(sqlx::FromRow)] +pub struct OAuthClient { + pub app_name: String, + pub client_id: String, + pub client_secret: String, + pub redirect_uri: String, + pub client_type: String, +} + +pub async fn list_oauth_clients(db: &Db) -> Result> { + sqlx::query_as( + "select app_name, client_id, client_secret, redirect_uri, client_type from oauth_clients", + ) + .fetch_all(&db.pool) + .await + .wrap_err("fetching oauth clients") +} + +pub async fn insert_code(db: &Db, code: &str, client_id: &str, user_id: i64) -> Result<()> { + sqlx::query( + "insert into oauth_codes (code, client_id, created_time_ms, user_id)\ + values (?, ?, ?, ?)", + ) + .bind(code) + .bind(client_id) + .bind(jiff::Timestamp::now().as_millisecond()) + .bind(user_id) + .execute(&db.pool) + .await + .wrap_err("inserting oauth client")?; + Ok(()) +} + +#[derive(sqlx::FromRow)] +pub struct OAuthCode { + pub user_id: i64, +} + +pub async fn find_code( + db: &Db, + code: &str, + client_id: &str, + client_secret: &str, +) -> Result> { + let min_created_time_ms = jiff::Timestamp::now() + .checked_sub(Duration::from_secs(60)) + .unwrap() + .as_millisecond(); + let result = sqlx::query_as::<_, OAuthCode>( + "select user_id from oauth_codes \ + inner join oauth_clients on oauth_clients.client_id = oauth_codes.client_id and oauth_clients.client_secret = ? \ + where code = ? and oauth_codes.client_id = ? and created_time_ms > ? and used = 0", + ) + .bind(client_secret) + .bind(code) + .bind(client_id) + .bind(min_created_time_ms) + .fetch_one(&db.pool) + .await; + match result { + Ok(code) => Ok(Some(code)), + Err(sqlx::Error::RowNotFound) => Ok(None), + Err(e) => Err(e).wrap_err("failed to fetch code"), + } +} + +pub async fn mark_code_as_used(db: &Db, code: &str) -> Result<()> { + sqlx::query("update oauth_codes set used = 1 where code = ?") + .bind(code) + .execute(&db.pool) + .await + .wrap_err("inserting oauth client")?; + Ok(()) +} diff --git a/idp/templates/add-oauth-client.html b/idp/templates/add-oauth-client.html new file mode 100644 index 0000000..4c71d7c --- /dev/null +++ b/idp/templates/add-oauth-client.html @@ -0,0 +1,38 @@ + + + + + + Login - IDP + + + +

Register a new OAuth client

+ home +
+
+ + +
+ +
+ + +
+ +
+ + +
+ + + + {% if let Some(error) = error %} +

error: {{error}}

+ {% endif %} +
+ + diff --git a/idp/templates/index.html b/idp/templates/index.html index c66dcb5..e9181ed 100644 --- a/idp/templates/index.html +++ b/idp/templates/index.html @@ -9,9 +9,17 @@

Your favorite identity provider

home +

OAuth Config

+ + {% if let Some(username) = username %}

Hello, {{username}}!

{% endif %} +

Login

@@ -22,6 +30,7 @@ List all users {% if let Some(username) = username %} +

Account

diff --git a/idp/templates/oauth-clients.html b/idp/templates/oauth-clients.html new file mode 100644 index 0000000..cbbef1c --- /dev/null +++ b/idp/templates/oauth-clients.html @@ -0,0 +1,36 @@ + + + + + + OAuth Clients - IDP + + + +

See OAuth clients registered

+ home + + + + + + + + + + + + + {% for client in clients %} + + + + + + + + {% endfor %} + +
App NameClient IDRedirect URIClient TypeClient Secret
{{ client.app_name }}{{ client.client_id }}{{ client.redirect_uri }}{{ client.client_type }}{{ client.client_secret }}
+ + diff --git a/idp/templates/style.css b/idp/templates/style.css index c82554f..2e3badc 100644 --- a/idp/templates/style.css +++ b/idp/templates/style.css @@ -3,6 +3,10 @@ body { width: 100%; } +html { + font-family: sans-serif; +} + @media (prefers-color-scheme: dark) { body { background-color: rgb(41, 41, 41); @@ -53,3 +57,17 @@ form:not(.fake-form) { .fake-form { display: inline-block; } + +table { + th { + text-align: left; + } + + th, td { + padding-bottom: 10px; + } + + :is(td, th):not(:last-child) { + padding-right: 10px; + } +} \ No newline at end of file