mirror of
https://forgejo.ellis.link/continuwuation/continuwuity/
synced 2026-07-04 23:21:37 +00:00
Compare commits
42 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 96c069cd67 | |||
| 9a38395f0a | |||
| fdbb1c86b1 | |||
| 17f8ec21a3 | |||
| c46104ac0c | |||
| 900ba33a4e | |||
| f2e696ae3e | |||
| 1088aa5020 | |||
| 5c2a5de7d3 | |||
| 600b8cb366 | |||
| 3bbbfcdd46 | |||
| e9b387414f | |||
| 426b113c30 | |||
| ddae8af99f | |||
| 769db9b818 | |||
| 27d9d1d78d | |||
| 2e34ac9f59 | |||
| 95632103bc | |||
| d916bb9f21 | |||
| 4e50064740 | |||
| 56bd11013f | |||
| 8893bd1613 | |||
| e7b9446b5a | |||
| bc23071dd4 | |||
| b602be0921 | |||
| 0c333c9a05 | |||
| b9b3a466f4 | |||
| 3803f06392 | |||
| cadcbd7d49 | |||
| 144036f58b | |||
| 2afe656e12 | |||
| c89dfe38da | |||
| 1ce1254514 | |||
| 5bd1bedad0 | |||
| 68ca6eabe3 | |||
| e75f5cbbed | |||
| 97c692b052 | |||
| a586ea390c | |||
| dba528a5e0 | |||
| aafc93f6fb | |||
| cd0c3886fb | |||
| be4ccbc11b |
@@ -7,3 +7,6 @@ f419c64aca300a338096b4e0db4c73ace54f23d0
|
||||
5998a0d883d31b866f7c8c46433a8857eae51a89
|
||||
# trailing whitespace and newlines
|
||||
46c193e74b2ce86c48ce802333a0aabce37fd6e9
|
||||
|
||||
# Formatting PRs
|
||||
fd972f114293ea1be9633b750a703edd661e970d
|
||||
|
||||
Generated
+176
@@ -50,6 +50,15 @@ dependencies = [
|
||||
"alloc-no-stdlib",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "android_system_properties"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstream"
|
||||
version = "0.6.21"
|
||||
@@ -147,6 +156,12 @@ version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236"
|
||||
|
||||
[[package]]
|
||||
name = "arrayref"
|
||||
version = "0.3.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb"
|
||||
|
||||
[[package]]
|
||||
name = "arrayvec"
|
||||
version = "0.7.6"
|
||||
@@ -612,6 +627,17 @@ dependencies = [
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "blake2b_simd"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "06e903a20b159e944f91ec8499fe1e55651480c541ea0a584f5d967c49ad9d99"
|
||||
dependencies = [
|
||||
"arrayref",
|
||||
"arrayvec",
|
||||
"constant_time_eq",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.10.4"
|
||||
@@ -777,7 +803,9 @@ version = "0.4.42"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2"
|
||||
dependencies = [
|
||||
"iana-time-zone",
|
||||
"num-traits",
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -966,6 +994,7 @@ dependencies = [
|
||||
"bytes",
|
||||
"conduwuit_core",
|
||||
"conduwuit_service",
|
||||
"conduwuit_web",
|
||||
"const-str",
|
||||
"ctor",
|
||||
"futures",
|
||||
@@ -976,6 +1005,8 @@ dependencies = [
|
||||
"ipaddress",
|
||||
"itertools 0.14.0",
|
||||
"log",
|
||||
"oxide-auth",
|
||||
"percent-encoding",
|
||||
"rand 0.8.5",
|
||||
"reqwest",
|
||||
"ruma",
|
||||
@@ -1142,6 +1173,8 @@ dependencies = [
|
||||
"log",
|
||||
"loole",
|
||||
"lru-cache",
|
||||
"once_cell",
|
||||
"oxide-auth",
|
||||
"rand 0.8.5",
|
||||
"recaptcha-verify",
|
||||
"regex",
|
||||
@@ -1165,13 +1198,19 @@ name = "conduwuit_web"
|
||||
version = "0.5.0-rc.8"
|
||||
dependencies = [
|
||||
"askama",
|
||||
"async-trait",
|
||||
"axum 0.7.9",
|
||||
"conduwuit_build_metadata",
|
||||
"conduwuit_core",
|
||||
"conduwuit_service",
|
||||
"futures",
|
||||
"oxide-auth",
|
||||
"percent-encoding",
|
||||
"rand 0.8.5",
|
||||
"serde",
|
||||
"thiserror 2.0.17",
|
||||
"tracing",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1235,6 +1274,12 @@ dependencies = [
|
||||
"typewit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "constant_time_eq"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6"
|
||||
|
||||
[[package]]
|
||||
name = "convert_case"
|
||||
version = "0.7.1"
|
||||
@@ -2346,6 +2391,30 @@ dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iana-time-zone"
|
||||
version = "0.1.63"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b0c919e5debc312ad217002b8048a17b7d83f80703865bbfcfebb0458b0b27d8"
|
||||
dependencies = [
|
||||
"android_system_properties",
|
||||
"core-foundation-sys",
|
||||
"iana-time-zone-haiku",
|
||||
"js-sys",
|
||||
"log",
|
||||
"wasm-bindgen",
|
||||
"windows-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iana-time-zone-haiku"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
|
||||
dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_collections"
|
||||
version = "2.0.0"
|
||||
@@ -3299,6 +3368,27 @@ dependencies = [
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "oxide-auth"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9c136ac668d12ba0b5b8ce159b95c7600fda826dc599a1e1916f8461c0d16a84"
|
||||
dependencies = [
|
||||
"base64 0.21.7",
|
||||
"chrono",
|
||||
"hmac",
|
||||
"once_cell",
|
||||
"rand 0.8.5",
|
||||
"rmp-serde",
|
||||
"rust-argon2",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"subtle",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "parking"
|
||||
version = "2.2.1"
|
||||
@@ -3997,6 +4087,28 @@ dependencies = [
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rmp"
|
||||
version = "0.8.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "228ed7c16fa39782c3b3468e974aec2795e9089153cd08ee2e9aefb3613334c4"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
"num-traits",
|
||||
"paste",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rmp-serde"
|
||||
version = "1.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "52e599a477cf9840e92f2cde9a7189e67b42c57532749bf90aea6ec10facd4db"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
"rmp",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "roff"
|
||||
version = "0.2.2"
|
||||
@@ -4199,6 +4311,17 @@ dependencies = [
|
||||
"thiserror 2.0.17",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rust-argon2"
|
||||
version = "2.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9d9848531d60c9cbbcf9d166c885316c24bc0e2a9d3eba0956bb6cbbd79bc6e8"
|
||||
dependencies = [
|
||||
"base64 0.21.7",
|
||||
"blake2b_simd",
|
||||
"constant_time_eq",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rust-librocksdb-sys"
|
||||
version = "0.39.0+10.5.1"
|
||||
@@ -5866,6 +5989,41 @@ version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
|
||||
|
||||
[[package]]
|
||||
name = "windows-core"
|
||||
version = "0.61.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3"
|
||||
dependencies = [
|
||||
"windows-implement",
|
||||
"windows-interface",
|
||||
"windows-link 0.1.3",
|
||||
"windows-result",
|
||||
"windows-strings",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-implement"
|
||||
version = "0.60.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-interface"
|
||||
version = "0.59.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-link"
|
||||
version = "0.1.3"
|
||||
@@ -5878,6 +6036,24 @@ version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.3.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6"
|
||||
dependencies = [
|
||||
"windows-link 0.1.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-strings"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57"
|
||||
dependencies = [
|
||||
"windows-link 0.1.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.48.0"
|
||||
|
||||
+12
@@ -103,6 +103,9 @@ features = [
|
||||
"matched-path",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"query",
|
||||
# Needed for debug_handler.
|
||||
#"macros",
|
||||
]
|
||||
|
||||
[workspace.dependencies.axum-extra]
|
||||
@@ -369,6 +372,7 @@ features = [
|
||||
"unstable-msc2666",
|
||||
"unstable-msc2867",
|
||||
"unstable-msc2870",
|
||||
"unstable-msc2965",
|
||||
"unstable-msc3026",
|
||||
"unstable-msc3061",
|
||||
"unstable-msc3245",
|
||||
@@ -557,6 +561,14 @@ features = ["sync", "tls-rustls", "rustls-provider"]
|
||||
[workspace.dependencies.resolv-conf]
|
||||
version = "0.7.5"
|
||||
|
||||
[workspace.dependencies.oxide-auth]
|
||||
version = "0.6.1"
|
||||
|
||||
[workspace.dependencies.once_cell]
|
||||
version = "1.21.3"
|
||||
|
||||
[workspace.dependencies.percent-encoding]
|
||||
version = "2.3.1"
|
||||
#
|
||||
# Patches
|
||||
#
|
||||
|
||||
+20
-1
@@ -23,7 +23,8 @@
|
||||
# See the docs for reverse proxying and delegation:
|
||||
# https://continuwuity.org/deploying/generic.html#setting-up-the-reverse-proxy
|
||||
#
|
||||
# Also see the `[global.well_known]` config section at the very bottom.
|
||||
# Also see the `[global.auth]` and `[global.well_known]` config sections
|
||||
# at the very bottom.
|
||||
#
|
||||
# Examples of delegation:
|
||||
# - https://puppygock.gay/.well-known/matrix/server
|
||||
@@ -1754,6 +1755,24 @@
|
||||
#
|
||||
#dual_protocol = false
|
||||
|
||||
[global.auth]
|
||||
|
||||
# Use this homeserver as the OIDC authentication reference. It will
|
||||
# advertise itself as the OIDC authentication issuer to new clients,
|
||||
# and use the internal user database to answer on the advertised
|
||||
# endpoints. Note that the legacy Matrix authentication still will be
|
||||
# reachable.
|
||||
# Unset by default.
|
||||
#
|
||||
#enable_oidc_login =
|
||||
|
||||
# Whether this homeserver should provide users with an account management
|
||||
# interface. Only used if `enable_oidc_login` is set. Note that the
|
||||
# endpoint is unimplemented at the moment.
|
||||
# Unset by default.
|
||||
#
|
||||
#enable_oidc_account_management =
|
||||
|
||||
[global.well_known]
|
||||
|
||||
# The server URL that the client well-known file will serve. This should
|
||||
|
||||
@@ -94,6 +94,9 @@ sha1.workspace = true
|
||||
tokio.workspace = true
|
||||
tracing.workspace = true
|
||||
ctor.workspace = true
|
||||
oxide-auth.workspace = true
|
||||
conduwuit-web.workspace = true
|
||||
percent-encoding.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -75,7 +75,9 @@ pub(crate) async fn upload_keys_route(
|
||||
}
|
||||
if deser_device_keys.device_id != sender_device {
|
||||
return Err!(Request(Unknown(
|
||||
"Device ID in keys uploaded does not match your own device ID"
|
||||
"Device ID in keys uploaded ({}) does not match your own device ID ({})",
|
||||
deser_device_keys.device_id,
|
||||
sender_device,
|
||||
)));
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
pub(super) mod media_legacy;
|
||||
pub(super) mod membership;
|
||||
pub(super) mod message;
|
||||
pub(super) mod oidc;
|
||||
pub(super) mod openid;
|
||||
pub(super) mod presence;
|
||||
pub(super) mod profile;
|
||||
@@ -58,6 +59,7 @@
|
||||
pub(super) use membership::*;
|
||||
pub use membership::{join_room_by_id_helper, leave_all_rooms, leave_room, remote_leave_room};
|
||||
pub(super) use message::*;
|
||||
pub(super) use oidc::*;
|
||||
pub(super) use openid::*;
|
||||
pub(super) use presence::*;
|
||||
pub(super) use profile::*;
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
use axum::extract::{Query, State};
|
||||
use conduwuit::{Result, err, utils::ReadyExt};
|
||||
use conduwuit_web::oidc::{
|
||||
AuthorizationQuery, OidcRequest, OidcResponse, oidc_consent_form, oidc_login_form,
|
||||
};
|
||||
use oxide_auth::{
|
||||
endpoint::{OwnerConsent, Solicitation},
|
||||
frontends::simple::endpoint::FnSolicitor,
|
||||
};
|
||||
use percent_encoding::percent_decode_str;
|
||||
use ruma::UserId;
|
||||
use service::oidc::{SCOPE_PREFIX_API, SCOPE_PREFIX_DEVICE};
|
||||
|
||||
/// # `GET /_matrix/client/unstable/org.matrix.msc2964/authorize`
|
||||
///
|
||||
/// Authenticate a user and device, and solicit the user's consent.
|
||||
///
|
||||
/// Redirects to the login page if no token or token not belonging to any user.
|
||||
/// [super::login::oidc_login] takes it up at the same point, so it's either
|
||||
/// the client has a token, or the user does user password. Then the user gets
|
||||
/// access to stage two, [authorize_consent].
|
||||
pub(crate) async fn authorize(
|
||||
State(services): State<crate::State>,
|
||||
Query(query): Query<AuthorizationQuery>,
|
||||
oauth: OidcRequest,
|
||||
) -> Result<OidcResponse> {
|
||||
tracing::trace!("processing OAuth request: {query:#?}");
|
||||
// Enforce MSC2964's restrictions on OAuth2 flow.
|
||||
let Ok(scope) = percent_decode_str(&query.scope).decode_utf8() else {
|
||||
return Err(err!(Request(Unknown("the scope could not be percent-decoded"))));
|
||||
};
|
||||
if !scope.contains(&format!("{SCOPE_PREFIX_API}*")) {
|
||||
return Err(err!(Request(Unknown("the scope does not include the client API"))));
|
||||
}
|
||||
if !scope.contains(SCOPE_PREFIX_DEVICE) {
|
||||
return Err(err!(Request(Unknown("the scope does not include a device ID"))));
|
||||
}
|
||||
if query.code_challenge_method != "S256" {
|
||||
return Err(err!(Request(Unknown("unsupported code challenge method"))));
|
||||
}
|
||||
|
||||
// Redirect to the login page if no token or token not known.
|
||||
let hostname = services.config.server_name.host();
|
||||
let Some(token) = oauth.authorization_header() else {
|
||||
return Ok(oidc_login_form(hostname, &query));
|
||||
};
|
||||
|
||||
tracing::debug!("submitting OIDC authorisation for token : {token:#?}");
|
||||
// Get the user id from the token and add it to the query.
|
||||
let (owner_id, _) = services.oidc.user_and_device_from_token(token).await?;
|
||||
let mut query_with_user_id = query.clone();
|
||||
query_with_user_id.username = Some(owner_id.localpart().to_owned());
|
||||
|
||||
services
|
||||
.oidc
|
||||
.endpoint()
|
||||
.with_solicitor(oidc_consent_form(hostname, &query_with_user_id))
|
||||
.authorization_flow()
|
||||
.execute(oauth)
|
||||
.map_err(|err| err!("authorization failed: {err:?}"))
|
||||
}
|
||||
|
||||
/// Whether a user allows their device to access this homeserver's resources.
|
||||
#[derive(serde::Deserialize)]
|
||||
pub(crate) struct Allowance {
|
||||
allow: Option<String>,
|
||||
}
|
||||
|
||||
/// # `POST /_matrix/client/unstable/org.matrix.msc2964/authorize?allow=[Option<String>]`
|
||||
///
|
||||
/// Authorize the device based on the owner's consent. If the owner allows
|
||||
/// it to access their data, the client may request a token at the
|
||||
/// [super::token::token] endpoint.
|
||||
///
|
||||
/// On the owner's consent, if their specific device is unregistered it will be
|
||||
/// registered in their device list (not to be confused with the OIDC client
|
||||
/// registration).
|
||||
pub(crate) async fn authorize_consent(
|
||||
Query(Allowance { allow }): Query<Allowance>,
|
||||
State(services): State<crate::State>,
|
||||
Query(query): Query<AuthorizationQuery>,
|
||||
oauth: OidcRequest,
|
||||
) -> Result<OidcResponse> {
|
||||
tracing::debug!("processing owner's consent: {:?}", allow);
|
||||
tracing::trace!("owner's consent request: {:#?}", query);
|
||||
let Some(owner_id) = allow.clone() else {
|
||||
return Err(err!(Request(Unknown("the owner did not consent to the client's access"))));
|
||||
};
|
||||
let server_name = services.globals.server_name();
|
||||
let owner_id = UserId::parse_with_server_name(owner_id.clone(), server_name)
|
||||
.map_err(|err| err!(Request(InvalidUsername("invalid username {owner_id:?}: {err}"))))?;
|
||||
let Some(matrix_client) = services
|
||||
.oidc
|
||||
.client_from_client_id(&query.client_id)
|
||||
.await?
|
||||
else {
|
||||
return Err(err!(Request(Unknown(
|
||||
"no client has registered client_id {:?}",
|
||||
query.client_id
|
||||
))));
|
||||
};
|
||||
let scope = query.scope.parse().map_err(|err| {
|
||||
err!(Request(Unknown("could not parse scope {:?}: {}", query.scope, err)))
|
||||
})?;
|
||||
let device_id = services.oidc.device_id_from_scope(&scope)?;
|
||||
// Check that the device is registered in the owner devices list.
|
||||
// Note that this is _not_ the OIDC client registration.
|
||||
let device_is_registered_with_owner = services
|
||||
.users
|
||||
.all_device_ids(&owner_id)
|
||||
.ready_any(|v| v == device_id)
|
||||
.await;
|
||||
if !device_is_registered_with_owner {
|
||||
// TODO get the client's IP from the request.
|
||||
let client_ip = None;
|
||||
services
|
||||
.oidc
|
||||
.register_device(
|
||||
&query.client_id,
|
||||
(&owner_id, &device_id),
|
||||
matrix_client.name.as_deref(),
|
||||
client_ip,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
services
|
||||
.oidc
|
||||
.endpoint()
|
||||
.with_solicitor(FnSolicitor(move |_: &mut _, _: Solicitation<'_>| match allow.clone() {
|
||||
| None => OwnerConsent::Denied,
|
||||
| Some(user_id) => OwnerConsent::Authorized(user_id),
|
||||
}))
|
||||
.authorization_flow()
|
||||
.execute(oauth)
|
||||
.map_err(|err| err!(Request(Unknown("consent request failed: {err:?}"))))
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
/// Manual implementation of [MSC2965]'s OIDC server discovery.
|
||||
///
|
||||
/// [MSC2965]: https://github.com/matrix-org/matrix-spec-proposals/pull/2965
|
||||
use axum::extract::State;
|
||||
use conduwuit::Result;
|
||||
use ruma::{
|
||||
api::client::{
|
||||
discovery::get_authorization_server_metadata::msc2965::{
|
||||
self, AccountManagementAction, AuthorizationServerMetadata, CodeChallengeMethod,
|
||||
GrantType, Prompt, ResponseMode, ResponseType,
|
||||
},
|
||||
error::{
|
||||
Error as ClientError, ErrorBody as ClientErrorBody, ErrorKind as ClientErrorKind,
|
||||
},
|
||||
},
|
||||
serde::Raw,
|
||||
};
|
||||
|
||||
use crate::{Ruma, RumaResponse, conduwuit::Error};
|
||||
|
||||
/// # `GET /_matrix/client/unstable/org.matrix.msc2965/auth_metadata`
|
||||
///
|
||||
/// If `globals.auth.enable_oidc_login` is set, advertise this homeserver's
|
||||
/// OAuth2 endpoints. Otherwise, MSC2965 requires that the homeserver responds
|
||||
/// with 404/M_UNRECOGNIZED.
|
||||
pub(crate) async fn get_auth_metadata(
|
||||
State(services): State<crate::State>,
|
||||
_body: Ruma<msc2965::Request>,
|
||||
) -> Result<RumaResponse<msc2965::Response>> {
|
||||
let unrecognized_error = Err(Error::Ruma(ClientError::new(
|
||||
http::StatusCode::NOT_FOUND,
|
||||
ClientErrorBody::Standard {
|
||||
kind: ClientErrorKind::Unrecognized,
|
||||
message: "This homeserver has disabled OIDC authentication.".to_owned(),
|
||||
},
|
||||
)));
|
||||
let Some(ref auth) = services.server.config.auth else {
|
||||
return unrecognized_error;
|
||||
};
|
||||
if !auth.enable_oidc_login {
|
||||
return unrecognized_error;
|
||||
}
|
||||
// Advertise this homeserver's access URL as the issuer URL.
|
||||
// Unwrap all Url::parse() calls because the issuer URL is validated at startup.
|
||||
let issuer = services.server.config.well_known.client.as_ref().unwrap();
|
||||
let account_management_uri = auth.enable_oidc_account_management.then_some(
|
||||
issuer
|
||||
.join("/_matrix/client/unstable/org.matrix.msc2964/account")
|
||||
.unwrap(),
|
||||
);
|
||||
|
||||
// Build up metadata with primitives from ruma::api::client::msc2965.
|
||||
let metadata = AuthorizationServerMetadata {
|
||||
issuer: issuer.clone(),
|
||||
authorization_endpoint: issuer
|
||||
.join("/_matrix/client/unstable/org.matrix.msc2964/authorize")
|
||||
.unwrap(),
|
||||
device_authorization_endpoint: Some(
|
||||
issuer
|
||||
.join("/_matrix/client/unstable/org.matrix.msc2964/device")
|
||||
.unwrap(),
|
||||
),
|
||||
token_endpoint: issuer
|
||||
.join("/_matrix/client/unstable/org.matrix.msc2964/token")
|
||||
.unwrap(),
|
||||
registration_endpoint: Some(
|
||||
issuer
|
||||
.join("/_matrix/client/unstable/org.matrix.msc2964/device/register")
|
||||
.unwrap(),
|
||||
),
|
||||
revocation_endpoint: issuer
|
||||
.join("/_matrix/client/unstable/org.matrix.msc2964/revoke")
|
||||
.unwrap(),
|
||||
response_types_supported: [ResponseType::Code].into(),
|
||||
grant_types_supported: [GrantType::AuthorizationCode, GrantType::RefreshToken].into(),
|
||||
response_modes_supported: [ResponseMode::Fragment, ResponseMode::Query].into(),
|
||||
code_challenge_methods_supported: [CodeChallengeMethod::S256].into(),
|
||||
account_management_uri,
|
||||
account_management_actions_supported: [
|
||||
AccountManagementAction::Profile,
|
||||
AccountManagementAction::SessionView,
|
||||
AccountManagementAction::SessionEnd,
|
||||
]
|
||||
.into(),
|
||||
prompt_values_supported: match services.server.config.allow_registration {
|
||||
| true => vec![Prompt::Create],
|
||||
| false => vec![],
|
||||
},
|
||||
};
|
||||
let metadata = Raw::new(&metadata).expect("authorization server metadata should serialize");
|
||||
|
||||
Ok(RumaResponse(msc2965::Response::new(metadata)))
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
use axum::extract::State;
|
||||
use conduwuit::{Result, err, utils::hash::verify_password};
|
||||
use conduwuit_web::oidc::{LoginError, LoginQuery, OidcRequest, OidcResponse, oidc_consent_form};
|
||||
use ruma::user_id::UserId;
|
||||
|
||||
//#[axum::debug_handler]
|
||||
/// # `POST /_matrix/client/unstable/org.matrix.msc2964/login`
|
||||
///
|
||||
/// Display a login UI to the user and return an authorization code on success.
|
||||
/// We presume that the OAuth2 query parameters are provided in the form.
|
||||
/// With the code, the client may then access stage two,
|
||||
/// [super::authorize::authorize_consent].
|
||||
pub(crate) async fn oidc_login(
|
||||
State(services): State<crate::State>,
|
||||
request: OidcRequest,
|
||||
) -> Result<OidcResponse> {
|
||||
let query: LoginQuery = request.clone().try_into().map_err(|LoginError(err)| {
|
||||
err!(Request(InvalidParam("Cannot process login form. {err}")))
|
||||
})?;
|
||||
tracing::trace!("processing login query {:#?}", query.clone());
|
||||
// Only accept local usernames. Mostly to simplify things at first.
|
||||
let user_id =
|
||||
UserId::parse_with_server_name(query.username.clone(), &services.config.server_name)
|
||||
.map_err(|e| err!(Request(InvalidUsername("Username is invalid: {e}"))))?;
|
||||
|
||||
if !services.users.exists(&user_id).await {
|
||||
return Err(err!(Request(Unknown("unknown username"))));
|
||||
}
|
||||
let valid_hash = services.users.password_hash(&user_id).await?;
|
||||
|
||||
if valid_hash.is_empty() {
|
||||
return Err(err!(Request(UserDeactivated("the user's hash was not found"))));
|
||||
}
|
||||
if verify_password(&query.password, &valid_hash).is_err() {
|
||||
return Err(err!(Request(InvalidParam("password does not match"))));
|
||||
}
|
||||
// TODO check if user disabled, etc. See /src/api/client/session.rs
|
||||
let hostname = services.config.server_name.host();
|
||||
tracing::info!("logging in {user_id:?}");
|
||||
|
||||
services
|
||||
.oidc
|
||||
.endpoint()
|
||||
.with_solicitor(oidc_consent_form(hostname, &query.into()))
|
||||
.authorization_flow()
|
||||
.execute(request)
|
||||
.map_err(|err| err!(Request(Unknown("authorisation failed: {err:?}"))))
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
//! OIDC
|
||||
//!
|
||||
//! Stands for OpenID Connect, and is an authentication scheme relying on
|
||||
//! OAuth2. The [MSC2964] Matrix Spec Proposal describes an authentication
|
||||
//! process based on the OIDC flow, with restrictions. See the [sample flow] for
|
||||
//! details on what's expected.
|
||||
//!
|
||||
//! This module implements the needed endpoints. It relies on the [oxide-auth]
|
||||
//! crate, and the [`service::oidc`] and [`web::oidc`] modules.
|
||||
//!
|
||||
//! [MSC2964]: https://github.com/matrix-org/matrix-spec-proposals/pull/2964
|
||||
//! [oxide-auth]: https://docs.rs/oxide-auth
|
||||
//! [sample flow]: https://github.com/sandhose/matrix-spec-proposals/blob/msc/sandhose/oauth2-profile/proposals/2964-oauth2-profile.md#sample-flow
|
||||
|
||||
mod authorize;
|
||||
mod discovery;
|
||||
mod login;
|
||||
mod register;
|
||||
mod token;
|
||||
|
||||
pub(crate) use self::{
|
||||
authorize::{authorize, authorize_consent},
|
||||
discovery::get_auth_metadata,
|
||||
login::oidc_login,
|
||||
register::register_client,
|
||||
token::token,
|
||||
};
|
||||
@@ -0,0 +1,139 @@
|
||||
use axum::{Json, extract::State};
|
||||
use conduwuit::{Result, err};
|
||||
use conduwuit_service::oidc::normalize_redirect;
|
||||
use oxide_auth::primitives::prelude::Client;
|
||||
use reqwest::Url;
|
||||
use ruma::{ClientSecret, DeviceId, identifiers_validation};
|
||||
|
||||
/// The required parameters to register a new client for OAuth2 application.
|
||||
/// See the required metadata in OAuth2 authorization grant flow in [MSC2966].
|
||||
///
|
||||
/// [MSC2966]: https://github.com/matrix-org/matrix-spec-proposals/pull/2966
|
||||
#[derive(serde::Deserialize, Clone, Debug)]
|
||||
pub(crate) struct ClientQuery {
|
||||
/// Human-readable name.
|
||||
client_name: String,
|
||||
/// A public page that tells more about the client. All other links must be
|
||||
/// within.
|
||||
client_uri: Url,
|
||||
/// Redirect URIs declared by the client. At least one.
|
||||
redirect_uris: Vec<Url>,
|
||||
/// Must include the literal "code".
|
||||
response_types: Vec<String>,
|
||||
/// Must include the literals "authorization_code" and "refresh_token".
|
||||
grant_types: Vec<String>,
|
||||
/// How the client intends to authenticate its requests. Can be "none",
|
||||
/// meaning that the client will negotiate its token with the
|
||||
/// "authorization code" flow.
|
||||
token_endpoint_auth_method: String,
|
||||
/// Link to the logo.
|
||||
logo_uri: Option<Url>,
|
||||
/// Link to the client's policy.
|
||||
policy_uri: Option<Url>,
|
||||
/// Link to the terms of service.
|
||||
tos_uri: Option<Url>,
|
||||
/// Can be "native", implying localhost or reserved redirect pages.
|
||||
/// Defaults to "web" if not present.
|
||||
application_type: Option<String>,
|
||||
}
|
||||
|
||||
/// A successful response that the client was registered.
|
||||
#[derive(serde::Serialize, Debug)]
|
||||
pub(crate) struct ClientResponse {
|
||||
client_id: String,
|
||||
/// If the client is private, the secret it authenticates itself with.
|
||||
client_secret: Option<String>,
|
||||
/// If there's a `client_secret`, its expiration date in seconds since
|
||||
/// 1970-01-01T00:00. Some(0) means no expiration date.
|
||||
client_secret_expires_at: Option<u32>,
|
||||
client_name: String,
|
||||
/// Points to the "about" page of the client.
|
||||
client_uri: Url,
|
||||
logo_uri: Option<Url>,
|
||||
tos_uri: Option<Url>,
|
||||
policy_uri: Option<Url>,
|
||||
/// Registered redirect uris, which will be matched against when
|
||||
/// authenticating. If a localhost address, must contain instances of
|
||||
/// oxide-auth's `RegisteredUrl::IgnorePortOnLocalhost` to let
|
||||
/// authorization flow through any port over localhost.
|
||||
redirect_uris: Vec<Url>,
|
||||
token_endpoint_auth_method: String,
|
||||
response_types: Vec<String>,
|
||||
grant_types: Vec<String>,
|
||||
application_type: Option<String>,
|
||||
}
|
||||
|
||||
/// # `GET /_matrix/client/unstable/org.matrix.msc2964/device/register`
|
||||
///
|
||||
/// Register a client, as specified in [MSC2966]. This client, "device" in OIDC
|
||||
/// parlance, will have the right to submit [super::authorize::authorize]
|
||||
/// requests.
|
||||
///
|
||||
/// [MSC2966]: https://github.com/matrix-org/matrix-spec-proposals/pull/2966
|
||||
pub(crate) async fn register_client(
|
||||
State(services): State<crate::State>,
|
||||
Json(client): Json<ClientQuery>,
|
||||
) -> Result<Json<ClientResponse>> {
|
||||
tracing::trace!("processing OIDC device register request for client: {client:#?}");
|
||||
if client.redirect_uris.is_empty() {
|
||||
return Err(err!(Request(Unknown(
|
||||
"the client's registration request should contain at least a redirect_uri"
|
||||
))));
|
||||
}
|
||||
let mut redirect_uris = client.redirect_uris.clone();
|
||||
let redirect_uri = redirect_uris.pop().expect("at least one redirect_uri");
|
||||
let redirect_uri = normalize_redirect(redirect_uri);
|
||||
let remaining_uris = redirect_uris.into_iter().map(normalize_redirect).collect();
|
||||
let device_id = DeviceId::new();
|
||||
// Only provide a default scope, we'll test the client's proposed scope for
|
||||
// consent anyway.
|
||||
let scope = "default".parse().unwrap();
|
||||
// TODO check if the users service needs an update.
|
||||
//services.users.update_device_metadata();
|
||||
|
||||
// If the client cannot authenticate itself at the token endpoint, then
|
||||
// it's a public client. This is usually the case in Matrix.
|
||||
let is_private = client.token_endpoint_auth_method != "none";
|
||||
let client_secret = match is_private {
|
||||
| true => {
|
||||
let secret = ClientSecret::new();
|
||||
identifiers_validation::client_secret::validate(secret.as_str())?;
|
||||
Some(secret.to_string())
|
||||
},
|
||||
| false => None,
|
||||
};
|
||||
let registration = match is_private {
|
||||
| true => &Client::confidential(
|
||||
device_id.as_ref(),
|
||||
redirect_uri,
|
||||
scope,
|
||||
client_secret.as_ref().unwrap().as_bytes(),
|
||||
)
|
||||
.with_additional_redirect_uris(remaining_uris),
|
||||
| _ => &Client::public(device_id.as_ref(), redirect_uri, scope)
|
||||
.with_additional_redirect_uris(remaining_uris),
|
||||
};
|
||||
tracing::trace!("registering OIDC device : {registration:#?}");
|
||||
services
|
||||
.oidc
|
||||
.register_client(Some(client.client_name.clone()), registration);
|
||||
|
||||
let client_response = ClientResponse {
|
||||
client_id: device_id.to_string(),
|
||||
client_secret,
|
||||
client_secret_expires_at: if is_private { Some(0) } else { None },
|
||||
client_name: client.client_name.clone(),
|
||||
client_uri: client.client_uri.clone(),
|
||||
redirect_uris: client.redirect_uris.clone(),
|
||||
logo_uri: client.logo_uri.clone(),
|
||||
policy_uri: client.policy_uri.clone(),
|
||||
tos_uri: client.tos_uri.clone(),
|
||||
token_endpoint_auth_method: client.token_endpoint_auth_method.clone(),
|
||||
response_types: client.response_types.clone(),
|
||||
grant_types: client.grant_types.clone(),
|
||||
application_type: client.application_type,
|
||||
};
|
||||
tracing::debug!("OIDC device registered : {client_response:#?}");
|
||||
|
||||
Ok(Json(client_response))
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
use axum::extract::State;
|
||||
use conduwuit::{Result, err};
|
||||
use conduwuit_web::oidc::{OidcRequest, OidcResponse};
|
||||
use oxide_auth::endpoint::QueryParameter;
|
||||
|
||||
/// # `POST /_matrix/client/unstable/org.matrix.msc2964/token`
|
||||
///
|
||||
/// Depending on `grant_type`, either deliver a new token to a device, and store
|
||||
/// it in the server's ring, or refresh the token.
|
||||
pub(crate) async fn token(
|
||||
State(services): State<crate::State>,
|
||||
oauth: OidcRequest,
|
||||
) -> Result<OidcResponse> {
|
||||
tracing::trace!("processing OpenID token request {:#?}", oauth);
|
||||
let Some(body) = oauth.body() else {
|
||||
return Err(err!(Request(Unknown("OAuth request had an empty body"))));
|
||||
};
|
||||
let grant_type = body
|
||||
.unique_value("grant_type")
|
||||
.map(|value| value.to_string());
|
||||
let endpoint = services.oidc.endpoint();
|
||||
tracing::debug!("submitting OpenID token request for grant type {grant_type:?}");
|
||||
|
||||
match grant_type.as_deref() {
|
||||
| Some("authorization_code") => endpoint
|
||||
.access_token_flow()
|
||||
.execute(oauth)
|
||||
.map_err(|err| err!(Request(Unknown("token grant failed: {err:?}")))),
|
||||
| Some("refresh_token") => endpoint
|
||||
.refresh_flow()
|
||||
.execute(oauth)
|
||||
.map_err(|err| err!(Request(Unknown("token refresh failed: {err:?}")))),
|
||||
| other => Err(err!(Request(Unknown("unsupported grant type: {other:?}")))),
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,9 @@
|
||||
use futures::StreamExt;
|
||||
use ruma::api::client::{
|
||||
discovery::{
|
||||
discover_homeserver::{self, HomeserverInfo, SlidingSyncProxyInfo},
|
||||
discover_homeserver::{
|
||||
self, AuthenticationServerInfo, HomeserverInfo, SlidingSyncProxyInfo,
|
||||
},
|
||||
discover_support::{self, Contact},
|
||||
},
|
||||
error::ErrorKind,
|
||||
@@ -26,8 +28,16 @@ pub(crate) async fn well_known_client(
|
||||
Ok(discover_homeserver::Response {
|
||||
homeserver: HomeserverInfo { base_url: client_url.clone() },
|
||||
identity_server: None,
|
||||
sliding_sync_proxy: Some(SlidingSyncProxyInfo { url: client_url }),
|
||||
sliding_sync_proxy: Some(SlidingSyncProxyInfo { url: client_url.clone() }),
|
||||
tile_server: None,
|
||||
authentication: services.config.auth.as_ref().and_then(|auth| {
|
||||
auth.enable_oidc_login
|
||||
.then_some(AuthenticationServerInfo::new(
|
||||
client_url.clone(),
|
||||
auth.enable_oidc_account_management
|
||||
.then_some(format!("{client_url}/account")),
|
||||
))
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -115,6 +115,21 @@ pub fn build(router: Router<State>, server: &Server) -> Router<State> {
|
||||
.ruma_route(&client::get_protocols_route)
|
||||
.route("/_matrix/client/unstable/thirdparty/protocols",
|
||||
get(client::get_protocols_route_unstable))
|
||||
// MSC2965: OAuth 2.0 Authorization Server Metadata discovery.
|
||||
.route("/_matrix/client/unstable/org.matrix.msc2965/auth_metadata",
|
||||
get(client::get_auth_metadata))
|
||||
// MSC2964: Usage of OAuth 2.0 authorization code grant and refresh token grant.
|
||||
.route("/_matrix/client/unstable/org.matrix.msc2964/authorize",
|
||||
get(client::authorize))
|
||||
.route("/_matrix/client/unstable/org.matrix.msc2964/authorize",
|
||||
post(client::authorize_consent))
|
||||
.route("/_matrix/client/unstable/org.matrix.msc2964/login",
|
||||
post(client::oidc_login))
|
||||
.route("/_matrix/client/unstable/org.matrix.msc2964/token",
|
||||
post(client::token))
|
||||
// MSC2966: Usage of OAuth 2.0 Dynamic Client Registration in Matrix.
|
||||
.route("/_matrix/client/unstable/org.matrix.msc2964/device/register",
|
||||
post(client::register_client))
|
||||
.ruma_route(&client::send_message_event_route)
|
||||
.ruma_route(&client::send_state_event_for_key_route)
|
||||
.ruma_route(&client::get_state_events_route)
|
||||
|
||||
@@ -289,6 +289,15 @@ pub fn check(config: &Config) -> Result {
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(auth) = &config.auth {
|
||||
if auth.enable_oidc_login && config.well_known.client.is_none() {
|
||||
return Err!(Config(
|
||||
"auth.enable_oidc_login",
|
||||
"OIDC authentication is enabled but the well-known client is not set."
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
+25
-2
@@ -53,7 +53,7 @@
|
||||
### For more information, see:
|
||||
### https://continuwuity.org/configuration.html
|
||||
"#,
|
||||
ignore = "catchall well_known tls blurhashing allow_invalid_tls_certificates_yes_i_know_what_the_fuck_i_am_doing_with_this_and_i_know_this_is_insecure"
|
||||
ignore = "catchall auth well_known tls blurhashing allow_invalid_tls_certificates_yes_i_know_what_the_fuck_i_am_doing_with_this_and_i_know_this_is_insecure"
|
||||
)]
|
||||
pub struct Config {
|
||||
/// The server_name is the pretty name of this server. It is used as a
|
||||
@@ -62,7 +62,8 @@ pub struct Config {
|
||||
/// See the docs for reverse proxying and delegation:
|
||||
/// https://continuwuity.org/deploying/generic.html#setting-up-the-reverse-proxy
|
||||
///
|
||||
/// Also see the `[global.well_known]` config section at the very bottom.
|
||||
/// Also see the `[global.auth]` and `[global.well_known]` config sections
|
||||
/// at the very bottom.
|
||||
///
|
||||
/// Examples of delegation:
|
||||
/// - https://puppygock.gay/.well-known/matrix/server
|
||||
@@ -104,6 +105,9 @@ pub struct Config {
|
||||
#[serde(default)]
|
||||
pub tls: TlsConfig,
|
||||
|
||||
// external structure; separate section
|
||||
pub auth: Option<AuthConfig>,
|
||||
|
||||
/// The UNIX socket continuwuity will listen on.
|
||||
///
|
||||
/// continuwuity cannot listen on both an IP address and a UNIX socket. If
|
||||
@@ -2020,6 +2024,25 @@ pub struct TlsConfig {
|
||||
pub dual_protocol: bool,
|
||||
}
|
||||
|
||||
#[allow(rustdoc::broken_intra_doc_links, rustdoc::bare_urls)]
|
||||
#[derive(Clone, Debug, Deserialize, Default)]
|
||||
#[config_example_generator(filename = "conduwuit-example.toml", section = "global.auth")]
|
||||
pub struct AuthConfig {
|
||||
/// Use this homeserver as the OIDC authentication reference. It will
|
||||
/// advertise itself as the OIDC authentication issuer to new clients,
|
||||
/// and use the internal user database to answer on the advertised
|
||||
/// endpoints. Note that the legacy Matrix authentication still will be
|
||||
/// reachable.
|
||||
/// Unset by default.
|
||||
pub enable_oidc_login: bool,
|
||||
|
||||
/// Whether this homeserver should provide users with an account management
|
||||
/// interface. Only used if `enable_oidc_login` is set. Note that the
|
||||
/// endpoint is unimplemented at the moment.
|
||||
/// Unset by default.
|
||||
pub enable_oidc_account_management: bool,
|
||||
}
|
||||
|
||||
#[allow(rustdoc::broken_intra_doc_links, rustdoc::bare_urls)]
|
||||
#[derive(Clone, Debug, Deserialize, Default)]
|
||||
#[config_example_generator(filename = "conduwuit-example.toml", section = "global.well_known")]
|
||||
|
||||
@@ -434,6 +434,14 @@ pub(super) fn open_list(db: &Arc<Engine>, maps: &[Descriptor]) -> Result<Maps> {
|
||||
name: "userroomid_notificationcount",
|
||||
..descriptor::RANDOM
|
||||
},
|
||||
Descriptor {
|
||||
name: "client_registrar",
|
||||
..descriptor::RANDOM
|
||||
},
|
||||
Descriptor {
|
||||
name: "deviceid_clientidmap",
|
||||
..descriptor::RANDOM_SMALL
|
||||
},
|
||||
Descriptor {
|
||||
name: "userroomid_invitesender",
|
||||
..descriptor::RANDOM_SMALL
|
||||
|
||||
@@ -125,6 +125,8 @@ ctor.workspace = true
|
||||
[target.'cfg(all(unix, target_os = "linux"))'.dependencies]
|
||||
sd-notify.workspace = true
|
||||
sd-notify.optional = true
|
||||
oxide-auth.workspace = true
|
||||
once_cell.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
pub mod key_backups;
|
||||
pub mod media;
|
||||
pub mod moderation;
|
||||
pub mod oidc;
|
||||
pub mod presence;
|
||||
pub mod pusher;
|
||||
pub mod resolver;
|
||||
|
||||
@@ -0,0 +1,361 @@
|
||||
//! OIDC service.
|
||||
//!
|
||||
//! Provides the registrar, authorizer and issuer needed by [api::client::oidc].
|
||||
//! The whole OIDC OAuth2 flow is taken care of by [oxide-auth].
|
||||
//!
|
||||
//! [oxide-auth]: https://docs.rs/oxide-auth
|
||||
|
||||
use std::{
|
||||
borrow::Cow,
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use conduwuit::{Result, err};
|
||||
use conduwuit_core::utils;
|
||||
use database::{Deserialized, Json, Map};
|
||||
use oxide_auth::{
|
||||
endpoint::{PreGrant, Scope},
|
||||
frontends::simple::endpoint::{Generic, Vacant},
|
||||
primitives::{
|
||||
grant::Grant,
|
||||
prelude::{
|
||||
AuthMap, Authorizer, Client, ClientUrl, Issuer, RandomGenerator, Registrar, TokenMap,
|
||||
},
|
||||
registrar::{
|
||||
Argon2, BoundClient, EncodedClient, RegisteredClient, RegisteredUrl, RegistrarError,
|
||||
},
|
||||
},
|
||||
};
|
||||
use ruma::{
|
||||
MilliSecondsSinceUnixEpoch, OwnedDeviceId, OwnedUserId, UserId, api::client::device::Device,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use url::Url;
|
||||
|
||||
use crate::{Dep, globals};
|
||||
|
||||
pub const SCOPE_PREFIX_DEVICE: &str = "urn:matrix:org.matrix.msc2967.client:device:";
|
||||
pub const SCOPE_PREFIX_API: &str = "urn:matrix:org.matrix.msc2967.client:api:";
|
||||
|
||||
static PASSWORD_POLICY: std::sync::LazyLock<Argon2> = std::sync::LazyLock::new(Argon2::default);
|
||||
|
||||
/// A client app that connects to continuwuity via OIDC, as recorded in the
|
||||
/// database.
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
pub struct OidcClient {
|
||||
/// The name published by the app itself.
|
||||
pub name: Option<String>,
|
||||
/// A device id that we'll generate on OIDC registration.
|
||||
pub device_id: Option<String>,
|
||||
/// The device's coordinates recorded by oxide-auth.
|
||||
pub client: EncodedClient,
|
||||
}
|
||||
|
||||
struct Services {
|
||||
globals: Dep<globals::Service>,
|
||||
}
|
||||
|
||||
struct Data {
|
||||
client_registrar: Arc<Map>,
|
||||
deviceid_clientidmap: Arc<Map>,
|
||||
userid_devicelistversion: Arc<Map>,
|
||||
userdeviceid_metadata: Arc<Map>,
|
||||
}
|
||||
|
||||
pub struct Service {
|
||||
/// Authorization tokens are 16 byte random keys to a memory hash map.
|
||||
///
|
||||
/// Will be reinitialised on continuwuity's restart.
|
||||
authorizer: Mutex<AuthMap<RandomGenerator>>,
|
||||
/// Bearer tokens are also random generated but 256-bit tokens, since they
|
||||
/// live longer.
|
||||
///
|
||||
/// We could also use a `TokenSigner::ephemeral` here to create signed
|
||||
/// tokens which can be read and parsed by anyone, but not maliciously
|
||||
/// created. However, they can not be revoked and thus don't offer even
|
||||
/// longer lived refresh tokens.
|
||||
///
|
||||
/// Will be reinitialised on continuwuity's restart.
|
||||
issuer: Mutex<TokenMap<RandomGenerator>>,
|
||||
services: Services,
|
||||
db: Data,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl crate::Service for Service {
|
||||
fn build(args: crate::Args<'_>) -> Result<Arc<Self>> {
|
||||
// TODO implement authorizer and issuer inside database so that token
|
||||
// requests survive server restarts.
|
||||
Ok(Arc::new(Self {
|
||||
authorizer: Mutex::new(AuthMap::new(RandomGenerator::new(16))),
|
||||
issuer: Mutex::new(TokenMap::new(RandomGenerator::new(16))),
|
||||
services: Services {
|
||||
globals: args.depend::<globals::Service>("globals"),
|
||||
},
|
||||
db: Data {
|
||||
client_registrar: args.db["client_registrar"].clone(),
|
||||
deviceid_clientidmap: args.db["deviceid_clientidmap"].clone(),
|
||||
userid_devicelistversion: args.db["userid_devicelistversion"].clone(),
|
||||
userdeviceid_metadata: args.db["userdeviceid_metadata"].clone(),
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
fn name(&self) -> &str { crate::service::make_name(std::module_path!()) }
|
||||
}
|
||||
|
||||
impl Service {
|
||||
/// Register an OIDC client in the client_registrar for future
|
||||
/// authentication flows.
|
||||
pub fn register_client(&self, display_name: Option<String>, client: &Client) {
|
||||
let client = client.clone().encode(&*PASSWORD_POLICY);
|
||||
let client_id = client.client_id.clone();
|
||||
let oidc_client = OidcClient {
|
||||
name: display_name,
|
||||
// Matrix clients have no device_id at registration time.
|
||||
device_id: None,
|
||||
client,
|
||||
};
|
||||
self.db.client_registrar.put(client_id, Json(oidc_client));
|
||||
}
|
||||
|
||||
/// Register a device in the main continuwuity database. This should only
|
||||
/// happen on successful authentication and consent, and will register the
|
||||
/// client's device_id.
|
||||
pub async fn register_device(
|
||||
&self,
|
||||
client_id: &str,
|
||||
(user_id, device_id): (&OwnedUserId, &OwnedDeviceId),
|
||||
display_name: Option<&str>,
|
||||
client_ip: Option<String>,
|
||||
) -> Result<()> {
|
||||
let device_key = (user_id, device_id);
|
||||
let device = Device {
|
||||
device_id: device_id.into(),
|
||||
display_name: display_name.map(ToOwned::to_owned),
|
||||
last_seen_ip: client_ip,
|
||||
last_seen_ts: Some(MilliSecondsSinceUnixEpoch::now()),
|
||||
};
|
||||
increment(&self.db.userid_devicelistversion, user_id.as_bytes()).await;
|
||||
self.db.userdeviceid_metadata.put(device_key, Json(device));
|
||||
|
||||
let mut client: OidcClient = self
|
||||
.db
|
||||
.client_registrar
|
||||
.get(client_id)
|
||||
.await?
|
||||
.deserialized()?;
|
||||
client.device_id = Some(device_id.to_string());
|
||||
self.db
|
||||
.client_registrar
|
||||
.put(client_id.to_owned(), Json(client));
|
||||
|
||||
self.db
|
||||
.deviceid_clientidmap
|
||||
.put(device_id, client_id.to_owned());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn grant_from_token(&self, token: &str) -> Option<Grant> {
|
||||
let issuer = self.issuer.lock().expect("lockable issuer");
|
||||
|
||||
issuer
|
||||
.recover_token(token)
|
||||
.expect("infallible recover_token implementation")
|
||||
}
|
||||
|
||||
pub async fn client_from_client_id(&self, client_id: &str) -> Result<Option<OidcClient>> {
|
||||
self.db
|
||||
.client_registrar
|
||||
.get(client_id)
|
||||
.await?
|
||||
.deserialized()
|
||||
}
|
||||
|
||||
pub async fn client_from_device_id(
|
||||
&self,
|
||||
device_id: OwnedDeviceId,
|
||||
) -> Result<Option<OidcClient>> {
|
||||
let client_id: String = self
|
||||
.db
|
||||
.deviceid_clientidmap
|
||||
.get(&device_id)
|
||||
.await?
|
||||
.deserialized()?;
|
||||
|
||||
self.db
|
||||
.client_registrar
|
||||
.get(&client_id)
|
||||
.await?
|
||||
.deserialized()
|
||||
}
|
||||
|
||||
pub fn device_id_from_scope(&self, scope: &Scope) -> Result<OwnedDeviceId> {
|
||||
let Some(device_id) = scope.iter().find(|s| s.starts_with(SCOPE_PREFIX_DEVICE)) else {
|
||||
tracing::warn!("device_id not found in scope {scope:?}");
|
||||
return Err(err!(Request(InvalidParam("something went wrong with the scope"))));
|
||||
};
|
||||
let device_id = device_id.replace(SCOPE_PREFIX_DEVICE, "");
|
||||
|
||||
Ok(device_id.into())
|
||||
}
|
||||
|
||||
pub async fn user_and_device_from_token(
|
||||
&self,
|
||||
token: &str,
|
||||
) -> Result<(OwnedUserId, OwnedDeviceId)> {
|
||||
let Some(Grant { owner_id, client_id, .. }) = self.grant_from_token(token) else {
|
||||
return Err(err!(Request(MissingToken("unknown token: {token:?}"))));
|
||||
};
|
||||
let server_name = self.services.globals.server_name();
|
||||
let owner_id =
|
||||
UserId::parse_with_server_name(owner_id.clone(), server_name).map_err(|err| {
|
||||
err!(Request(InvalidUsername("invalid username {owner_id:?}: {err}")))
|
||||
})?;
|
||||
let client = self
|
||||
.client_from_client_id(&client_id)
|
||||
.await?
|
||||
.expect("validated client_id");
|
||||
let Some(device_id) = client.device_id else {
|
||||
return Err(err!(Request(Unknown("this client has no device_id yet"))));
|
||||
};
|
||||
let device_id = OwnedDeviceId::from(device_id);
|
||||
|
||||
Ok((owner_id, device_id))
|
||||
}
|
||||
|
||||
/// The oxide-auth carry-all endpoint.
|
||||
pub fn endpoint(
|
||||
&self,
|
||||
) -> Generic<impl Registrar + '_, impl Authorizer + '_, impl Issuer + '_> {
|
||||
Generic {
|
||||
registrar: self,
|
||||
authorizer: self.authorizer.lock().unwrap(),
|
||||
issuer: self.issuer.lock().unwrap(),
|
||||
// Solicitor configured later.
|
||||
solicitor: Vacant,
|
||||
// Scope configured later.
|
||||
scopes: Vacant,
|
||||
response: Vacant,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn increment(db: &Arc<Map>, key: &[u8]) {
|
||||
let old = db.get(key).await;
|
||||
let new = utils::increment(old.ok().as_deref());
|
||||
db.insert(key, new);
|
||||
}
|
||||
|
||||
/// Substitute "127.0.0.1" and "[::1]" for "localhost" to let oxide-auth compare
|
||||
/// them ignoring their port.
|
||||
pub fn normalize_redirect_hostname(url: &mut Url) {
|
||||
let new_host = url.host_str().map(|h| {
|
||||
h.replace("127.0.0.1", "localhost")
|
||||
.replace("[::1]", "localhost")
|
||||
});
|
||||
|
||||
url.set_host(new_host.as_deref())
|
||||
.expect("replaceable redirect hostname");
|
||||
}
|
||||
|
||||
/// If `url` is a localhost (either 'localhost', '127.0.0.1' or '[::1]'), wrap
|
||||
/// it in an `IgnorePortOnLocalhost`, so that oxide-auth ignores the port when
|
||||
/// comparing it with the registered ones.
|
||||
#[must_use]
|
||||
pub fn normalize_redirect(mut url: Url) -> RegisteredUrl {
|
||||
normalize_redirect_hostname(&mut url);
|
||||
|
||||
match url.host_str() {
|
||||
| Some("localhost") => RegisteredUrl::IgnorePortOnLocalhost(url.into()),
|
||||
| _ => RegisteredUrl::Semantic(url),
|
||||
}
|
||||
}
|
||||
|
||||
/// Let this service act as an oxide-auth `Registrar`.
|
||||
impl Registrar for Service {
|
||||
fn bound_redirect<'a>(
|
||||
&self,
|
||||
bound: ClientUrl<'a>,
|
||||
) -> Result<BoundClient<'a>, RegistrarError> {
|
||||
let client_handle = self
|
||||
.db
|
||||
.client_registrar
|
||||
.get_blocking(bound.client_id.as_ref())
|
||||
.map_err(|_| RegistrarError::Unspecified)?;
|
||||
let oidc_client: OidcClient = client_handle
|
||||
.deserialized()
|
||||
.map_err(|_| RegistrarError::Unspecified)?;
|
||||
let client = oidc_client.client;
|
||||
// Perform exact matching as motivated in the rfc, but substitute
|
||||
// "127.0.0.1" and "[::1]" for "localhost" to let oxide-auth ignore
|
||||
// their port.
|
||||
let redirect_uri = bound.redirect_uri;
|
||||
let normalized_uri = redirect_uri.clone().map(|u| normalize_redirect(u.to_url()));
|
||||
let redirect_uri = match normalized_uri {
|
||||
| None => client.redirect_uri,
|
||||
| Some(url) => {
|
||||
let original = std::iter::once(&client.redirect_uri);
|
||||
let alternatives = client.additional_redirect_uris.iter();
|
||||
if original
|
||||
.chain(alternatives)
|
||||
.any(|registered| *registered == url)
|
||||
{
|
||||
// If normalized_uri is Some(url), so is redirect_uri, so unwrap().
|
||||
redirect_uri.unwrap().into_owned().into()
|
||||
} else {
|
||||
tracing::debug!(
|
||||
"the request's redirect url didn't match any registered. bound: {:?}, \
|
||||
in client {:#?}",
|
||||
url,
|
||||
client
|
||||
);
|
||||
return Err(RegistrarError::Unspecified);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
Ok(BoundClient {
|
||||
client_id: bound.client_id,
|
||||
redirect_uri: Cow::Owned(redirect_uri),
|
||||
})
|
||||
}
|
||||
|
||||
fn negotiate(
|
||||
&self,
|
||||
bound: BoundClient<'_>,
|
||||
_scope: Option<Scope>,
|
||||
) -> Result<PreGrant, RegistrarError> {
|
||||
let client_handle = self
|
||||
.db
|
||||
.client_registrar
|
||||
.get_blocking(bound.client_id.as_ref())
|
||||
.map_err(|_| RegistrarError::Unspecified)?;
|
||||
let oidc_client: OidcClient = client_handle
|
||||
.deserialized()
|
||||
.map_err(|_| RegistrarError::Unspecified)?;
|
||||
|
||||
Ok(PreGrant {
|
||||
client_id: bound.client_id.into_owned(),
|
||||
redirect_uri: bound.redirect_uri.into_owned(),
|
||||
// Always use the client's scope.
|
||||
scope: oidc_client.client.default_scope,
|
||||
})
|
||||
}
|
||||
|
||||
fn check(&self, client_id: &str, passphrase: Option<&[u8]>) -> Result<(), RegistrarError> {
|
||||
let client_handle = self
|
||||
.db
|
||||
.client_registrar
|
||||
.get_blocking(client_id)
|
||||
.map_err(|_| RegistrarError::Unspecified)?;
|
||||
let oidc_client: OidcClient = client_handle
|
||||
.deserialized()
|
||||
.map_err(|_| RegistrarError::Unspecified)?;
|
||||
let client = oidc_client.client;
|
||||
|
||||
RegisteredClient::new(&client, &*PASSWORD_POLICY).check_authentication(passphrase)
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@
|
||||
account_data, admin, announcements, appservice, client, config, emergency, federation,
|
||||
globals, key_backups,
|
||||
manager::Manager,
|
||||
media, moderation, presence, pusher, resolver, rooms, sending, server_keys, service,
|
||||
media, moderation, oidc, presence, pusher, resolver, rooms, sending, server_keys, service,
|
||||
service::{Args, Map, Service},
|
||||
sync, transaction_ids, uiaa, users,
|
||||
};
|
||||
@@ -39,6 +39,7 @@ pub struct Services {
|
||||
pub users: Arc<users::Service>,
|
||||
pub moderation: Arc<moderation::Service>,
|
||||
pub announcements: Arc<announcements::Service>,
|
||||
pub oidc: Arc<oidc::Service>,
|
||||
|
||||
manager: Mutex<Option<Arc<Manager>>>,
|
||||
pub(crate) service: Arc<Map>,
|
||||
@@ -107,6 +108,7 @@ macro_rules! build {
|
||||
users: build!(users::Service),
|
||||
moderation: build!(moderation::Service),
|
||||
announcements: build!(announcements::Service),
|
||||
oidc: build!(oidc::Service),
|
||||
|
||||
manager: Mutex::new(None),
|
||||
service,
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{Dep, account_data, admin, appservice, globals, rooms};
|
||||
use crate::{Dep, account_data, admin, appservice, globals, oidc, rooms};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct UserSuspension {
|
||||
@@ -52,6 +52,7 @@ struct Services {
|
||||
admin: Dep<admin::Service>,
|
||||
appservice: Dep<appservice::Service>,
|
||||
globals: Dep<globals::Service>,
|
||||
oidc: Dep<oidc::Service>,
|
||||
state_accessor: Dep<rooms::state_accessor::Service>,
|
||||
state_cache: Dep<rooms::state_cache::Service>,
|
||||
}
|
||||
@@ -89,6 +90,7 @@ fn build(args: crate::Args<'_>) -> Result<Arc<Self>> {
|
||||
account_data: args.depend::<account_data::Service>("account_data"),
|
||||
admin: args.depend::<admin::Service>("admin"),
|
||||
appservice: args.depend::<appservice::Service>("appservice"),
|
||||
oidc: args.depend::<oidc::Service>("oidc"),
|
||||
globals: args.depend::<globals::Service>("globals"),
|
||||
state_accessor: args
|
||||
.depend::<rooms::state_accessor::Service>("rooms::state_accessor"),
|
||||
@@ -270,7 +272,18 @@ pub async fn count(&self) -> usize { self.db.userid_password.count().await }
|
||||
|
||||
/// Find out which user an access token belongs to.
|
||||
pub async fn find_from_token(&self, token: &str) -> Result<(OwnedUserId, OwnedDeviceId)> {
|
||||
self.db.token_userdeviceid.get(token).await.deserialized()
|
||||
if self
|
||||
.services
|
||||
.server
|
||||
.config
|
||||
.auth
|
||||
.as_ref()
|
||||
.is_some_and(|auth| auth.enable_oidc_login)
|
||||
{
|
||||
self.services.oidc.user_and_device_from_token(token).await
|
||||
} else {
|
||||
self.db.token_userdeviceid.get(token).await.deserialized()
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns an iterator over all users on this homeserver (offered for
|
||||
@@ -536,7 +549,24 @@ pub async fn add_one_time_key(
|
||||
// Only existing devices should be able to call this, but we shouldn't assert
|
||||
// either...
|
||||
let key = (user_id, device_id);
|
||||
if self.db.userdeviceid_metadata.qry(&key).await.is_err() {
|
||||
if self
|
||||
.services
|
||||
.server
|
||||
.config
|
||||
.auth
|
||||
.as_ref()
|
||||
.is_some_and(|auth| auth.enable_oidc_login)
|
||||
{
|
||||
if self
|
||||
.services
|
||||
.oidc
|
||||
.client_from_device_id(device_id.into())
|
||||
.await?
|
||||
.is_none()
|
||||
{
|
||||
return Err!(Database(error!(?user_id, ?device_id, "Device has no metadata.")));
|
||||
}
|
||||
} else if self.db.userdeviceid_metadata.qry(&key).await.is_err() {
|
||||
return Err!(Database(error!(
|
||||
?user_id,
|
||||
?device_id,
|
||||
|
||||
@@ -22,6 +22,8 @@ crate-type = [
|
||||
[dependencies]
|
||||
conduwuit-build-metadata.workspace = true
|
||||
conduwuit-service.workspace = true
|
||||
conduwuit-core.workspace = true
|
||||
async-trait.workspace = true
|
||||
|
||||
askama = "0.14.0"
|
||||
|
||||
@@ -30,6 +32,10 @@ futures.workspace = true
|
||||
tracing.workspace = true
|
||||
rand.workspace = true
|
||||
thiserror.workspace = true
|
||||
serde.workspace = true
|
||||
url.workspace = true
|
||||
percent-encoding.workspace = true
|
||||
oxide-auth.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
};
|
||||
use conduwuit_build_metadata::{GIT_REMOTE_COMMIT_URL, GIT_REMOTE_WEB_URL, version_tag};
|
||||
use conduwuit_service::state;
|
||||
pub mod oidc;
|
||||
|
||||
pub fn build() -> Router<state::State> {
|
||||
let router = Router::<state::State>::new();
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
use askama::Template;
|
||||
use conduwuit_build_metadata::version_tag;
|
||||
use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode};
|
||||
|
||||
// Imports needed by askama templates.
|
||||
use crate::{GIT_REMOTE_COMMIT_URL, GIT_REMOTE_WEB_URL};
|
||||
|
||||
mod authorize;
|
||||
mod consent;
|
||||
mod error;
|
||||
mod login;
|
||||
mod request;
|
||||
mod response;
|
||||
pub use authorize::AuthorizationQuery;
|
||||
pub use consent::oidc_consent_form;
|
||||
pub use error::OidcError;
|
||||
pub use login::{LoginError, LoginQuery, oidc_login_form};
|
||||
pub use request::OidcRequest;
|
||||
pub use response::OidcResponse;
|
||||
|
||||
/// The parameters for the OIDC login page template.
|
||||
#[derive(Template)]
|
||||
#[template(path = "login.html.j2")]
|
||||
pub(crate) struct LoginPageTemplate<'a> {
|
||||
nonce: &'a str,
|
||||
hostname: &'a str,
|
||||
route: &'a str,
|
||||
client_id: &'a str,
|
||||
client_secret: Option<&'a str>,
|
||||
redirect_uri: &'a str,
|
||||
scope: &'a str,
|
||||
state: &'a str,
|
||||
code_challenge: &'a str,
|
||||
code_challenge_method: &'a str,
|
||||
response_type: &'a str,
|
||||
response_mode: &'a str,
|
||||
}
|
||||
|
||||
/// The parameters for the OIDC consent page template.
|
||||
#[derive(Template)]
|
||||
#[template(path = "consent.html.j2")]
|
||||
pub(crate) struct ConsentPageTemplate<'a> {
|
||||
nonce: &'a str,
|
||||
hostname: &'a str,
|
||||
route: &'a str,
|
||||
user_id: &'a str,
|
||||
client_id: &'a str,
|
||||
client_secret: Option<&'a str>,
|
||||
redirect_uri: &'a str,
|
||||
scope: &'a str,
|
||||
state: &'a str,
|
||||
code_challenge: &'a str,
|
||||
code_challenge_method: &'a str,
|
||||
response_type: &'a str,
|
||||
response_mode: &'a str,
|
||||
}
|
||||
|
||||
pub(crate) fn encode(text: &str) -> String {
|
||||
utf8_percent_encode(text, NON_ALPHANUMERIC).to_string()
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
use url::Url;
|
||||
|
||||
use super::LoginQuery;
|
||||
|
||||
/// The set of parameters required for an OIDC authorization request.
|
||||
#[derive(serde::Deserialize, Debug, Clone)]
|
||||
pub struct AuthorizationQuery {
|
||||
pub client_id: String,
|
||||
pub client_secret: Option<String>,
|
||||
pub redirect_uri: Url,
|
||||
pub scope: String,
|
||||
pub state: String,
|
||||
pub code_challenge: String,
|
||||
pub code_challenge_method: String,
|
||||
pub response_type: String,
|
||||
pub response_mode: Option<String>,
|
||||
pub username: Option<String>,
|
||||
}
|
||||
|
||||
impl From<LoginQuery> for AuthorizationQuery {
|
||||
fn from(value: LoginQuery) -> Self {
|
||||
let LoginQuery {
|
||||
client_id,
|
||||
client_secret,
|
||||
redirect_uri,
|
||||
scope,
|
||||
state,
|
||||
code_challenge,
|
||||
code_challenge_method,
|
||||
response_type,
|
||||
response_mode,
|
||||
username,
|
||||
..
|
||||
} = value;
|
||||
|
||||
Self {
|
||||
client_id,
|
||||
client_secret,
|
||||
redirect_uri,
|
||||
scope,
|
||||
state,
|
||||
code_challenge,
|
||||
code_challenge_method,
|
||||
response_type,
|
||||
response_mode: Some(response_mode),
|
||||
username: Some(username),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
use askama::Template;
|
||||
use axum::http::StatusCode;
|
||||
use oxide_auth::frontends::simple::request::Body;
|
||||
|
||||
use super::{AuthorizationQuery, ConsentPageTemplate, OidcResponse, encode};
|
||||
|
||||
/// A web consent solicitor form for the OIDC authentication flow.
|
||||
///
|
||||
/// Asks the resource owner for their consent to let a client access their data
|
||||
/// on this server.
|
||||
#[must_use]
|
||||
pub fn oidc_consent_form(hostname: &str, query: &AuthorizationQuery) -> OidcResponse {
|
||||
// The target request route.
|
||||
let route = "/_matrix/client/unstable/org.matrix.msc2964/authorize";
|
||||
let nonce = rand::random::<u64>().to_string();
|
||||
let body = Some(Body::Text(consent_page(hostname, query, route, &nonce)));
|
||||
|
||||
OidcResponse {
|
||||
status: StatusCode::OK,
|
||||
location: None,
|
||||
www_authenticate: None,
|
||||
body,
|
||||
nonce: Some(nonce),
|
||||
}
|
||||
}
|
||||
|
||||
/// Render the html contents of the user consent page.
|
||||
fn consent_page(hostname: &str, query: &AuthorizationQuery, route: &str, nonce: &str) -> String {
|
||||
let response_mode = query
|
||||
.response_mode
|
||||
.as_deref()
|
||||
.unwrap_or("fragment")
|
||||
.to_owned();
|
||||
let user_id = query
|
||||
.username
|
||||
.clone()
|
||||
.expect("user_id in authorization query");
|
||||
let template = ConsentPageTemplate {
|
||||
nonce,
|
||||
hostname,
|
||||
route,
|
||||
user_id: &encode(&user_id),
|
||||
client_id: &encode(query.client_id.as_str()),
|
||||
client_secret: query.client_secret.as_deref(),
|
||||
redirect_uri: &encode(query.redirect_uri.as_str()),
|
||||
scope: &encode(query.scope.as_str()),
|
||||
state: &encode(query.state.as_str()),
|
||||
code_challenge: &encode(query.code_challenge.as_str()),
|
||||
code_challenge_method: &encode(query.code_challenge_method.as_str()),
|
||||
response_type: &encode(query.response_type.as_str()),
|
||||
response_mode: &encode(response_mode.as_str()),
|
||||
};
|
||||
|
||||
template.render().expect("consent page render")
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
use axum::{
|
||||
http::{StatusCode, header::InvalidHeaderValue},
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use oxide_auth::frontends::{dev::OAuthError, simple::endpoint::Error};
|
||||
|
||||
use super::OidcRequest;
|
||||
|
||||
#[derive(Debug)]
|
||||
/// The error type for Oxide Auth operations
|
||||
pub enum OidcError {
|
||||
/// Errors occurring in Endpoint operations
|
||||
Endpoint(OAuthError),
|
||||
/// Errors occurring in Endpoint operations
|
||||
Header(InvalidHeaderValue),
|
||||
/// Errors with the request encoding
|
||||
Encoding,
|
||||
/// Request body could not be parsed as a form
|
||||
Form,
|
||||
/// Request query was absent or could not be parsed
|
||||
Query,
|
||||
/// Request query was absent or could not be parsed
|
||||
Body,
|
||||
/// The Authorization header was invalid
|
||||
Authorization,
|
||||
/// General internal server error
|
||||
InternalError(Option<String>),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for OidcError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match *self {
|
||||
| Self::Endpoint(ref e) => write!(f, "Endpoint, {e}"),
|
||||
| Self::Header(ref e) => write!(f, "Couldn't set header, {e}"),
|
||||
| Self::Encoding => write!(f, "Error decoding request"),
|
||||
| Self::Form => write!(f, "Request is not a form"),
|
||||
| Self::Query => write!(f, "No query present"),
|
||||
| Self::Body => write!(f, "No body present"),
|
||||
| Self::Authorization => write!(f, "Request has invalid Authorization headers"),
|
||||
| Self::InternalError(None) => write!(f, "An internal server error occurred"),
|
||||
| Self::InternalError(Some(ref e)) =>
|
||||
write!(f, "An internal server error occurred: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for OidcError {
|
||||
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
||||
match *self {
|
||||
| Self::Endpoint(ref e) => e.source(),
|
||||
| Self::Header(ref e) => e.source(),
|
||||
| _ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for OidcError {
|
||||
fn into_response(self) -> Response {
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, self.to_string()).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Error<OidcRequest>> for OidcError {
|
||||
fn from(e: Error<OidcRequest>) -> Self {
|
||||
match e {
|
||||
| Error::Web(e) => e,
|
||||
| Error::OAuth(e) => e.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<OAuthError> for OidcError {
|
||||
fn from(e: OAuthError) -> Self { Self::Endpoint(e) }
|
||||
}
|
||||
|
||||
impl From<InvalidHeaderValue> for OidcError {
|
||||
fn from(e: InvalidHeaderValue) -> Self { Self::Header(e) }
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
use std::{borrow::Cow, str::FromStr};
|
||||
|
||||
use askama::Template;
|
||||
use axum::http::StatusCode;
|
||||
use oxide_auth::{endpoint::QueryParameter, frontends::simple::request::Body};
|
||||
use url::Url;
|
||||
|
||||
use super::{AuthorizationQuery, LoginPageTemplate, OidcRequest, OidcResponse};
|
||||
|
||||
/// The set of query parameters a client needs to get authorization.
|
||||
#[derive(serde::Deserialize, Debug, Clone)]
|
||||
pub struct LoginQuery {
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
pub client_id: String,
|
||||
pub client_secret: Option<String>,
|
||||
pub redirect_uri: Url,
|
||||
pub scope: String,
|
||||
pub state: String,
|
||||
pub code_challenge: String,
|
||||
pub code_challenge_method: String,
|
||||
pub response_type: String,
|
||||
pub response_mode: String,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct LoginError(pub String);
|
||||
|
||||
impl TryFrom<OidcRequest> for LoginQuery {
|
||||
type Error = LoginError;
|
||||
|
||||
fn try_from(value: OidcRequest) -> Result<Self, LoginError> {
|
||||
let body = value.body().expect("body in OidcRequest");
|
||||
|
||||
let Some(username) = body.unique_value("username") else {
|
||||
return Err(LoginError("missing field: username".to_owned()));
|
||||
};
|
||||
let Some(password) = body.unique_value("password") else {
|
||||
return Err(LoginError("missing field: password".to_owned()));
|
||||
};
|
||||
let Some(client_id) = body.unique_value("client_id") else {
|
||||
return Err(LoginError("missing field: client_id".to_owned()));
|
||||
};
|
||||
let Some(redirect_uri) = body.unique_value("redirect_uri") else {
|
||||
return Err(LoginError("missing field: redirect_uri".to_owned()));
|
||||
};
|
||||
let Some(scope) = body.unique_value("scope") else {
|
||||
return Err(LoginError("missing field: scope".to_owned()));
|
||||
};
|
||||
let Some(state) = body.unique_value("state") else {
|
||||
return Err(LoginError("missing field: state".to_owned()));
|
||||
};
|
||||
let Some(code_challenge) = body.unique_value("code_challenge") else {
|
||||
return Err(LoginError("missing field: code_challenge".to_owned()));
|
||||
};
|
||||
let Some(code_challenge_method) = body.unique_value("code_challenge_method") else {
|
||||
return Err(LoginError("missing field: code_challenge_method".to_owned()));
|
||||
};
|
||||
let Some(response_type) = body.unique_value("response_type") else {
|
||||
return Err(LoginError("missing field: response_type".to_owned()));
|
||||
};
|
||||
let Ok(redirect_uri) = Url::from_str(&redirect_uri) else {
|
||||
return Err(LoginError("invalid field: redirect_uri".to_owned()));
|
||||
};
|
||||
// response_mode is not strictly needed : it must be the literal "fragment"
|
||||
// when over https. It's required by the spec but Fractal doesn't provide it.
|
||||
let response_mode = body
|
||||
.unique_value("response_mode")
|
||||
.unwrap_or(Cow::Borrowed("fragment"));
|
||||
let client_secret = body.unique_value("client_secret").map(|s| s.to_string());
|
||||
|
||||
Ok(Self {
|
||||
username: username.to_string(),
|
||||
password: password.to_string(),
|
||||
client_id: client_id.to_string(),
|
||||
client_secret,
|
||||
redirect_uri,
|
||||
scope: scope.to_string(),
|
||||
state: state.to_string(),
|
||||
code_challenge: code_challenge.to_string(),
|
||||
code_challenge_method: code_challenge_method.to_string(),
|
||||
response_type: response_type.to_string(),
|
||||
response_mode: response_mode.to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// A web login form for the OIDC authentication flow.
|
||||
///
|
||||
/// The returned `OidcResponse` handles CSP headers to allow that form.
|
||||
#[must_use]
|
||||
pub fn oidc_login_form(hostname: &str, query: &AuthorizationQuery) -> OidcResponse {
|
||||
// The target request route.
|
||||
let route = "/_matrix/client/unstable/org.matrix.msc2964/login";
|
||||
let nonce = rand::random::<u64>().to_string();
|
||||
let body = Some(Body::Text(login_page(hostname, query, route, &nonce)));
|
||||
|
||||
OidcResponse {
|
||||
status: StatusCode::OK,
|
||||
location: None,
|
||||
www_authenticate: None,
|
||||
body,
|
||||
nonce: Some(nonce),
|
||||
}
|
||||
}
|
||||
|
||||
/// Render the html contents of the login page.
|
||||
fn login_page(hostname: &str, query: &AuthorizationQuery, route: &str, nonce: &str) -> String {
|
||||
let response_mode = query.response_mode.as_deref().unwrap_or("fragment");
|
||||
let template = LoginPageTemplate {
|
||||
nonce,
|
||||
hostname,
|
||||
route,
|
||||
client_id: query.client_id.as_str(),
|
||||
client_secret: query.client_secret.as_deref(),
|
||||
redirect_uri: query.redirect_uri.as_str(),
|
||||
scope: query.scope.as_str(),
|
||||
state: query.state.as_str(),
|
||||
code_challenge: query.code_challenge.as_str(),
|
||||
code_challenge_method: query.code_challenge_method.as_str(),
|
||||
response_type: query.response_type.as_str(),
|
||||
response_mode,
|
||||
};
|
||||
|
||||
template.render().expect("login template render")
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
use std::borrow::Cow;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use axum::{
|
||||
extract::{Form, FromRequest, FromRequestParts, Query, Request},
|
||||
http::header,
|
||||
};
|
||||
use oxide_auth::endpoint::{NormalizedParameter, QueryParameter, WebRequest};
|
||||
|
||||
use super::{OidcError, OidcResponse};
|
||||
|
||||
/// An OIDC authentication request.
|
||||
///
|
||||
/// Expected to receive GET and POST requests to the `authorize` endpoint, or
|
||||
/// POST requests to the `login` endpoint.
|
||||
///
|
||||
/// Mostly adapted from the OAuthRequest struct in the [oxide-auth-axum] crate.
|
||||
/// [oxide-auth-axum]: https://docs.rs/oxide-auth-axum
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct OidcRequest {
|
||||
pub(crate) auth: Option<String>,
|
||||
pub(crate) query: Option<NormalizedParameter>,
|
||||
pub(crate) body: Option<NormalizedParameter>,
|
||||
}
|
||||
|
||||
impl OidcRequest {
|
||||
/// Fetch the authorization header from the request
|
||||
#[must_use]
|
||||
pub fn authorization_header(&self) -> Option<&str> { self.auth.as_deref() }
|
||||
|
||||
/// Fetch the query for this request
|
||||
#[must_use]
|
||||
pub fn query(&self) -> Option<&NormalizedParameter> { self.query.as_ref() }
|
||||
|
||||
/// Fetch the query mutably
|
||||
pub fn query_mut(&mut self) -> Option<&mut NormalizedParameter> { self.query.as_mut() }
|
||||
|
||||
/// Fetch the body of the request
|
||||
#[must_use]
|
||||
pub fn body(&self) -> Option<&NormalizedParameter> { self.body.as_ref() }
|
||||
}
|
||||
|
||||
impl WebRequest for OidcRequest {
|
||||
type Error = OidcError;
|
||||
type Response = OidcResponse;
|
||||
|
||||
fn query(&mut self) -> Result<Cow<'_, dyn QueryParameter + 'static>, Self::Error> {
|
||||
self.query
|
||||
.as_ref()
|
||||
.map(|q| {
|
||||
let q: &dyn QueryParameter = q;
|
||||
Cow::Borrowed(q)
|
||||
})
|
||||
.ok_or(OidcError::Query)
|
||||
}
|
||||
|
||||
fn urlbody(&mut self) -> Result<Cow<'_, dyn QueryParameter + 'static>, Self::Error> {
|
||||
self.body
|
||||
.as_ref()
|
||||
.map(|q| {
|
||||
let q: &dyn QueryParameter = q;
|
||||
Cow::Borrowed(q)
|
||||
})
|
||||
.ok_or(OidcError::Body)
|
||||
}
|
||||
|
||||
fn authheader(&mut self) -> Result<Option<Cow<'_, str>>, Self::Error> {
|
||||
Ok(self.auth.as_deref().map(Cow::Borrowed))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl<S> FromRequest<S> for OidcRequest
|
||||
where
|
||||
S: Send + Sync,
|
||||
{
|
||||
type Rejection = OidcError;
|
||||
|
||||
async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> {
|
||||
let mut all_auth = req.headers().get_all(header::AUTHORIZATION).iter();
|
||||
let optional = all_auth.next();
|
||||
|
||||
let auth = if all_auth.next().is_some() {
|
||||
return Err(OidcError::Authorization);
|
||||
} else {
|
||||
optional.and_then(|hv| hv.to_str().ok().map(str::to_owned))
|
||||
};
|
||||
|
||||
let (mut parts, body) = req.into_parts();
|
||||
let query = Query::from_request_parts(&mut parts, state)
|
||||
.await
|
||||
.ok()
|
||||
.map(|q: Query<NormalizedParameter>| q.0);
|
||||
|
||||
let req = Request::from_parts(parts, body);
|
||||
let body = Form::from_request(req, state)
|
||||
.await
|
||||
.ok()
|
||||
.map(|b: Form<NormalizedParameter>| b.0);
|
||||
|
||||
// If the query is empty and the body has a request, copy it over
|
||||
// because login forms are POST requests but OAuth flow expects
|
||||
// arguments in query.
|
||||
let query = match query {
|
||||
| None => body.clone(),
|
||||
| Some(params) => {
|
||||
//if params == NormalizedParameter::new() {
|
||||
if params.unique_value("client_id").is_none() {
|
||||
body.clone()
|
||||
} else {
|
||||
Some(params)
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
Ok(Self { auth, query, body })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
use axum::{
|
||||
body::Body,
|
||||
http::{Response, StatusCode, header},
|
||||
response::IntoResponse,
|
||||
};
|
||||
use oxide_auth::{
|
||||
endpoint::{OwnerConsent, OwnerSolicitor, Solicitation, WebRequest, WebResponse},
|
||||
frontends::simple::request::Body as OAuthRequestBody,
|
||||
};
|
||||
use url::Url;
|
||||
|
||||
use super::{LoginQuery, OidcError, OidcRequest, oidc_consent_form};
|
||||
|
||||
/// A Web response that can be processed by the OIDC authentication flow before
|
||||
/// being sent over.
|
||||
#[derive(Default, Clone, Debug)]
|
||||
pub struct OidcResponse {
|
||||
pub(crate) status: StatusCode,
|
||||
pub(crate) location: Option<Url>,
|
||||
pub(crate) www_authenticate: Option<String>,
|
||||
pub(crate) body: Option<OAuthRequestBody>,
|
||||
pub(crate) nonce: Option<String>,
|
||||
}
|
||||
|
||||
impl IntoResponse for OidcResponse {
|
||||
fn into_response(self) -> Response<Body> {
|
||||
let csp_src = match self.nonce {
|
||||
| Some(nonce) => &format!("default-src 'nonce-{nonce}';"),
|
||||
| None => "default-src 'none';",
|
||||
};
|
||||
let csp_form_action =
|
||||
"form-action 'self' http://localhost http://127.0.0.1 http://[::1];";
|
||||
let content_csp = format!("{csp_src} {csp_form_action}");
|
||||
let content_type = match self.body {
|
||||
| Some(OAuthRequestBody::Json(_)) => "application/json",
|
||||
| _ => "text/html",
|
||||
};
|
||||
let mut response = Response::builder()
|
||||
.status(self.status)
|
||||
.header(header::CONTENT_TYPE, content_type)
|
||||
.header(header::CONTENT_SECURITY_POLICY, content_csp);
|
||||
if let Some(location) = self.location {
|
||||
response = response.header(header::LOCATION, location.as_str());
|
||||
}
|
||||
// Transform from OAuthRequestBody to String.
|
||||
let body_content = self.body.map(|b| b.as_str().to_owned()).unwrap_or_default();
|
||||
|
||||
response.body(body_content.into()).unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
/// OidcResponse uses [super::oidc_consent_form] to be turned into an owner
|
||||
/// consent solicitation.
|
||||
impl OwnerSolicitor<OidcRequest> for OidcResponse {
|
||||
fn check_consent(
|
||||
&mut self,
|
||||
request: &mut OidcRequest,
|
||||
_: Solicitation<'_>,
|
||||
) -> OwnerConsent<<OidcRequest as WebRequest>::Response> {
|
||||
// TODO find a way to pass the hostname to the template.
|
||||
let hostname = "Continuwuity";
|
||||
let query: LoginQuery = request
|
||||
.clone()
|
||||
.try_into()
|
||||
.expect("login query from OidcRequest");
|
||||
|
||||
OwnerConsent::InProgress(oidc_consent_form(hostname, &query.into()))
|
||||
}
|
||||
}
|
||||
|
||||
impl WebResponse for OidcResponse {
|
||||
type Error = OidcError;
|
||||
|
||||
fn ok(&mut self) -> Result<(), Self::Error> {
|
||||
self.status = StatusCode::OK;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A response which will redirect the user-agent to which the response is
|
||||
/// issued.
|
||||
fn redirect(&mut self, url: Url) -> Result<(), Self::Error> {
|
||||
self.status = StatusCode::FOUND;
|
||||
self.location = Some(url);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Set the response status to 400.
|
||||
fn client_error(&mut self) -> Result<(), Self::Error> {
|
||||
self.status = StatusCode::BAD_REQUEST;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Set the response status to 401 and add a `WWW-Authenticate` header.
|
||||
fn unauthorized(&mut self, header_value: &str) -> Result<(), Self::Error> {
|
||||
self.status = StatusCode::UNAUTHORIZED;
|
||||
self.www_authenticate = Some(header_value.to_owned());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A pure text response with no special media type set.
|
||||
fn body_text(&mut self, text: &str) -> Result<(), Self::Error> {
|
||||
self.body = Some(OAuthRequestBody::Text(text.to_owned()));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Json response data, with media type `aplication/json.
|
||||
fn body_json(&mut self, data: &str) -> Result<(), Self::Error> {
|
||||
self.body = Some(OAuthRequestBody::Json(data.to_owned()));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{% extends "_layout.html.j2" %}
|
||||
{%- block content -%}
|
||||
<div class="panel">
|
||||
<h1 class-"project-name">{{ hostname }}</h1>
|
||||
<p>
|
||||
'{{ client_id }}' (at {{ redirect_uri }}) is requesting permission for '{{ scope }}'
|
||||
</p>
|
||||
<form method="post">
|
||||
<input type="submit" value="Accept" formaction="{{ route }}?client_id={{ client_id }}{%- if let Some(secret)
|
||||
= client_secret -%}&client_secret={{ secret }}{%- endif -%}&redirect_uri={{ redirect_uri }}&scope={{ scope
|
||||
}}&state={{ state }}&code_challenge={{ code_challenge }}&code_challenge_method={{ code_challenge_method
|
||||
}}&response_type={{ response_type }}&response_mode={{ response_mode }}&allow={{ user_id }}">
|
||||
<input type="submit" value="Deny" formaction="{{ route }}?client_id={{ client_id }}{%- if let Some(secret) = client_secret -%}&client_secret={{ secret }}{%- endif -%}&redirect_uri={{ redirect_uri }}&scope={{scope }}&state={{ state }}&code_challenge={{ code_challenge }}&code_challenge_method={{ code_challenge_method }}&response_type={{ response_type }}&response_mode={{ response_mode }}&deny=true">
|
||||
</form>
|
||||
</div>
|
||||
{%- endblock content -%}
|
||||
@@ -0,0 +1,22 @@
|
||||
{% extends "_layout.html.j2" %}
|
||||
{%- block content -%}
|
||||
<div class="panel">
|
||||
<h1 class-"project-name">{{ hostname }}</h1>
|
||||
<form action="{{ route }}" method="post">
|
||||
<input type="text" name="username" placeholder="Username" required>
|
||||
<input type="password" name="password" placeholder="Password" required>
|
||||
<input type="hidden" name="client_id" value="{{ client_id }}">
|
||||
{%- if let Some(secret) = client_secret -%}
|
||||
<input type="hidden" name="client_secret" value="{{ secret }}">
|
||||
{%- endif -%}
|
||||
<input type="hidden" name="redirect_uri" value="{{ redirect_uri }}">
|
||||
<input type="hidden" name="scope" value="{{ scope }}">
|
||||
<input type="hidden" name="state" value="{{ state }}">
|
||||
<input type="hidden" name="code_challenge" value="{{ code_challenge }}">
|
||||
<input type="hidden" name="code_challenge_method" value="{{ code_challenge_method }}">
|
||||
<input type="hidden" name="response_type" value="{{ response_type }}">
|
||||
<input type="hidden" name="response_mode" value="{{ response_mode }}">
|
||||
<button type="submit">Login</button>
|
||||
</form>
|
||||
</div>
|
||||
{%- endblock content -%}
|
||||
Reference in New Issue
Block a user