Rewrite auth endpoint to allow anonymous tokens, and better tokens
This commit is contained in:
parent
618c04c29b
commit
95914653e0
|
@ -321,6 +321,7 @@ dependencies = [
|
||||||
"js-sys",
|
"js-sys",
|
||||||
"num-integer",
|
"num-integer",
|
||||||
"num-traits",
|
"num-traits",
|
||||||
|
"serde",
|
||||||
"time",
|
"time",
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
"winapi",
|
"winapi",
|
||||||
|
|
|
@ -14,7 +14,7 @@ uuid = { version = "1.3.1", features = [ "v4", "fast-rng" ] }
|
||||||
sqlx = { version = "0.6.3", features = [ "runtime-tokio-rustls", "sqlite" ] }
|
sqlx = { version = "0.6.3", features = [ "runtime-tokio-rustls", "sqlite" ] }
|
||||||
|
|
||||||
bytes = "1.4.0"
|
bytes = "1.4.0"
|
||||||
chrono = "0.4.23"
|
chrono = { version = "0.4.23", features = [ "serde" ] }
|
||||||
tokio = { version = "1.21.2", features = [ "fs", "macros" ] }
|
tokio = { version = "1.21.2", features = [ "fs", "macros" ] }
|
||||||
tokio-util = { version = "0.7.7", features = [ "io" ] }
|
tokio-util = { version = "0.7.7", features = [ "io" ] }
|
||||||
|
|
||||||
|
|
218
src/api/auth.rs
218
src/api/auth.rs
|
@ -1,8 +1,17 @@
|
||||||
use std::{sync::Arc, collections::{HashMap, BTreeMap}, time::SystemTime};
|
use std::{
|
||||||
|
collections::{BTreeMap, HashMap},
|
||||||
|
sync::Arc,
|
||||||
|
time::SystemTime,
|
||||||
|
};
|
||||||
|
|
||||||
use axum::{extract::{Query, State}, response::{IntoResponse, Response}, http::{StatusCode, header}, Form};
|
use axum::{
|
||||||
|
extract::{Query, State},
|
||||||
|
http::{header, StatusCode},
|
||||||
|
response::{IntoResponse, Response},
|
||||||
|
Form,
|
||||||
|
};
|
||||||
use axum_auth::AuthBasic;
|
use axum_auth::AuthBasic;
|
||||||
use chrono::{DateTime, Utc, Duration};
|
use chrono::{DateTime, Days, Duration, Utc};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use tracing::{debug, error, info, span, Level};
|
use tracing::{debug, error, info, span, Level};
|
||||||
|
|
||||||
|
@ -12,8 +21,15 @@ use sha2::Sha256;
|
||||||
|
|
||||||
use rand::Rng;
|
use rand::Rng;
|
||||||
|
|
||||||
use crate::{dto::{scope::Scope, user::TokenInfo}, app_state::AppState};
|
use crate::{database::Database, dto::scope::Action};
|
||||||
use crate::database::Database;
|
use crate::{
|
||||||
|
app_state::AppState,
|
||||||
|
dto::{
|
||||||
|
scope::{Scope, ScopeType},
|
||||||
|
user::{AuthToken, TokenInfo},
|
||||||
|
RepositoryVisibility,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
use crate::auth::auth_challenge_response;
|
use crate::auth::auth_challenge_response;
|
||||||
|
|
||||||
|
@ -44,38 +60,43 @@ pub struct AuthResponse {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// In the returned UserToken::user, only the username is specified
|
/// In the returned UserToken::user, only the username is specified
|
||||||
fn create_jwt_token(account: &str) -> anyhow::Result<TokenInfo> {
|
fn create_jwt_token(account: Option<&str>, scopes: Vec<Scope>) -> anyhow::Result<TokenInfo> {
|
||||||
let key: Hmac<Sha256> = Hmac::new_from_slice(b"some-secret")?;
|
let key: Hmac<Sha256> = Hmac::new_from_slice(b"some-secret")?;
|
||||||
|
|
||||||
let now = chrono::offset::Utc::now();
|
let now = chrono::offset::Utc::now();
|
||||||
let now_secs = now.timestamp();
|
|
||||||
|
|
||||||
// Construct the claims for the token
|
// Expire the token in a day
|
||||||
let mut claims = BTreeMap::new();
|
let expiration = now.checked_add_days(Days::new(1)).unwrap();
|
||||||
claims.insert("issuer", "orca-registry__DEV");
|
|
||||||
claims.insert("subject", &account);
|
|
||||||
//claims.insert("audience", auth.service);
|
|
||||||
|
|
||||||
let not_before = format!("{}", now_secs);
|
|
||||||
let issued_at = format!("{}", now_secs);
|
|
||||||
let expiration = format!("{}", now_secs + 86400); // 1 day
|
|
||||||
claims.insert("notbefore", ¬_before);
|
|
||||||
claims.insert("issuedat", &issued_at);
|
|
||||||
claims.insert("expiration", &expiration); // TODO: 20 seconds expiry for testing
|
|
||||||
|
|
||||||
let issued_at = now;
|
|
||||||
let expiration = now + Duration::seconds(20);
|
|
||||||
|
|
||||||
// Create a randomized jwtid
|
|
||||||
let mut rng = rand::thread_rng();
|
let mut rng = rand::thread_rng();
|
||||||
let jwtid = format!("{}", rng.gen::<u64>());
|
let jwtid = format!("{}", rng.gen::<u64>());
|
||||||
claims.insert("jwtid", &jwtid);
|
|
||||||
|
|
||||||
let token_str = claims.sign_with_key(&key)?;
|
// empty account if they are not authenticated
|
||||||
Ok(TokenInfo::new(token_str, expiration, issued_at))
|
let account = account.map(|a| a.to_string()).unwrap_or(String::new());
|
||||||
|
|
||||||
|
// Construct the claims for the token
|
||||||
|
// TODO: Verify the token!
|
||||||
|
let token = AuthToken::new(
|
||||||
|
String::from("orca-registry__DEV"),
|
||||||
|
account,
|
||||||
|
String::from("reg"),
|
||||||
|
expiration,
|
||||||
|
now.clone(),
|
||||||
|
now.clone(),
|
||||||
|
jwtid,
|
||||||
|
scopes,
|
||||||
|
);
|
||||||
|
|
||||||
|
let token_str = token.sign_with_key(&key)?;
|
||||||
|
Ok(TokenInfo::new(token_str, expiration, now))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn auth_basic_get(basic_auth: Option<AuthBasic>, state: State<Arc<AppState>>, Query(params): Query<HashMap<String, String>>, form: Option<Form<AuthForm>>) -> Result<Response, StatusCode> {
|
pub async fn auth_basic_get(
|
||||||
|
basic_auth: Option<AuthBasic>,
|
||||||
|
state: State<Arc<AppState>>,
|
||||||
|
Query(params): Query<HashMap<String, String>>,
|
||||||
|
form: Option<Form<AuthForm>>,
|
||||||
|
) -> Result<Response, StatusCode> {
|
||||||
let mut auth = TokenAuthRequest {
|
let mut auth = TokenAuthRequest {
|
||||||
user: None,
|
user: None,
|
||||||
password: None,
|
password: None,
|
||||||
|
@ -88,6 +109,19 @@ pub async fn auth_basic_get(basic_auth: Option<AuthBasic>, state: State<Arc<AppS
|
||||||
|
|
||||||
let auth_method;
|
let auth_method;
|
||||||
|
|
||||||
|
// Process all the scopes
|
||||||
|
if let Some(scope) = params.get("scope") {
|
||||||
|
// TODO: Handle multiple scopes
|
||||||
|
match Scope::try_from(&scope[..]) {
|
||||||
|
Ok(scope) => {
|
||||||
|
auth.scope.push(scope);
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
return Err(StatusCode::BAD_REQUEST);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// If BasicAuth is provided, set the fields to it
|
// If BasicAuth is provided, set the fields to it
|
||||||
if let Some(AuthBasic((username, pass))) = basic_auth {
|
if let Some(AuthBasic((username, pass))) = basic_auth {
|
||||||
auth.user = Some(username.clone());
|
auth.user = Some(username.clone());
|
||||||
|
@ -113,15 +147,89 @@ pub async fn auth_basic_get(basic_auth: Option<AuthBasic>, state: State<Arc<AppS
|
||||||
debug!("Read user authentication from a Form");
|
debug!("Read user authentication from a Form");
|
||||||
|
|
||||||
auth_method = "form";
|
auth_method = "form";
|
||||||
|
} else {
|
||||||
|
// If no auth parameters were specified, check if the repository is public. if it is, respond with a token.
|
||||||
|
|
||||||
|
let is_public_access = {
|
||||||
|
let mut res = vec![];
|
||||||
|
|
||||||
|
for scope in auth.scope.iter() {
|
||||||
|
match scope.scope_type {
|
||||||
|
ScopeType::Repository => {
|
||||||
|
// check repository visibility
|
||||||
|
let database = &state.database;
|
||||||
|
match database.get_repository_visibility(&scope.path).await {
|
||||||
|
Ok(Some(RepositoryVisibility::Public)) => res.push(Ok(true)),
|
||||||
|
Ok(_) => res.push(Ok(false)),
|
||||||
|
Err(e) => {
|
||||||
|
error!(
|
||||||
|
"Failure to check repository visibility for {}! Err: {}",
|
||||||
|
scope.path, e
|
||||||
|
);
|
||||||
|
|
||||||
|
res.push(Err(StatusCode::INTERNAL_SERVER_ERROR));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => res.push(Ok(false)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// merge the booleans into a single bool, respond with errors if there are any.
|
||||||
|
let res: Result<Vec<bool>, StatusCode> = res.into_iter().collect();
|
||||||
|
res?.iter().all(|b| *b)
|
||||||
|
};
|
||||||
|
|
||||||
|
if is_public_access {
|
||||||
|
for scope in auth.scope.iter_mut() {
|
||||||
|
// only retain Action::Pull
|
||||||
|
scope.actions.retain(|a| *a == Action::Pull);
|
||||||
|
}
|
||||||
|
|
||||||
|
let token = create_jwt_token(None, auth.scope).map_err(|_| {
|
||||||
|
error!("Failed to create jwt token!");
|
||||||
|
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let token_str = token.token;
|
||||||
|
let now_format = format!("{}", token.created_at.format("%+"));
|
||||||
|
|
||||||
|
let auth_response = AuthResponse {
|
||||||
|
token: token_str.clone(),
|
||||||
|
expires_in: 86400, // 1 day
|
||||||
|
issued_at: now_format,
|
||||||
|
};
|
||||||
|
|
||||||
|
let json_str =
|
||||||
|
serde_json::to_string(&auth_response).map_err(|_| StatusCode::BAD_REQUEST)?;
|
||||||
|
|
||||||
|
debug!("Created anonymous token for public scopes!");
|
||||||
|
|
||||||
|
return Ok((
|
||||||
|
StatusCode::OK,
|
||||||
|
[
|
||||||
|
(header::CONTENT_TYPE, "application/json"),
|
||||||
|
(header::AUTHORIZATION, &format!("Bearer {}", token_str)),
|
||||||
|
],
|
||||||
|
json_str,
|
||||||
|
)
|
||||||
|
.into_response());
|
||||||
} else {
|
} else {
|
||||||
info!("Auth failure! Auth was not provided in either AuthBasic or Form!");
|
info!("Auth failure! Auth was not provided in either AuthBasic or Form!");
|
||||||
|
|
||||||
// Maybe BAD_REQUEST should be returned?
|
// Maybe BAD_REQUEST should be returned?
|
||||||
return Err(StatusCode::UNAUTHORIZED);
|
return Err(StatusCode::UNAUTHORIZED);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Create logging span for the rest of this request
|
// Create logging span for the rest of this request
|
||||||
let span = span!(Level::DEBUG, "auth", username = auth.user.clone(), auth_method);
|
let span = span!(
|
||||||
|
Level::DEBUG,
|
||||||
|
"auth",
|
||||||
|
username = auth.user.clone(),
|
||||||
|
auth_method
|
||||||
|
);
|
||||||
let _enter = span.enter();
|
let _enter = span.enter();
|
||||||
|
|
||||||
debug!("Parsed user auth request");
|
debug!("Parsed user auth request");
|
||||||
|
@ -131,7 +239,10 @@ pub async fn auth_basic_get(basic_auth: Option<AuthBasic>, state: State<Arc<AppS
|
||||||
if let Some(account) = params.get("account") {
|
if let Some(account) = params.get("account") {
|
||||||
if let Some(user) = &auth.user {
|
if let Some(user) = &auth.user {
|
||||||
if account != user {
|
if account != user {
|
||||||
error!("`user` and `account` are not the same!!! (user: {}, account: {})", user, account);
|
error!(
|
||||||
|
"`user` and `account` are not the same!!! (user: {}, account: {})",
|
||||||
|
user, account
|
||||||
|
);
|
||||||
|
|
||||||
return Err(StatusCode::BAD_REQUEST);
|
return Err(StatusCode::BAD_REQUEST);
|
||||||
}
|
}
|
||||||
|
@ -145,20 +256,6 @@ pub async fn auth_basic_get(basic_auth: Option<AuthBasic>, state: State<Arc<AppS
|
||||||
auth.service = Some(service.clone());
|
auth.service = Some(service.clone());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process all the scopes
|
|
||||||
if let Some(scope) = params.get("scope") {
|
|
||||||
|
|
||||||
// TODO: Handle multiple scopes
|
|
||||||
match Scope::try_from(&scope[..]) {
|
|
||||||
Ok(scope) => {
|
|
||||||
auth.scope.push(scope);
|
|
||||||
},
|
|
||||||
Err(_) => {
|
|
||||||
return Err(StatusCode::BAD_REQUEST);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get offline token and attempt to convert it to a boolean
|
// Get offline token and attempt to convert it to a boolean
|
||||||
if let Some(offline_token) = params.get("offline_token") {
|
if let Some(offline_token) = params.get("offline_token") {
|
||||||
if let Ok(b) = offline_token.parse::<bool>() {
|
if let Ok(b) = offline_token.parse::<bool>() {
|
||||||
|
@ -175,20 +272,25 @@ pub async fn auth_basic_get(basic_auth: Option<AuthBasic>, state: State<Arc<AppS
|
||||||
if let (Some(account), Some(password)) = (&auth.account, auth.password) {
|
if let (Some(account), Some(password)) = (&auth.account, auth.password) {
|
||||||
// Ensure that the password is correct
|
// Ensure that the password is correct
|
||||||
let mut auth_driver = state.auth_checker.lock().await;
|
let mut auth_driver = state.auth_checker.lock().await;
|
||||||
if !auth_driver.verify_user_login(account.clone(), password).await
|
if !auth_driver
|
||||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? {
|
.verify_user_login(account.clone(), password)
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
|
||||||
|
{
|
||||||
debug!("Authentication failed, incorrect password!");
|
debug!("Authentication failed, incorrect password!");
|
||||||
|
|
||||||
// TODO: Dont unwrap, find a way to return multiple scopes
|
// TODO: Dont unwrap, find a way to return multiple scopes
|
||||||
return Ok(auth_challenge_response(&state.config, Some(auth.scope.first().unwrap().clone())));
|
return Ok(auth_challenge_response(
|
||||||
|
&state.config,
|
||||||
|
Some(auth.scope.first().unwrap().clone()),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
drop(auth_driver);
|
drop(auth_driver);
|
||||||
|
|
||||||
debug!("User password is correct");
|
debug!("User password is correct");
|
||||||
|
|
||||||
let now = SystemTime::now();
|
let now = SystemTime::now();
|
||||||
let token = create_jwt_token(account)
|
let token = create_jwt_token(Some(account), vec![]).map_err(|_| {
|
||||||
.map_err(|_| {
|
|
||||||
error!("Failed to create jwt token!");
|
error!("Failed to create jwt token!");
|
||||||
|
|
||||||
StatusCode::INTERNAL_SERVER_ERROR
|
StatusCode::INTERNAL_SERVER_ERROR
|
||||||
|
@ -208,11 +310,18 @@ pub async fn auth_basic_get(basic_auth: Option<AuthBasic>, state: State<Arc<AppS
|
||||||
issued_at: now_format,
|
issued_at: now_format,
|
||||||
};
|
};
|
||||||
|
|
||||||
let json_str = serde_json::to_string(&auth_response)
|
let json_str =
|
||||||
.map_err(|_| StatusCode::BAD_REQUEST)?;
|
serde_json::to_string(&auth_response).map_err(|_| StatusCode::BAD_REQUEST)?;
|
||||||
|
|
||||||
let database = &state.database;
|
let database = &state.database;
|
||||||
database.store_user_token(token_str.clone(), account.clone(), token.expiry, token.created_at).await
|
database
|
||||||
|
.store_user_token(
|
||||||
|
token_str.clone(),
|
||||||
|
account.clone(),
|
||||||
|
token.expiry,
|
||||||
|
token.created_at,
|
||||||
|
)
|
||||||
|
.await
|
||||||
.map_err(|_| {
|
.map_err(|_| {
|
||||||
error!("Failed to store user token in database!");
|
error!("Failed to store user token in database!");
|
||||||
|
|
||||||
|
@ -224,10 +333,11 @@ pub async fn auth_basic_get(basic_auth: Option<AuthBasic>, state: State<Arc<AppS
|
||||||
StatusCode::OK,
|
StatusCode::OK,
|
||||||
[
|
[
|
||||||
(header::CONTENT_TYPE, "application/json"),
|
(header::CONTENT_TYPE, "application/json"),
|
||||||
( header::AUTHORIZATION, &format!("Bearer {}", token_str) )
|
(header::AUTHORIZATION, &format!("Bearer {}", token_str)),
|
||||||
],
|
],
|
||||||
json_str
|
json_str,
|
||||||
).into_response());
|
)
|
||||||
|
.into_response());
|
||||||
}
|
}
|
||||||
|
|
||||||
info!("Auth failure! Not enough information given to create auth token!");
|
info!("Auth failure! Not enough information given to create auth token!");
|
||||||
|
|
|
@ -1,12 +1,13 @@
|
||||||
use anyhow::anyhow;
|
use anyhow::anyhow;
|
||||||
use serde::{Deserialize, de::Visitor};
|
use serde::{Deserialize, de::Visitor, Serialize};
|
||||||
|
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
|
|
||||||
#[derive(Default, Debug, Clone)]
|
#[derive(Default, Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
pub enum ScopeType {
|
pub enum ScopeType {
|
||||||
#[default]
|
#[default]
|
||||||
Unknown,
|
Unknown,
|
||||||
|
#[serde(rename = "repository")]
|
||||||
Repository,
|
Repository,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -19,11 +20,13 @@ impl fmt::Display for ScopeType {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Default, Debug, Clone)]
|
#[derive(Default, Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
pub enum Action {
|
pub enum Action {
|
||||||
#[default]
|
#[default]
|
||||||
None,
|
None,
|
||||||
|
#[serde(rename = "push")]
|
||||||
Push,
|
Push,
|
||||||
|
#[serde(rename = "pull")]
|
||||||
Pull,
|
Pull,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -37,11 +40,11 @@ impl fmt::Display for Action {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Default, Debug, Clone)]
|
#[derive(Default, Debug, Clone, Serialize, PartialEq, Eq)]
|
||||||
pub struct Scope {
|
pub struct Scope {
|
||||||
scope_type: ScopeType,
|
pub scope_type: ScopeType,
|
||||||
path: String,
|
pub path: String,
|
||||||
actions: Vec<Action>,
|
pub actions: Vec<Action>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Scope {
|
impl Scope {
|
||||||
|
|
|
@ -4,11 +4,12 @@ use async_trait::async_trait;
|
||||||
use axum::{http::{StatusCode, header, HeaderName, HeaderMap, request::Parts}, extract::FromRequestParts};
|
use axum::{http::{StatusCode, header, HeaderName, HeaderMap, request::Parts}, extract::FromRequestParts};
|
||||||
use bitflags::bitflags;
|
use bitflags::bitflags;
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
use tracing::debug;
|
use tracing::debug;
|
||||||
|
|
||||||
use crate::{app_state::AppState, database::Database};
|
use crate::{app_state::AppState, database::Database};
|
||||||
|
|
||||||
use super::RepositoryVisibility;
|
use super::{RepositoryVisibility, scope::Scope};
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||||
pub enum LoginSource {
|
pub enum LoginSource {
|
||||||
|
@ -45,6 +46,50 @@ impl User {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct AuthToken {
|
||||||
|
#[serde(rename = "iss")]
|
||||||
|
pub issuer: String,
|
||||||
|
|
||||||
|
#[serde(rename = "sub")]
|
||||||
|
pub subject: String,
|
||||||
|
|
||||||
|
#[serde(rename = "aud")]
|
||||||
|
pub audience: String,
|
||||||
|
|
||||||
|
#[serde(rename = "exp")]
|
||||||
|
#[serde(with = "chrono::serde::ts_seconds")]
|
||||||
|
pub expiration: DateTime<Utc>,
|
||||||
|
|
||||||
|
#[serde(rename = "nbf")]
|
||||||
|
#[serde(with = "chrono::serde::ts_seconds")]
|
||||||
|
pub not_before: DateTime<Utc>,
|
||||||
|
|
||||||
|
#[serde(rename = "iat")]
|
||||||
|
#[serde(with = "chrono::serde::ts_seconds")]
|
||||||
|
pub issued_at: DateTime<Utc>,
|
||||||
|
|
||||||
|
#[serde(rename = "jti")]
|
||||||
|
pub jwt_id: String,
|
||||||
|
|
||||||
|
pub access: Vec<Scope>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AuthToken {
|
||||||
|
pub fn new(issuer: String, subject: String, audience: String, expiration: DateTime<Utc>, not_before: DateTime<Utc>, issued_at: DateTime<Utc>, jwt_id: String, access: Vec<Scope>) -> Self {
|
||||||
|
Self {
|
||||||
|
issuer,
|
||||||
|
subject,
|
||||||
|
audience,
|
||||||
|
expiration,
|
||||||
|
not_before,
|
||||||
|
issued_at,
|
||||||
|
jwt_id,
|
||||||
|
access
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq)]
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
pub struct TokenInfo {
|
pub struct TokenInfo {
|
||||||
pub token: String,
|
pub token: String,
|
||||||
|
|
Loading…
Reference in New Issue