Add implicit authentication flow #255
@ -13,10 +13,15 @@ BasicOIDC operates without any database, just with three files :
|
|||||||
## Configuration
|
## Configuration
|
||||||
You can configure a list of clients (Relying Parties) in a `clients.yaml` file with the following syntax :
|
You can configure a list of clients (Relying Parties) in a `clients.yaml` file with the following syntax :
|
||||||
```yaml
|
```yaml
|
||||||
|
# Client ID
|
||||||
- id: gitea
|
- id: gitea
|
||||||
|
# Client name
|
||||||
name: Gitea
|
name: Gitea
|
||||||
|
# Client description
|
||||||
description: Git with a cup of tea
|
description: Git with a cup of tea
|
||||||
|
# Client secret. Specify this value to use authorization code flow, remove it for implicit authentication flow
|
||||||
secret: TOP_SECRET
|
secret: TOP_SECRET
|
||||||
|
# The URL where user shall be redirected after authentication
|
||||||
redirect_uri: https://mygit.mywebsite.com/
|
redirect_uri: https://mygit.mywebsite.com/
|
||||||
# If you want new accounts to be granted access to this client by default
|
# If you want new accounts to be granted access to this client by default
|
||||||
default: true
|
default: true
|
||||||
@ -32,6 +37,7 @@ In order to run BasicOIDC for development, you will need to create a least an em
|
|||||||
|
|
||||||
## Features
|
## Features
|
||||||
* [x] `authorization_code` flow
|
* [x] `authorization_code` flow
|
||||||
|
* [x] `implicit` flow
|
||||||
* [x] Client authentication using secrets
|
* [x] Client authentication using secrets
|
||||||
* [x] Bruteforce protection
|
* [x] Bruteforce protection
|
||||||
* [x] 2 factors authentication
|
* [x] 2 factors authentication
|
||||||
|
@ -69,6 +69,7 @@ pub const OPEN_ID_AUTHORIZATION_CODE_LEN: usize = 120;
|
|||||||
pub const OPEN_ID_AUTHORIZATION_CODE_TIMEOUT: u64 = 300;
|
pub const OPEN_ID_AUTHORIZATION_CODE_TIMEOUT: u64 = 300;
|
||||||
pub const OPEN_ID_ACCESS_TOKEN_LEN: usize = 50;
|
pub const OPEN_ID_ACCESS_TOKEN_LEN: usize = 50;
|
||||||
pub const OPEN_ID_ACCESS_TOKEN_TIMEOUT: u64 = 3600;
|
pub const OPEN_ID_ACCESS_TOKEN_TIMEOUT: u64 = 3600;
|
||||||
|
pub const OPEN_ID_ID_TOKEN_TIMEOUT: u64 = 3600;
|
||||||
pub const OPEN_ID_REFRESH_TOKEN_LEN: usize = 120;
|
pub const OPEN_ID_REFRESH_TOKEN_LEN: usize = 120;
|
||||||
pub const OPEN_ID_REFRESH_TOKEN_TIMEOUT: u64 = 360000;
|
pub const OPEN_ID_REFRESH_TOKEN_TIMEOUT: u64 = 360000;
|
||||||
|
|
||||||
|
@ -16,7 +16,7 @@ use crate::constants::*;
|
|||||||
use crate::controllers::base_controller::{build_fatal_error_page, redirect_user};
|
use crate::controllers::base_controller::{build_fatal_error_page, redirect_user};
|
||||||
use crate::data::action_logger::{Action, ActionLogger};
|
use crate::data::action_logger::{Action, ActionLogger};
|
||||||
use crate::data::app_config::AppConfig;
|
use crate::data::app_config::AppConfig;
|
||||||
use crate::data::client::{ClientID, ClientManager};
|
use crate::data::client::{AuthenticationFlow, ClientID, ClientManager};
|
||||||
use crate::data::code_challenge::CodeChallenge;
|
use crate::data::code_challenge::CodeChallenge;
|
||||||
use crate::data::current_user::CurrentUser;
|
use crate::data::current_user::CurrentUser;
|
||||||
use crate::data::id_token::IdToken;
|
use crate::data::id_token::IdToken;
|
||||||
@ -97,7 +97,7 @@ pub struct AuthorizeQuery {
|
|||||||
redirect_uri: String,
|
redirect_uri: String,
|
||||||
|
|
||||||
/// RECOMMENDED. Opaque value used to maintain state between the request and the callback. Typically, Cross-Site Request Forgery (CSRF, XSRF) mitigation is done by cryptographically binding the value of this parameter with a browser cookie.
|
/// RECOMMENDED. Opaque value used to maintain state between the request and the callback. Typically, Cross-Site Request Forgery (CSRF, XSRF) mitigation is done by cryptographically binding the value of this parameter with a browser cookie.
|
||||||
state: String,
|
state: Option<String>,
|
||||||
|
|
||||||
/// OPTIONAL. String value used to associate a Client session with an ID Token, and to mitigate replay attacks. The value is passed through unmodified from the Authentication Request to the ID Token. Sufficient entropy MUST be present in the nonce values used to prevent attackers from guessing values.
|
/// OPTIONAL. String value used to associate a Client session with an ID Token, and to mitigate replay attacks. The value is passed through unmodified from the Authentication Request to the ID Token. Sufficient entropy MUST be present in the nonce values used to prevent attackers from guessing values.
|
||||||
nonce: Option<String>,
|
nonce: Option<String>,
|
||||||
@ -118,16 +118,20 @@ fn error_redirect(query: &AuthorizeQuery, error: &str, description: &str) -> Htt
|
|||||||
.append_header((
|
.append_header((
|
||||||
"Location",
|
"Location",
|
||||||
format!(
|
format!(
|
||||||
"{}?error={}?error_description={}&state={}",
|
"{}?error={}?error_description={}{}",
|
||||||
query.redirect_uri,
|
query.redirect_uri,
|
||||||
urlencoding::encode(error),
|
urlencoding::encode(error),
|
||||||
urlencoding::encode(description),
|
urlencoding::encode(description),
|
||||||
urlencoding::encode(&query.state)
|
match &query.state {
|
||||||
|
Some(s) => format!("&state={}", urlencoding::encode(s)),
|
||||||
|
None => "".to_string(),
|
||||||
|
}
|
||||||
),
|
),
|
||||||
))
|
))
|
||||||
.finish()
|
.finish()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub async fn authorize(
|
pub async fn authorize(
|
||||||
req: HttpRequest,
|
req: HttpRequest,
|
||||||
user: CurrentUser,
|
user: CurrentUser,
|
||||||
@ -136,10 +140,13 @@ pub async fn authorize(
|
|||||||
clients: web::Data<Arc<ClientManager>>,
|
clients: web::Data<Arc<ClientManager>>,
|
||||||
sessions: web::Data<Addr<OpenIDSessionsActor>>,
|
sessions: web::Data<Addr<OpenIDSessionsActor>>,
|
||||||
logger: ActionLogger,
|
logger: ActionLogger,
|
||||||
) -> impl Responder {
|
jwt_signer: web::Data<JWTSigner>,
|
||||||
|
) -> actix_web::Result<HttpResponse> {
|
||||||
let client = match clients.find_by_id(&query.client_id) {
|
let client = match clients.find_by_id(&query.client_id) {
|
||||||
None => {
|
None => {
|
||||||
return HttpResponse::BadRequest().body(build_fatal_error_page("Client is invalid!"));
|
return Ok(
|
||||||
|
HttpResponse::BadRequest().body(build_fatal_error_page("Client is invalid!"))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
Some(c) => c,
|
Some(c) => c,
|
||||||
};
|
};
|
||||||
@ -147,39 +154,42 @@ pub async fn authorize(
|
|||||||
// Check if 2FA is required
|
// Check if 2FA is required
|
||||||
if client.enforce_2fa_auth && user.should_request_2fa_for_critical_functions() {
|
if client.enforce_2fa_auth && user.should_request_2fa_for_critical_functions() {
|
||||||
let uri = get_2fa_url(&LoginRedirect::from_req(&req), true);
|
let uri = get_2fa_url(&LoginRedirect::from_req(&req), true);
|
||||||
return redirect_user(&uri);
|
return Ok(redirect_user(&uri));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate specified redirect URI
|
||||||
let redirect_uri = query.redirect_uri.trim().to_string();
|
let redirect_uri = query.redirect_uri.trim().to_string();
|
||||||
if !redirect_uri.starts_with(&client.redirect_uri) {
|
if !redirect_uri.starts_with(&client.redirect_uri) {
|
||||||
return HttpResponse::BadRequest().body(build_fatal_error_page("Redirect URI is invalid!"));
|
return Ok(
|
||||||
}
|
HttpResponse::BadRequest().body(build_fatal_error_page("Redirect URI is invalid!"))
|
||||||
|
|
||||||
if !query.scope.split(' ').any(|x| x == "openid") {
|
|
||||||
return error_redirect(&query, "invalid_request", "openid scope missing!");
|
|
||||||
}
|
|
||||||
|
|
||||||
if !query.response_type.eq("code") {
|
|
||||||
return error_redirect(
|
|
||||||
&query,
|
|
||||||
"invalid_request",
|
|
||||||
"Only code response type is supported!",
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if query.state.is_empty() {
|
if !query.scope.split(' ').any(|x| x == "openid") {
|
||||||
return error_redirect(&query, "invalid_request", "State is empty!");
|
return Ok(error_redirect(
|
||||||
|
&query,
|
||||||
|
"invalid_request",
|
||||||
|
"openid scope missing!",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if query.state.as_ref().map(String::is_empty).unwrap_or(false) {
|
||||||
|
return Ok(error_redirect(
|
||||||
|
&query,
|
||||||
|
"invalid_request",
|
||||||
|
"State is specified but empty!",
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let code_challenge = match query.0.code_challenge.clone() {
|
let code_challenge = match query.0.code_challenge.clone() {
|
||||||
Some(chal) => {
|
Some(chal) => {
|
||||||
let meth = query.0.code_challenge_method.as_deref().unwrap_or("plain");
|
let meth = query.0.code_challenge_method.as_deref().unwrap_or("plain");
|
||||||
if !meth.eq("S256") && !meth.eq("plain") {
|
if !meth.eq("S256") && !meth.eq("plain") {
|
||||||
return error_redirect(
|
return Ok(error_redirect(
|
||||||
&query,
|
&query,
|
||||||
"invalid_request",
|
"invalid_request",
|
||||||
"Only S256 and plain code challenge methods are supported!",
|
"Only S256 and plain code challenge methods are supported!",
|
||||||
);
|
));
|
||||||
}
|
}
|
||||||
Some(CodeChallenge {
|
Some(CodeChallenge {
|
||||||
code_challenge: chal,
|
code_challenge: chal,
|
||||||
@ -191,49 +201,110 @@ pub async fn authorize(
|
|||||||
|
|
||||||
// Check if user is authorized to access the application
|
// Check if user is authorized to access the application
|
||||||
if !user.can_access_app(&client) {
|
if !user.can_access_app(&client) {
|
||||||
return error_redirect(
|
return Ok(error_redirect(
|
||||||
&query,
|
&query,
|
||||||
"invalid_request",
|
"invalid_request",
|
||||||
"User is not authorized to access this application!",
|
"User is not authorized to access this application!",
|
||||||
);
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save all authentication information in memory
|
// Check that requested authorization flow is supported
|
||||||
let session = Session {
|
if query.response_type != "code" && query.response_type != "id_token" {
|
||||||
session_id: SessionID(rand_str(OPEN_ID_SESSION_LEN)),
|
return Ok(error_redirect(
|
||||||
client: client.id.clone(),
|
&query,
|
||||||
user: user.uid.clone(),
|
"invalid_request",
|
||||||
auth_time: SessionIdentity(Some(&id)).auth_time(),
|
"Unsupported authorization flow!",
|
||||||
redirect_uri,
|
));
|
||||||
authorization_code: rand_str(OPEN_ID_AUTHORIZATION_CODE_LEN),
|
}
|
||||||
authorization_code_expire_at: time() + OPEN_ID_AUTHORIZATION_CODE_TIMEOUT,
|
|
||||||
access_token: None,
|
|
||||||
access_token_expire_at: time() + OPEN_ID_ACCESS_TOKEN_TIMEOUT,
|
|
||||||
refresh_token: "".to_string(),
|
|
||||||
refresh_token_expire_at: 0,
|
|
||||||
nonce: query.0.nonce,
|
|
||||||
code_challenge,
|
|
||||||
};
|
|
||||||
sessions
|
|
||||||
.send(openid_sessions_actor::PushNewSession(session.clone()))
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
log::trace!("New OpenID session: {:#?}", session);
|
match (client.auth_flow(), query.response_type.as_str()) {
|
||||||
logger.log(Action::NewOpenIDSession { client: &client });
|
(AuthenticationFlow::AuthorizationCode, "code") => {
|
||||||
|
// Save all authentication information in memory
|
||||||
|
let session = Session {
|
||||||
|
session_id: SessionID(rand_str(OPEN_ID_SESSION_LEN)),
|
||||||
|
client: client.id.clone(),
|
||||||
|
user: user.uid.clone(),
|
||||||
|
auth_time: SessionIdentity(Some(&id)).auth_time(),
|
||||||
|
redirect_uri,
|
||||||
|
authorization_code: rand_str(OPEN_ID_AUTHORIZATION_CODE_LEN),
|
||||||
|
authorization_code_expire_at: time() + OPEN_ID_AUTHORIZATION_CODE_TIMEOUT,
|
||||||
|
access_token: None,
|
||||||
|
access_token_expire_at: time() + OPEN_ID_ACCESS_TOKEN_TIMEOUT,
|
||||||
|
refresh_token: "".to_string(),
|
||||||
|
refresh_token_expire_at: 0,
|
||||||
|
nonce: query.0.nonce,
|
||||||
|
code_challenge,
|
||||||
|
};
|
||||||
|
sessions
|
||||||
|
.send(openid_sessions_actor::PushNewSession(session.clone()))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
HttpResponse::Found()
|
log::trace!("New OpenID session: {:#?}", session);
|
||||||
.append_header((
|
logger.log(Action::NewOpenIDSession { client: &client });
|
||||||
"Location",
|
|
||||||
format!(
|
Ok(HttpResponse::Found()
|
||||||
"{}?state={}&session_state={}&code={}",
|
.append_header((
|
||||||
session.redirect_uri,
|
"Location",
|
||||||
urlencoding::encode(&query.0.state),
|
format!(
|
||||||
urlencoding::encode(&session.session_id.0),
|
"{}?{}session_state={}&code={}",
|
||||||
urlencoding::encode(&session.authorization_code)
|
session.redirect_uri,
|
||||||
),
|
match &query.0.state {
|
||||||
))
|
Some(state) => format!("state={}&", urlencoding::encode(state)),
|
||||||
.finish()
|
None => "".to_string(),
|
||||||
|
},
|
||||||
|
urlencoding::encode(&session.session_id.0),
|
||||||
|
urlencoding::encode(&session.authorization_code)
|
||||||
|
),
|
||||||
|
))
|
||||||
|
.finish())
|
||||||
|
}
|
||||||
|
|
||||||
|
(AuthenticationFlow::Implicit, "id_token") => {
|
||||||
|
let id_token = IdToken {
|
||||||
|
issuer: AppConfig::get().website_origin.to_string(),
|
||||||
|
subject_identifier: user.uid.0.clone(),
|
||||||
|
audience: client.id.0.to_string(),
|
||||||
|
expiration_time: time() + OPEN_ID_ID_TOKEN_TIMEOUT,
|
||||||
|
issued_at: time(),
|
||||||
|
auth_time: SessionIdentity(Some(&id)).auth_time(),
|
||||||
|
nonce: query.nonce.clone(),
|
||||||
|
email: user.email.clone(),
|
||||||
|
};
|
||||||
|
|
||||||
|
log::trace!("New OpenID id token: {:#?}", &id_token);
|
||||||
|
logger.log(Action::NewOpenIDSuccessfulImplicitAuth { client: &client });
|
||||||
|
|
||||||
|
Ok(HttpResponse::Found()
|
||||||
|
.append_header((
|
||||||
|
"Location",
|
||||||
|
format!(
|
||||||
|
"{}?{}token_type=bearer&id_token={}&expires_in={OPEN_ID_ID_TOKEN_TIMEOUT}",
|
||||||
|
client.redirect_uri,
|
||||||
|
match &query.0.state {
|
||||||
|
Some(state) => format!("state={}&", urlencoding::encode(state)),
|
||||||
|
None => "".to_string(),
|
||||||
|
},
|
||||||
|
jwt_signer.sign_token(id_token.to_jwt_claims())?
|
||||||
|
),
|
||||||
|
))
|
||||||
|
.finish())
|
||||||
|
}
|
||||||
|
|
||||||
|
(flow, code) => {
|
||||||
|
log::warn!(
|
||||||
|
"For client {:?}, configured with flow {:?}, made request with code {}",
|
||||||
|
client.id,
|
||||||
|
flow,
|
||||||
|
code
|
||||||
|
);
|
||||||
|
Ok(error_redirect(
|
||||||
|
&query,
|
||||||
|
"invalid_request",
|
||||||
|
"Requested authentication flow is unsupported / not configured for this client!",
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(serde::Serialize)]
|
#[derive(serde::Serialize)]
|
||||||
@ -344,7 +415,8 @@ pub async fn token(
|
|||||||
.find_by_id(&client_id)
|
.find_by_id(&client_id)
|
||||||
.ok_or_else(|| ErrorUnauthorized("Client not found"))?;
|
.ok_or_else(|| ErrorUnauthorized("Client not found"))?;
|
||||||
|
|
||||||
if !client.secret.eq(&client_secret) {
|
// Retrieving token requires the client to have a defined secret
|
||||||
|
if client.secret != Some(client_secret) {
|
||||||
return Ok(error_response(
|
return Ok(error_response(
|
||||||
&query,
|
&query,
|
||||||
"invalid_request",
|
"invalid_request",
|
||||||
|
@ -90,6 +90,9 @@ pub enum Action<'a> {
|
|||||||
NewOpenIDSession {
|
NewOpenIDSession {
|
||||||
client: &'a Client,
|
client: &'a Client,
|
||||||
},
|
},
|
||||||
|
NewOpenIDSuccessfulImplicitAuth {
|
||||||
|
client: &'a Client,
|
||||||
|
},
|
||||||
ChangedHisPassword,
|
ChangedHisPassword,
|
||||||
ClearedHisLoginHistory,
|
ClearedHisLoginHistory,
|
||||||
AddNewFactor(&'a TwoFactor),
|
AddNewFactor(&'a TwoFactor),
|
||||||
@ -199,6 +202,7 @@ impl<'a> Action<'a> {
|
|||||||
Action::NewOpenIDSession { client } => {
|
Action::NewOpenIDSession { client } => {
|
||||||
format!("opened a new OpenID session with {:?}", client.id)
|
format!("opened a new OpenID session with {:?}", client.id)
|
||||||
}
|
}
|
||||||
|
Action::NewOpenIDSuccessfulImplicitAuth { client } => format!("finished an implicit flow connection for client {:?}", client.id),
|
||||||
Action::ChangedHisPassword => "changed his password".to_string(),
|
Action::ChangedHisPassword => "changed his password".to_string(),
|
||||||
Action::ClearedHisLoginHistory => "cleared his login history".to_string(),
|
Action::ClearedHisLoginHistory => "cleared his login history".to_string(),
|
||||||
Action::AddNewFactor(factor) => format!(
|
Action::AddNewFactor(factor) => format!(
|
||||||
@ -206,7 +210,6 @@ impl<'a> Action<'a> {
|
|||||||
factor.quick_description(),
|
factor.quick_description(),
|
||||||
),
|
),
|
||||||
Action::Removed2FAFactor { factor_id } => format!("Removed his factor {factor_id:?}"),
|
Action::Removed2FAFactor { factor_id } => format!("Removed his factor {factor_id:?}"),
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -4,6 +4,12 @@ use crate::utils::string_utils::apply_env_vars;
|
|||||||
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize, Eq, PartialEq)]
|
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize, Eq, PartialEq)]
|
||||||
pub struct ClientID(pub String);
|
pub struct ClientID(pub String);
|
||||||
|
|
||||||
|
#[derive(Debug, Copy, Clone, Eq, PartialEq)]
|
||||||
|
pub enum AuthenticationFlow {
|
||||||
|
AuthorizationCode,
|
||||||
|
Implicit,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
|
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
|
||||||
pub struct Client {
|
pub struct Client {
|
||||||
/// The ID of the client
|
/// The ID of the client
|
||||||
@ -16,7 +22,8 @@ pub struct Client {
|
|||||||
pub description: String,
|
pub description: String,
|
||||||
|
|
||||||
/// The secret used by the client to retrieve authenticated users information
|
/// The secret used by the client to retrieve authenticated users information
|
||||||
pub secret: String,
|
/// This value is absent if implicit authentication flow is used
|
||||||
|
pub secret: Option<String>,
|
||||||
|
|
||||||
/// The URI where the users should be redirected once authenticated
|
/// The URI where the users should be redirected once authenticated
|
||||||
pub redirect_uri: String,
|
pub redirect_uri: String,
|
||||||
@ -42,6 +49,16 @@ impl PartialEq for Client {
|
|||||||
|
|
||||||
impl Eq for Client {}
|
impl Eq for Client {}
|
||||||
|
|
||||||
|
impl Client {
|
||||||
|
/// Get the client authentication flow
|
||||||
|
pub fn auth_flow(&self) -> AuthenticationFlow {
|
||||||
|
match self.secret {
|
||||||
|
None => AuthenticationFlow::Implicit,
|
||||||
|
Some(_) => AuthenticationFlow::AuthorizationCode,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub type ClientManager = EntityManager<Client>;
|
pub type ClientManager = EntityManager<Client>;
|
||||||
|
|
||||||
impl EntityManager<Client> {
|
impl EntityManager<Client> {
|
||||||
@ -66,7 +83,7 @@ impl EntityManager<Client> {
|
|||||||
c.id = ClientID(apply_env_vars(&c.id.0));
|
c.id = ClientID(apply_env_vars(&c.id.0));
|
||||||
c.name = apply_env_vars(&c.name);
|
c.name = apply_env_vars(&c.name);
|
||||||
c.description = apply_env_vars(&c.description);
|
c.description = apply_env_vars(&c.description);
|
||||||
c.secret = apply_env_vars(&c.secret);
|
c.secret = c.secret.as_deref().map(apply_env_vars);
|
||||||
c.redirect_uri = apply_env_vars(&c.redirect_uri);
|
c.redirect_uri = apply_env_vars(&c.redirect_uri);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
use jwt_simple::claims::Audiences;
|
use jwt_simple::claims::Audiences;
|
||||||
use jwt_simple::prelude::{Duration, JWTClaims};
|
use jwt_simple::prelude::{Duration, JWTClaims};
|
||||||
|
|
||||||
#[derive(serde::Serialize)]
|
#[derive(serde::Serialize, Debug)]
|
||||||
pub struct IdToken {
|
pub struct IdToken {
|
||||||
/// REQUIRED. Issuer Identifier for the Issuer of the response. The iss value is a case sensitive URL using the https scheme that contains scheme, host, and optionally, port number and path components and no query or fragment components.
|
/// REQUIRED. Issuer Identifier for the Issuer of the response. The iss value is a case sensitive URL using the https scheme that contains scheme, host, and optionally, port number and path components and no query or fragment components.
|
||||||
#[serde(rename = "iss")]
|
#[serde(rename = "iss")]
|
||||||
|
Loading…
Reference in New Issue
Block a user