mirror of
https://forgejo.ellis.link/continuwuation/continuwuity.git
synced 2025-09-11 19:53:02 +02:00
feat(oidc_provider) use askama templates
Implements a custom OidcResponse with CSP headers and oxide-auth processing compatibility.
This commit is contained in:
parent
3417ac2487
commit
fa9b8869b6
18 changed files with 621 additions and 225 deletions
|
@ -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,11 @@ 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
|
||||
oxide-auth-axum.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
|
|
@ -8,6 +8,7 @@ use axum::{
|
|||
};
|
||||
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();
|
||||
|
|
56
src/web/oidc.rs
Normal file
56
src/web/oidc.rs
Normal file
|
@ -0,0 +1,56 @@
|
|||
use percent_encoding::{utf8_percent_encode, NON_ALPHANUMERIC};
|
||||
use askama::Template;
|
||||
// Imports needed by askama templates.
|
||||
use crate::{
|
||||
VERSION_EXTRA, GIT_REMOTE_WEB_URL, GIT_REMOTE_COMMIT_URL,
|
||||
};
|
||||
|
||||
mod authorize;
|
||||
mod consent;
|
||||
mod login;
|
||||
mod response;
|
||||
mod request;
|
||||
pub use authorize::AuthorizationQuery;
|
||||
pub use consent::oidc_consent_form;
|
||||
pub use login::{LoginQuery, LoginError, 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,
|
||||
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,
|
||||
client_id: &'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()
|
||||
}
|
43
src/web/oidc/authorize.rs
Normal file
43
src/web/oidc/authorize.rs
Normal file
|
@ -0,0 +1,43 @@
|
|||
use url::Url;
|
||||
use super::LoginQuery;
|
||||
|
||||
/// The set of parameters required for an OIDC authorization request.
|
||||
#[derive(serde::Deserialize, Debug)]
|
||||
pub struct AuthorizationQuery {
|
||||
pub client_id: 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,
|
||||
}
|
||||
|
||||
impl From<LoginQuery> for AuthorizationQuery {
|
||||
fn from(value: LoginQuery) -> Self {
|
||||
let LoginQuery {
|
||||
client_id,
|
||||
redirect_uri,
|
||||
scope,
|
||||
state,
|
||||
code_challenge,
|
||||
code_challenge_method,
|
||||
response_type,
|
||||
response_mode,
|
||||
..
|
||||
} = value;
|
||||
|
||||
AuthorizationQuery {
|
||||
client_id,
|
||||
redirect_uri,
|
||||
scope,
|
||||
state,
|
||||
code_challenge,
|
||||
code_challenge_method,
|
||||
response_type,
|
||||
response_mode,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
54
src/web/oidc/consent.rs
Normal file
54
src/web/oidc/consent.rs
Normal file
|
@ -0,0 +1,54 @@
|
|||
use super::{
|
||||
encode,
|
||||
ConsentPageTemplate,
|
||||
AuthorizationQuery,
|
||||
OidcResponse,
|
||||
};
|
||||
use askama::Template;
|
||||
use oxide_auth::frontends::simple::request::{Body, Status};
|
||||
|
||||
/// 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.
|
||||
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: Status::Ok,
|
||||
location: None,
|
||||
www_authenticate: None,
|
||||
body,
|
||||
nonce,
|
||||
}
|
||||
}
|
||||
|
||||
/// Render the html contents of the user consent page.
|
||||
fn consent_page(
|
||||
hostname: &str,
|
||||
query: &AuthorizationQuery,
|
||||
route: &str,
|
||||
nonce: &str,
|
||||
) -> String {
|
||||
let template = ConsentPageTemplate {
|
||||
nonce,
|
||||
hostname,
|
||||
route,
|
||||
client_id: &encode(query.client_id.as_str()),
|
||||
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(query.response_mode.as_str()),
|
||||
};
|
||||
|
||||
template.render().expect("consent page render")
|
||||
}
|
132
src/web/oidc/login.rs
Normal file
132
src/web/oidc/login.rs
Normal file
|
@ -0,0 +1,132 @@
|
|||
use super::{
|
||||
AuthorizationQuery,
|
||||
LoginPageTemplate,
|
||||
OidcRequest,
|
||||
OidcResponse,
|
||||
};
|
||||
use askama::Template;
|
||||
use oxide_auth::{
|
||||
endpoint::QueryParameter,
|
||||
frontends::simple::request::{Body, Status},
|
||||
};
|
||||
use url::Url;
|
||||
use std::str::FromStr;
|
||||
|
||||
|
||||
/// 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 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_string()));
|
||||
};
|
||||
let Some(password) = body.unique_value("password") else {
|
||||
return Err(LoginError("missing field: password".to_string()));
|
||||
};
|
||||
let Some(client_id) = body.unique_value("client_id") else {
|
||||
return Err(LoginError("missing field: client_id".to_string()));
|
||||
};
|
||||
let Some(redirect_uri) = body.unique_value("redirect_uri") else {
|
||||
return Err(LoginError("missing field: redirect_uri".to_string()));
|
||||
};
|
||||
let Some(scope) = body.unique_value("scope") else {
|
||||
return Err(LoginError("missing field: scope".to_string()));
|
||||
};
|
||||
let Some(state) = body.unique_value("state") else {
|
||||
return Err(LoginError("missing field: state".to_string()));
|
||||
};
|
||||
let Some(code_challenge) = body.unique_value("code_challenge") else {
|
||||
return Err(LoginError("missing field: code_challenge".to_string()));
|
||||
};
|
||||
let Some(code_challenge_method) = body.unique_value("code_challenge_method") else {
|
||||
return Err(LoginError("missing field: code_challenge_method".to_string()));
|
||||
};
|
||||
let Some(response_type) = body.unique_value("response_type") else {
|
||||
return Err(LoginError("missing field: response_type".to_string()));
|
||||
};
|
||||
let Some(response_mode) = body.unique_value("response_mode") else {
|
||||
return Err(LoginError("missing field: response_mode".to_string()));
|
||||
};
|
||||
let Ok(redirect_uri) = Url::from_str(&redirect_uri) else {
|
||||
return Err(LoginError("invalid field: redirect_uri".to_string()));
|
||||
};
|
||||
|
||||
Ok(LoginQuery {
|
||||
username: username.to_string(),
|
||||
password: password.to_string(),
|
||||
client_id: client_id.to_string(),
|
||||
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.
|
||||
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: Status::Ok,
|
||||
location: None,
|
||||
www_authenticate: None,
|
||||
body,
|
||||
nonce,
|
||||
}
|
||||
}
|
||||
|
||||
/// Render the html contents of the login page.
|
||||
fn login_page(
|
||||
hostname: &str,
|
||||
query: &AuthorizationQuery,
|
||||
route: &str,
|
||||
nonce: &str,
|
||||
) -> String {
|
||||
let template = LoginPageTemplate {
|
||||
nonce,
|
||||
hostname,
|
||||
route,
|
||||
client_id: query.client_id.as_str(),
|
||||
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: query.response_mode.as_str(),
|
||||
};
|
||||
|
||||
template.render().expect("login template render")
|
||||
}
|
116
src/web/oidc/request.rs
Normal file
116
src/web/oidc/request.rs
Normal file
|
@ -0,0 +1,116 @@
|
|||
use super::OidcResponse;
|
||||
use oxide_auth::endpoint::{NormalizedParameter, QueryParameter, WebRequest};
|
||||
use oxide_auth_axum::WebError;
|
||||
use async_trait::async_trait;
|
||||
use axum::{
|
||||
extract::{Form, FromRequest, FromRequestParts, Query, Request},
|
||||
http::header,
|
||||
};
|
||||
use std::borrow::Cow;
|
||||
|
||||
/// 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
|
||||
pub fn authorization_header(&self) -> Option<&str> {
|
||||
self.auth.as_deref()
|
||||
}
|
||||
|
||||
/// Fetch the query for this request
|
||||
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
|
||||
pub fn body(&self) -> Option<&NormalizedParameter> {
|
||||
self.body.as_ref()
|
||||
}
|
||||
}
|
||||
|
||||
impl WebRequest for OidcRequest {
|
||||
type Error = WebError;
|
||||
type Response = OidcResponse;
|
||||
|
||||
fn query(&mut self) -> Result<Cow<'_, dyn QueryParameter + 'static>, Self::Error> {
|
||||
self.query
|
||||
.as_ref()
|
||||
.map(|q| Cow::Borrowed(q as &dyn QueryParameter))
|
||||
.ok_or(WebError::Query)
|
||||
}
|
||||
|
||||
fn urlbody(&mut self) -> Result<Cow<'_, dyn QueryParameter + 'static>, Self::Error> {
|
||||
self.body
|
||||
.as_ref()
|
||||
.map(|b| Cow::Borrowed(b as &dyn QueryParameter))
|
||||
.ok_or(WebError::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 = WebError;
|
||||
|
||||
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(WebError::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 })
|
||||
}
|
||||
}
|
127
src/web/oidc/response.rs
Normal file
127
src/web/oidc/response.rs
Normal file
|
@ -0,0 +1,127 @@
|
|||
use super::{OidcRequest, oidc_consent_form};
|
||||
|
||||
use crate::oidc::LoginQuery;
|
||||
use oxide_auth::{
|
||||
endpoint::{OwnerConsent, OwnerSolicitor, Solicitation, WebRequest, WebResponse},
|
||||
frontends::simple::request::{Body as OAuthRequestBody, Status},
|
||||
};
|
||||
use oxide_auth_axum::WebError;
|
||||
use axum::{
|
||||
body::Body,
|
||||
http::{Response, header},
|
||||
response::IntoResponse,
|
||||
};
|
||||
use url::Url;
|
||||
|
||||
/// 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: Status,
|
||||
pub(crate) location: Option<Url>,
|
||||
pub(crate) www_authenticate: Option<String>,
|
||||
pub(crate) body: Option<OAuthRequestBody>,
|
||||
pub(crate) nonce: String,
|
||||
}
|
||||
|
||||
impl OidcResponse {
|
||||
/// Instanciate from a response body. Used to send login or consent forms.
|
||||
pub fn from_body(body: &str) -> Result<Self, WebError> {
|
||||
let mut result = OidcResponse::default();
|
||||
result.body_text(body)?;
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for OidcResponse {
|
||||
fn into_response(self) -> Response<Body> {
|
||||
let body = self.body.expect("body").as_str().to_string();
|
||||
let response = Response::builder()
|
||||
.header(header::CONTENT_TYPE, "text/html")
|
||||
.header(
|
||||
header::CONTENT_SECURITY_POLICY,
|
||||
format!("default-src 'nonce-{}'; form-action https://eon.presentmatter.one/;", self.nonce)
|
||||
)
|
||||
.body(body.into())
|
||||
.unwrap();
|
||||
|
||||
response
|
||||
}
|
||||
}
|
||||
|
||||
impl OwnerSolicitor<OidcRequest> for OidcResponse {
|
||||
fn check_consent(
|
||||
&mut self,
|
||||
request: &mut OidcRequest,
|
||||
_: Solicitation<'_>,
|
||||
) -> OwnerConsent<<OidcRequest as WebRequest>::Response> {
|
||||
//let hostname = self.location.map(|l| l.as_str()).unwrap_or("Continuwuity");
|
||||
let hostname = "Continuwuity";
|
||||
/*
|
||||
let hostname = request
|
||||
.query()
|
||||
.expect("query in OAuth request")
|
||||
.unique_value("hostname")
|
||||
.expect("hostname in OAuth request")
|
||||
.as_str();
|
||||
*/
|
||||
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 = WebError;
|
||||
|
||||
fn ok(&mut self) -> Result<(), Self::Error> {
|
||||
self.status = Status::Ok;
|
||||
self.location = None;
|
||||
self.www_authenticate = None;
|
||||
|
||||
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 = Status::Redirect;
|
||||
self.location = Some(url);
|
||||
self.www_authenticate = None;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Set the response status to 400.
|
||||
fn client_error(&mut self) -> Result<(), Self::Error> {
|
||||
self.status = Status::BadRequest;
|
||||
self.location = None;
|
||||
self.www_authenticate = None;
|
||||
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 = Status::Unauthorized;
|
||||
self.location = None;
|
||||
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 repsonse 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(())
|
||||
}
|
||||
}
|
14
src/web/templates/consent.html.j2
Normal file
14
src/web/templates/consent.html.j2
Normal file
|
@ -0,0 +1,14 @@
|
|||
{% 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 }}&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=true">
|
||||
<input type="submit" value="Deny" formaction="{{ route }}?client_id={{ client_id }}&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 -%}
|
||||
|
20
src/web/templates/login.html.j2
Normal file
20
src/web/templates/login.html.j2
Normal file
|
@ -0,0 +1,20 @@
|
|||
{% 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 }}">
|
||||
<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 -%}
|
||||
|
Loading…
Add table
Add a link
Reference in a new issue