SSO auth pt 2
This commit is contained in:
51
src-tauri/src/assets/auth-cancelled.html
Normal file
51
src-tauri/src/assets/auth-cancelled.html
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>Friendolls Sign-in Cancelled</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
color-scheme: light;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top, #fff0cf, transparent 45%),
|
||||||
|
linear-gradient(180deg, #fffdf7 0%, #f8f2e6 100%);
|
||||||
|
font-family: "Avenir Next", "Segoe UI", sans-serif;
|
||||||
|
color: #55411d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
width: min(420px, calc(100vw - 32px));
|
||||||
|
padding: 32px 28px;
|
||||||
|
border-radius: 24px;
|
||||||
|
background: rgba(255, 255, 255, 0.92);
|
||||||
|
box-shadow: 0 24px 80px rgba(120, 95, 35, 0.14);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
font-size: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: #7a6237;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="card">
|
||||||
|
<h1>Sign-in cancelled</h1>
|
||||||
|
<p>You can close this tab and return to Friendolls to try again.</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
51
src-tauri/src/assets/auth-failed.html
Normal file
51
src-tauri/src/assets/auth-failed.html
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>Friendolls Sign-in Failed</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
color-scheme: light;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top, #ffd8d8, transparent 45%),
|
||||||
|
linear-gradient(180deg, #fff8f8 0%, #fceeee 100%);
|
||||||
|
font-family: "Avenir Next", "Segoe UI", sans-serif;
|
||||||
|
color: #4d2323;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
width: min(420px, calc(100vw - 32px));
|
||||||
|
padding: 32px 28px;
|
||||||
|
border-radius: 24px;
|
||||||
|
background: rgba(255, 255, 255, 0.92);
|
||||||
|
box-shadow: 0 24px 80px rgba(135, 57, 57, 0.14);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
font-size: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: #7c4a4a;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="card">
|
||||||
|
<h1>Sign-in failed</h1>
|
||||||
|
<p>Friendolls could not complete the browser handshake. Return to the app and try again.</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -27,9 +27,9 @@ use tauri::async_runtime;
|
|||||||
use tauri_specta::{collect_commands, collect_events, Builder as SpectaBuilder, ErrorHandlingMode};
|
use tauri_specta::{collect_commands, collect_events, Builder as SpectaBuilder, ErrorHandlingMode};
|
||||||
|
|
||||||
use crate::services::app_events::{
|
use crate::services::app_events::{
|
||||||
ActiveDollSpriteChanged, AppDataRefreshed, CreateDoll, CursorMoved, EditDoll,
|
ActiveDollSpriteChanged, AppDataRefreshed, AuthFlowUpdated, CreateDoll, CursorMoved,
|
||||||
FriendActiveDollChanged, FriendActiveDollSpritesUpdated, FriendCursorPositionsUpdated,
|
EditDoll, FriendActiveDollChanged, FriendActiveDollSpritesUpdated,
|
||||||
FriendDisconnected,
|
FriendCursorPositionsUpdated, FriendDisconnected,
|
||||||
FriendRequestAccepted, FriendRequestDenied, FriendRequestReceived, FriendUserStatusChanged,
|
FriendRequestAccepted, FriendRequestDenied, FriendRequestReceived, FriendUserStatusChanged,
|
||||||
InteractionDeliveryFailed, InteractionReceived, SceneInteractiveChanged, SetInteractionOverlay,
|
InteractionDeliveryFailed, InteractionReceived, SceneInteractiveChanged, SetInteractionOverlay,
|
||||||
Unfriended, UserStatusChanged,
|
Unfriended, UserStatusChanged,
|
||||||
@@ -124,7 +124,8 @@ pub fn run() {
|
|||||||
FriendRequestReceived,
|
FriendRequestReceived,
|
||||||
FriendRequestAccepted,
|
FriendRequestAccepted,
|
||||||
FriendRequestDenied,
|
FriendRequestDenied,
|
||||||
Unfriended
|
Unfriended,
|
||||||
|
AuthFlowUpdated
|
||||||
]);
|
]);
|
||||||
|
|
||||||
#[cfg(debug_assertions)]
|
#[cfg(debug_assertions)]
|
||||||
|
|||||||
@@ -18,6 +18,23 @@ use crate::{
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum AuthFlowStatus {
|
||||||
|
Started,
|
||||||
|
Succeeded,
|
||||||
|
Failed,
|
||||||
|
Cancelled,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct AuthFlowUpdatedPayload {
|
||||||
|
pub provider: String,
|
||||||
|
pub status: AuthFlowStatus,
|
||||||
|
pub message: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, Type, Event)]
|
#[derive(Debug, Clone, Serialize, Deserialize, Type, Event)]
|
||||||
#[tauri_specta(event_name = "cursor-position")]
|
#[tauri_specta(event_name = "cursor-position")]
|
||||||
pub struct CursorMoved(pub CursorPositions);
|
pub struct CursorMoved(pub CursorPositions);
|
||||||
@@ -93,3 +110,7 @@ pub struct FriendRequestDenied(pub FriendRequestDeniedPayload);
|
|||||||
#[derive(Debug, Clone, Serialize, Deserialize, Type, Event)]
|
#[derive(Debug, Clone, Serialize, Deserialize, Type, Event)]
|
||||||
#[tauri_specta(event_name = "unfriended")]
|
#[tauri_specta(event_name = "unfriended")]
|
||||||
pub struct Unfriended(pub UnfriendedPayload);
|
pub struct Unfriended(pub UnfriendedPayload);
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Type, Event)]
|
||||||
|
#[tauri_specta(event_name = "auth-flow-updated")]
|
||||||
|
pub struct AuthFlowUpdated(pub AuthFlowUpdatedPayload);
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ use super::storage::{build_auth_pass, save_auth_pass, AuthError, AuthPass};
|
|||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct StartSsoResponse {
|
pub struct StartSsoResponse {
|
||||||
pub state: String,
|
pub state: String,
|
||||||
|
#[serde(rename = "authorizeUrl")]
|
||||||
|
pub authorize_url: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
use tauri_specta::Event as _;
|
||||||
use tauri_plugin_opener::OpenerExt;
|
use tauri_plugin_opener::OpenerExt;
|
||||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
use tokio::net::{TcpListener, TcpStream};
|
use tokio::net::{TcpListener, TcpStream};
|
||||||
@@ -5,6 +6,7 @@ use tokio_util::sync::CancellationToken;
|
|||||||
use tracing::{error, info, warn};
|
use tracing::{error, info, warn};
|
||||||
|
|
||||||
use crate::get_app_handle;
|
use crate::get_app_handle;
|
||||||
|
use crate::services::app_events::{AuthFlowStatus, AuthFlowUpdated, AuthFlowUpdatedPayload};
|
||||||
use crate::state::clear_auth_flow_state;
|
use crate::state::clear_auth_flow_state;
|
||||||
use crate::{lock_r, state::FDOLL};
|
use crate::{lock_r, state::FDOLL};
|
||||||
|
|
||||||
@@ -12,10 +14,17 @@ use super::api::{exchange_sso_code, start_sso};
|
|||||||
use super::storage::AuthError;
|
use super::storage::AuthError;
|
||||||
|
|
||||||
static AUTH_SUCCESS_HTML: &str = include_str!("../../assets/auth-success.html");
|
static AUTH_SUCCESS_HTML: &str = include_str!("../../assets/auth-success.html");
|
||||||
|
static AUTH_CANCELLED_HTML: &str = include_str!("../../assets/auth-cancelled.html");
|
||||||
|
static AUTH_FAILED_HTML: &str = include_str!("../../assets/auth-failed.html");
|
||||||
|
|
||||||
pub struct OAuthCallbackParams {
|
pub struct OAuthCallbackParams {
|
||||||
pub state: String,
|
pub state: String,
|
||||||
pub code: String,
|
pub result: OAuthCallbackResult,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub enum OAuthCallbackResult {
|
||||||
|
Code(String),
|
||||||
|
Error { message: String, cancelled: bool },
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn start_browser_auth_flow(provider: &str) -> Result<(), AuthError> {
|
pub async fn start_browser_auth_flow(provider: &str) -> Result<(), AuthError> {
|
||||||
@@ -37,25 +46,52 @@ pub async fn start_browser_auth_flow(provider: &str) -> Result<(), AuthError> {
|
|||||||
let cancel_token = CancellationToken::new();
|
let cancel_token = CancellationToken::new();
|
||||||
{
|
{
|
||||||
let mut guard = crate::lock_w!(crate::state::FDOLL);
|
let mut guard = crate::lock_w!(crate::state::FDOLL);
|
||||||
guard.auth.oauth_flow.state = Some(start_response.state.clone());
|
|
||||||
guard.auth.oauth_flow.background_auth_token = Some(cancel_token.clone());
|
guard.auth.oauth_flow.background_auth_token = Some(cancel_token.clone());
|
||||||
}
|
}
|
||||||
|
|
||||||
let listener = TcpListener::from_std(std_listener)
|
let listener = TcpListener::from_std(std_listener)
|
||||||
.map_err(|e| AuthError::ServerBindError(e.to_string()))?;
|
.map_err(|e| AuthError::ServerBindError(e.to_string()))?;
|
||||||
let expected_state = start_response.state.clone();
|
let expected_state = start_response.state.clone();
|
||||||
let auth_url = build_authorize_url(provider, &start_response.state)?;
|
let auth_url = match start_response.authorize_url.clone() {
|
||||||
|
Some(authorize_url) => authorize_url,
|
||||||
|
None => build_authorize_url(provider, &start_response.state)?,
|
||||||
|
};
|
||||||
|
let provider_name = provider.to_string();
|
||||||
|
|
||||||
|
if let Err(err) = get_app_handle().opener().open_url(auth_url, None::<&str>) {
|
||||||
|
clear_auth_flow_state();
|
||||||
|
emit_auth_flow_event(
|
||||||
|
provider,
|
||||||
|
AuthFlowStatus::Failed,
|
||||||
|
Some("Friendolls could not open your browser for sign-in.".to_string()),
|
||||||
|
);
|
||||||
|
return Err(err.into());
|
||||||
|
}
|
||||||
|
|
||||||
|
emit_auth_flow_event(provider, AuthFlowStatus::Started, None);
|
||||||
tauri::async_runtime::spawn(async move {
|
tauri::async_runtime::spawn(async move {
|
||||||
match listen_for_callback(listener, cancel_token.clone()).await {
|
match listen_for_callback(listener, cancel_token.clone()).await {
|
||||||
Ok(params) => {
|
Ok(params) => {
|
||||||
if params.state != expected_state {
|
if params.state != expected_state {
|
||||||
error!("SSO state mismatch");
|
error!("SSO state mismatch");
|
||||||
|
emit_auth_flow_event(
|
||||||
|
&provider_name,
|
||||||
|
AuthFlowStatus::Failed,
|
||||||
|
Some("Sign-in verification failed. Please try again.".to_string()),
|
||||||
|
);
|
||||||
clear_auth_flow_state();
|
clear_auth_flow_state();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Err(err) = exchange_sso_code(¶ms.code).await {
|
match params.result {
|
||||||
|
OAuthCallbackResult::Code(code) => {
|
||||||
|
if let Err(err) = exchange_sso_code(&code).await {
|
||||||
error!("Failed to exchange SSO code: {}", err);
|
error!("Failed to exchange SSO code: {}", err);
|
||||||
|
emit_auth_flow_event(
|
||||||
|
&provider_name,
|
||||||
|
AuthFlowStatus::Failed,
|
||||||
|
Some("Friendolls could not complete sign-in. Please try again.".to_string()),
|
||||||
|
);
|
||||||
clear_auth_flow_state();
|
clear_auth_flow_state();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -63,22 +99,50 @@ pub async fn start_browser_auth_flow(provider: &str) -> Result<(), AuthError> {
|
|||||||
clear_auth_flow_state();
|
clear_auth_flow_state();
|
||||||
if let Err(err) = super::session::finish_login_session().await {
|
if let Err(err) = super::session::finish_login_session().await {
|
||||||
error!("Failed to finalize desktop login session: {}", err);
|
error!("Failed to finalize desktop login session: {}", err);
|
||||||
|
emit_auth_flow_event(
|
||||||
|
&provider_name,
|
||||||
|
AuthFlowStatus::Failed,
|
||||||
|
Some("Signed in, but Friendolls could not open your session.".to_string()),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
emit_auth_flow_event(&provider_name, AuthFlowStatus::Succeeded, None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
OAuthCallbackResult::Error { message, cancelled } => {
|
||||||
|
clear_auth_flow_state();
|
||||||
|
emit_auth_flow_event(
|
||||||
|
&provider_name,
|
||||||
|
if cancelled {
|
||||||
|
AuthFlowStatus::Cancelled
|
||||||
|
} else {
|
||||||
|
AuthFlowStatus::Failed
|
||||||
|
},
|
||||||
|
Some(message),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(AuthError::Cancelled) => {
|
Err(AuthError::Cancelled) => {
|
||||||
info!("Auth flow cancelled");
|
info!("Auth flow cancelled");
|
||||||
|
emit_auth_flow_event(
|
||||||
|
&provider_name,
|
||||||
|
AuthFlowStatus::Cancelled,
|
||||||
|
Some("Sign-in was cancelled.".to_string()),
|
||||||
|
);
|
||||||
|
clear_auth_flow_state();
|
||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
error!("Auth callback listener failed: {}", err);
|
error!("Auth callback listener failed: {}", err);
|
||||||
|
emit_auth_flow_event(
|
||||||
|
&provider_name,
|
||||||
|
AuthFlowStatus::Failed,
|
||||||
|
Some(auth_flow_error_message(&err)),
|
||||||
|
);
|
||||||
clear_auth_flow_state();
|
clear_auth_flow_state();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
get_app_handle()
|
|
||||||
.opener()
|
|
||||||
.open_url(auth_url, None::<&str>)?;
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,20 +220,36 @@ async fn parse_callback(stream: &mut TcpStream) -> Result<Option<OAuthCallbackPa
|
|||||||
.get("state")
|
.get("state")
|
||||||
.cloned()
|
.cloned()
|
||||||
.ok_or_else(|| AuthError::MissingParameter("state".to_string()))?;
|
.ok_or_else(|| AuthError::MissingParameter("state".to_string()))?;
|
||||||
|
if let Some(error_code) = params.get("error") {
|
||||||
|
let message = oauth_error_message(error_code, params.get("error_description"));
|
||||||
|
let response_html = if is_oauth_cancellation(error_code) {
|
||||||
|
AUTH_CANCELLED_HTML
|
||||||
|
} else {
|
||||||
|
AUTH_FAILED_HTML
|
||||||
|
};
|
||||||
|
write_html_response(stream, response_html).await?;
|
||||||
|
|
||||||
|
return Ok(Some(OAuthCallbackParams {
|
||||||
|
state,
|
||||||
|
result: OAuthCallbackResult::Error {
|
||||||
|
message,
|
||||||
|
cancelled: is_oauth_cancellation(error_code),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
let code = params
|
let code = params
|
||||||
.get("code")
|
.get("code")
|
||||||
.cloned()
|
.cloned()
|
||||||
.ok_or_else(|| AuthError::MissingParameter("code".to_string()))?;
|
.ok_or_else(|| AuthError::MissingParameter("code".to_string()))?;
|
||||||
|
|
||||||
let response = format!(
|
write_html_response(stream, AUTH_SUCCESS_HTML).await?;
|
||||||
"HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\n\r\n{}",
|
|
||||||
AUTH_SUCCESS_HTML.len(),
|
|
||||||
AUTH_SUCCESS_HTML
|
|
||||||
);
|
|
||||||
stream.write_all(response.as_bytes()).await?;
|
|
||||||
stream.flush().await?;
|
stream.flush().await?;
|
||||||
|
|
||||||
Ok(Some(OAuthCallbackParams { state, code }))
|
Ok(Some(OAuthCallbackParams {
|
||||||
|
state,
|
||||||
|
result: OAuthCallbackResult::Code(code),
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
(Some("GET"), Some("/health")) => {
|
(Some("GET"), Some("/health")) => {
|
||||||
stream
|
stream
|
||||||
@@ -185,3 +265,53 @@ async fn parse_callback(stream: &mut TcpStream) -> Result<Option<OAuthCallbackPa
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn emit_auth_flow_event(provider: &str, status: AuthFlowStatus, message: Option<String>) {
|
||||||
|
if let Err(err) = AuthFlowUpdated(AuthFlowUpdatedPayload {
|
||||||
|
provider: provider.to_string(),
|
||||||
|
status,
|
||||||
|
message,
|
||||||
|
})
|
||||||
|
.emit(get_app_handle())
|
||||||
|
{
|
||||||
|
warn!("Failed to emit auth flow event: {}", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn auth_flow_error_message(err: &AuthError) -> String {
|
||||||
|
match err {
|
||||||
|
AuthError::Cancelled => "Sign-in was cancelled.".to_string(),
|
||||||
|
AuthError::CallbackTimeout => "Sign-in timed out. Please try again.".to_string(),
|
||||||
|
AuthError::MissingParameter(_) => {
|
||||||
|
"Friendolls did not receive a complete sign-in response. Please try again.".to_string()
|
||||||
|
}
|
||||||
|
_ => "Friendolls could not complete sign-in. Please try again.".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn oauth_error_message(error_code: &str, description: Option<&String>) -> String {
|
||||||
|
if let Some(description) = description.filter(|description| !description.is_empty()) {
|
||||||
|
return description.clone();
|
||||||
|
}
|
||||||
|
|
||||||
|
if is_oauth_cancellation(error_code) {
|
||||||
|
"Sign-in was cancelled.".to_string()
|
||||||
|
} else {
|
||||||
|
"The sign-in provider reported an error. Please try again.".to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_oauth_cancellation(error_code: &str) -> bool {
|
||||||
|
matches!(error_code, "access_denied" | "user_cancelled" | "authorization_cancelled")
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn write_html_response(stream: &mut TcpStream, html: &str) -> Result<(), AuthError> {
|
||||||
|
let response = format!(
|
||||||
|
"HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\n\r\n{}",
|
||||||
|
html.len(),
|
||||||
|
html
|
||||||
|
);
|
||||||
|
stream.write_all(response.as_bytes()).await?;
|
||||||
|
stream.flush().await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
use tracing::info;
|
use tracing::info;
|
||||||
|
use tokio::time::{timeout, Duration};
|
||||||
|
|
||||||
use crate::get_app_handle;
|
use crate::get_app_handle;
|
||||||
use crate::services::{scene::close_splash_window, session::construct_user_session, welcome::close_welcome_window};
|
use crate::services::{scene::close_splash_window, session::construct_user_session, welcome::close_welcome_window};
|
||||||
@@ -15,28 +16,28 @@ pub async fn get_access_token() -> Option<String> {
|
|||||||
get_session_token().await.map(|pass| pass.access_token)
|
get_session_token().await.map(|pass| pass.access_token)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn logout() -> Result<(), AuthError> {
|
pub async fn logout() -> Result<(), AuthError> {
|
||||||
info!("Logging out user");
|
info!("Logging out user");
|
||||||
let refresh_token = lock_w!(FDOLL)
|
let refresh_token = lock_w!(FDOLL)
|
||||||
.auth
|
.auth
|
||||||
.auth_pass
|
.auth_pass
|
||||||
.take()
|
.take()
|
||||||
.map(|pass| pass.refresh_token);
|
.and_then(|pass| pass.refresh_token);
|
||||||
clear_auth_pass()?;
|
clear_auth_pass()?;
|
||||||
|
|
||||||
if let Some(refresh_token) = refresh_token {
|
if let Some(refresh_token) = refresh_token {
|
||||||
tauri::async_runtime::spawn(async move {
|
match timeout(Duration::from_secs(5), super::api::logout_remote(&refresh_token)).await {
|
||||||
if let Err(err) = super::api::logout_remote(&refresh_token).await {
|
Ok(Ok(())) => {}
|
||||||
info!("Failed to revoke refresh token on server: {}", err);
|
Ok(Err(err)) => info!("Failed to revoke refresh token on server: {}", err),
|
||||||
|
Err(_) => info!("Timed out while revoking refresh token on server"),
|
||||||
}
|
}
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn logout_and_restart() -> Result<(), AuthError> {
|
pub async fn logout_and_restart() -> Result<(), AuthError> {
|
||||||
logout()?;
|
logout().await?;
|
||||||
let app_handle = get_app_handle();
|
let app_handle = get_app_handle();
|
||||||
app_handle.restart();
|
app_handle.restart();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,8 +52,10 @@ pub enum AuthError {
|
|||||||
pub struct AuthPass {
|
pub struct AuthPass {
|
||||||
pub access_token: String,
|
pub access_token: String,
|
||||||
pub expires_in: u64,
|
pub expires_in: u64,
|
||||||
pub refresh_token: String,
|
#[serde(default)]
|
||||||
pub refresh_expires_in: u64,
|
pub refresh_token: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub refresh_expires_in: Option<u64>,
|
||||||
pub issued_at: Option<u64>,
|
pub issued_at: Option<u64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,8 +72,8 @@ pub(crate) fn build_auth_pass(
|
|||||||
Ok(AuthPass {
|
Ok(AuthPass {
|
||||||
access_token,
|
access_token,
|
||||||
expires_in,
|
expires_in,
|
||||||
refresh_token,
|
refresh_token: Some(refresh_token),
|
||||||
refresh_expires_in,
|
refresh_expires_in: Some(refresh_expires_in),
|
||||||
issued_at: Some(issued_at),
|
issued_at: Some(issued_at),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -189,6 +191,10 @@ pub fn load_auth_pass() -> Result<Option<AuthPass>, AuthError> {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if auth_pass.refresh_token.is_none() || auth_pass.refresh_expires_in.is_none() {
|
||||||
|
info!("Loaded legacy auth pass without refresh token support");
|
||||||
|
}
|
||||||
|
|
||||||
Ok(Some(auth_pass))
|
Ok(Some(auth_pass))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ static REFRESH_LOCK: once_cell::sync::Lazy<Mutex<()>> =
|
|||||||
|
|
||||||
#[derive(Default, Clone)]
|
#[derive(Default, Clone)]
|
||||||
pub struct OAuthFlowTracker {
|
pub struct OAuthFlowTracker {
|
||||||
pub state: Option<String>,
|
|
||||||
pub background_auth_token: Option<CancellationToken>,
|
pub background_auth_token: Option<CancellationToken>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,7 +45,6 @@ pub fn clear_auth_flow_state() {
|
|||||||
if let Some(cancel_token) = guard.auth.oauth_flow.background_auth_token.take() {
|
if let Some(cancel_token) = guard.auth.oauth_flow.background_auth_token.take() {
|
||||||
cancel_token.cancel();
|
cancel_token.cancel();
|
||||||
}
|
}
|
||||||
guard.auth.oauth_flow.state = None;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the auth pass object, including access token and metadata.
|
/// Returns the auth pass object, including access token and metadata.
|
||||||
@@ -63,9 +61,11 @@ pub async fn get_auth_pass_with_refresh() -> Option<AuthPass> {
|
|||||||
|
|
||||||
let current_time = SystemTime::now().duration_since(UNIX_EPOCH).ok()?.as_secs();
|
let current_time = SystemTime::now().duration_since(UNIX_EPOCH).ok()?.as_secs();
|
||||||
let access_expires_at = issued_at.saturating_add(auth_pass.expires_in);
|
let access_expires_at = issued_at.saturating_add(auth_pass.expires_in);
|
||||||
let refresh_expires_at = issued_at.saturating_add(auth_pass.refresh_expires_in);
|
let refresh_expires_at = auth_pass
|
||||||
|
.refresh_expires_in
|
||||||
|
.map(|refresh_expires_in| issued_at.saturating_add(refresh_expires_in));
|
||||||
|
|
||||||
if current_time >= refresh_expires_at {
|
if refresh_expires_at.is_some_and(|refresh_expires_at| current_time >= refresh_expires_at) {
|
||||||
clear_expired_auth().await;
|
clear_expired_auth().await;
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
@@ -85,9 +85,11 @@ pub async fn get_auth_pass_with_refresh() -> Option<AuthPass> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let access_expires_at = issued_at.saturating_add(auth_pass.expires_in);
|
let access_expires_at = issued_at.saturating_add(auth_pass.expires_in);
|
||||||
let refresh_expires_at = issued_at.saturating_add(auth_pass.refresh_expires_in);
|
let refresh_expires_at = auth_pass
|
||||||
|
.refresh_expires_in
|
||||||
|
.map(|refresh_expires_in| issued_at.saturating_add(refresh_expires_in));
|
||||||
|
|
||||||
if current_time >= refresh_expires_at {
|
if refresh_expires_at.is_some_and(|refresh_expires_at| current_time >= refresh_expires_at) {
|
||||||
clear_expired_auth().await;
|
clear_expired_auth().await;
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
@@ -97,7 +99,13 @@ pub async fn get_auth_pass_with_refresh() -> Option<AuthPass> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
info!("Access token expired, attempting refresh");
|
info!("Access token expired, attempting refresh");
|
||||||
match refresh_token(&auth_pass.refresh_token).await {
|
let Some(refresh_token_value) = auth_pass.refresh_token.as_deref() else {
|
||||||
|
warn!("Legacy auth pass missing refresh token, clearing expired session");
|
||||||
|
clear_expired_auth().await;
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
|
||||||
|
match refresh_token(refresh_token_value).await {
|
||||||
Ok(new_pass) => Some(new_pass),
|
Ok(new_pass) => Some(new_pass),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
error!("Failed to refresh token: {}", e);
|
error!("Failed to refresh token: {}", e);
|
||||||
@@ -130,13 +138,20 @@ async fn refresh_if_expiring_soon() {
|
|||||||
Err(_) => return,
|
Err(_) => return,
|
||||||
};
|
};
|
||||||
|
|
||||||
let refresh_expires_at = issued_at.saturating_add(auth_pass.refresh_expires_in);
|
let access_expires_at = issued_at.saturating_add(auth_pass.expires_in);
|
||||||
if current_time >= refresh_expires_at {
|
if current_time >= access_expires_at && auth_pass.refresh_token.is_none() {
|
||||||
|
clear_expired_auth().await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let refresh_expires_at = auth_pass
|
||||||
|
.refresh_expires_in
|
||||||
|
.map(|refresh_expires_in| issued_at.saturating_add(refresh_expires_in));
|
||||||
|
if refresh_expires_at.is_some_and(|refresh_expires_at| current_time >= refresh_expires_at) {
|
||||||
clear_expired_auth().await;
|
clear_expired_auth().await;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let access_expires_at = issued_at.saturating_add(auth_pass.expires_in);
|
|
||||||
if access_expires_at.saturating_sub(current_time) >= 60 {
|
if access_expires_at.saturating_sub(current_time) >= 60 {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -156,18 +171,29 @@ async fn refresh_if_expiring_soon() {
|
|||||||
Err(_) => return,
|
Err(_) => return,
|
||||||
};
|
};
|
||||||
|
|
||||||
let refresh_expires_at = latest_issued_at.saturating_add(latest_pass.refresh_expires_in);
|
let access_expires_at = latest_issued_at.saturating_add(latest_pass.expires_in);
|
||||||
if current_time >= refresh_expires_at {
|
if current_time >= access_expires_at && latest_pass.refresh_token.is_none() {
|
||||||
|
clear_expired_auth().await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let refresh_expires_at = latest_pass
|
||||||
|
.refresh_expires_in
|
||||||
|
.map(|refresh_expires_in| latest_issued_at.saturating_add(refresh_expires_in));
|
||||||
|
if refresh_expires_at.is_some_and(|refresh_expires_at| current_time >= refresh_expires_at) {
|
||||||
clear_expired_auth().await;
|
clear_expired_auth().await;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let access_expires_at = latest_issued_at.saturating_add(latest_pass.expires_in);
|
|
||||||
if access_expires_at.saturating_sub(current_time) >= 60 {
|
if access_expires_at.saturating_sub(current_time) >= 60 {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Err(e) = refresh_token(&latest_pass.refresh_token).await {
|
let Some(refresh_token_value) = latest_pass.refresh_token.as_deref() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Err(e) = refresh_token(refresh_token_value).await {
|
||||||
warn!("Background refresh failed: {}", e);
|
warn!("Background refresh failed: {}", e);
|
||||||
clear_expired_auth().await;
|
clear_expired_auth().await;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -127,6 +127,7 @@ async getModules() : Promise<ModuleMetadata[]> {
|
|||||||
export const events = __makeEvents__<{
|
export const events = __makeEvents__<{
|
||||||
activeDollSpriteChanged: ActiveDollSpriteChanged,
|
activeDollSpriteChanged: ActiveDollSpriteChanged,
|
||||||
appDataRefreshed: AppDataRefreshed,
|
appDataRefreshed: AppDataRefreshed,
|
||||||
|
authFlowUpdated: AuthFlowUpdated,
|
||||||
createDoll: CreateDoll,
|
createDoll: CreateDoll,
|
||||||
cursorMoved: CursorMoved,
|
cursorMoved: CursorMoved,
|
||||||
editDoll: EditDoll,
|
editDoll: EditDoll,
|
||||||
@@ -147,6 +148,7 @@ userStatusChanged: UserStatusChanged
|
|||||||
}>({
|
}>({
|
||||||
activeDollSpriteChanged: "active-doll-sprite-changed",
|
activeDollSpriteChanged: "active-doll-sprite-changed",
|
||||||
appDataRefreshed: "app-data-refreshed",
|
appDataRefreshed: "app-data-refreshed",
|
||||||
|
authFlowUpdated: "auth-flow-updated",
|
||||||
createDoll: "create-doll",
|
createDoll: "create-doll",
|
||||||
cursorMoved: "cursor-moved",
|
cursorMoved: "cursor-moved",
|
||||||
editDoll: "edit-doll",
|
editDoll: "edit-doll",
|
||||||
@@ -175,6 +177,9 @@ userStatusChanged: "user-status-changed"
|
|||||||
export type ActiveDollSpriteChanged = string | null
|
export type ActiveDollSpriteChanged = string | null
|
||||||
export type AppConfig = { api_base_url: string | null }
|
export type AppConfig = { api_base_url: string | null }
|
||||||
export type AppDataRefreshed = UserData
|
export type AppDataRefreshed = UserData
|
||||||
|
export type AuthFlowStatus = "started" | "succeeded" | "failed" | "cancelled"
|
||||||
|
export type AuthFlowUpdated = AuthFlowUpdatedPayload
|
||||||
|
export type AuthFlowUpdatedPayload = { provider: string; status: AuthFlowStatus; message: string | null }
|
||||||
export type CreateDoll = null
|
export type CreateDoll = null
|
||||||
export type CreateDollDto = { name: string; configuration: DollConfigurationDto | null }
|
export type CreateDollDto = { name: string; configuration: DollConfigurationDto | null }
|
||||||
export type CursorMoved = CursorPositions
|
export type CursorMoved = CursorPositions
|
||||||
|
|||||||
@@ -1,10 +1,19 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { commands } from "$lib/bindings";
|
import { commands, events } from "$lib/bindings";
|
||||||
|
import { onDestroy, onMount } from "svelte";
|
||||||
import DollPreview from "../app-menu/components/doll-preview.svelte";
|
import DollPreview from "../app-menu/components/doll-preview.svelte";
|
||||||
import ExternalLink from "../../assets/icons/external-link.svelte";
|
import ExternalLink from "../../assets/icons/external-link.svelte";
|
||||||
|
import type { UnlistenFn } from "@tauri-apps/api/event";
|
||||||
|
|
||||||
let loadingProvider: "google" | "discord" | null = null;
|
let loadingProvider: "google" | "discord" | null = null;
|
||||||
let errorMessage = "";
|
let errorMessage = "";
|
||||||
|
let unlistenAuthFlow: UnlistenFn | null = null;
|
||||||
|
|
||||||
|
type AuthFlowUpdatedPayload = {
|
||||||
|
provider: string;
|
||||||
|
status: "started" | "succeeded" | "failed" | "cancelled";
|
||||||
|
message: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
const normalizeError = (value: unknown) => {
|
const normalizeError = (value: unknown) => {
|
||||||
if (value instanceof Error) {
|
if (value instanceof Error) {
|
||||||
@@ -33,6 +42,37 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const providerLabel = (provider: "google" | "discord") =>
|
||||||
|
provider === "google" ? "Google" : "Discord";
|
||||||
|
|
||||||
|
const handleAuthFlowUpdated = ({ payload }: { payload: AuthFlowUpdatedPayload }) => {
|
||||||
|
const provider = payload.provider as "google" | "discord";
|
||||||
|
if (loadingProvider !== provider) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload.status === "started") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
loadingProvider = null;
|
||||||
|
|
||||||
|
if (payload.status === "succeeded") {
|
||||||
|
errorMessage = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
errorMessage = payload.message ?? `Unable to sign in with ${providerLabel(provider)}.`;
|
||||||
|
};
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
unlistenAuthFlow = await events.authFlowUpdated.listen(handleAuthFlowUpdated);
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
unlistenAuthFlow?.();
|
||||||
|
});
|
||||||
|
|
||||||
const openClientConfig = async () => {
|
const openClientConfig = async () => {
|
||||||
try {
|
try {
|
||||||
await commands.openClientConfig();
|
await commands.openClientConfig();
|
||||||
@@ -90,7 +130,7 @@
|
|||||||
<p class="text-xs text-error max-w-72">{errorMessage}</p>
|
<p class="text-xs text-error max-w-72">{errorMessage}</p>
|
||||||
{:else}
|
{:else}
|
||||||
<p class="text-xs opacity-50 max-w-72">
|
<p class="text-xs opacity-50 max-w-72">
|
||||||
Sign in in your browser, then return here once Friendolls finishes the handshake.
|
Sign in through your browser, then return here once Friendolls finishes the handshake.
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user