use std::sync::Arc;
use async_trait::async_trait;
use axum::{
Json, Router,
extract::{Form, Query, State},
http::{HeaderMap, StatusCode, header},
response::{IntoResponse, Redirect, Response},
routing::post,
};
use surrealdb::{Connection, Surreal};
use super::{GOOGLE_CALLBACK_PATH, GoogleCallback, GoogleSignedIn};
use crate::{AuthUser, PublicEndpoint, SessionContext};
inventory::submit!(PublicEndpoint::new(GOOGLE_CALLBACK_PATH));
#[derive(Clone, Debug)]
pub struct GoogleIdentity {
pub subject: String,
pub email: Option<String>,
pub email_verified: bool,
pub name: Option<String>,
pub picture: Option<String>,
}
#[derive(Clone, Debug)]
pub struct GoogleAccount {
pub user_id: String,
pub is_new: bool,
}
#[async_trait]
pub trait GoogleAccounts<C: Connection>: Send + Sync + 'static {
async fn find_or_create(
&self,
db: &Surreal<C>,
identity: GoogleIdentity,
) -> anyhow::Result<GoogleAccount>;
fn destination(&self, _is_new: bool, _return_to: Option<&str>) -> Option<String> {
None
}
}
const MAX_RETURN_TO: usize = 512;
#[derive(serde::Deserialize)]
struct ReturnTo {
return_to: Option<String>,
}
#[derive(Clone, Debug)]
pub struct GoogleConfig {
client_id: Option<String>,
signed_in_path: String,
new_account_path: String,
}
impl GoogleConfig {
pub fn new(signed_in_path: impl Into<String>, new_account_path: impl Into<String>) -> Self {
Self {
client_id: None,
signed_in_path: signed_in_path.into(),
new_account_path: new_account_path.into(),
}
}
#[must_use]
pub fn client_id(mut self, client_id: impl Into<String>) -> Self {
self.client_id = Some(client_id.into());
self
}
fn resolved_client_id(&self) -> Option<String> {
self.client_id
.clone()
.or_else(|| std::env::var("GOOGLE_OAUTH_CLIENT_ID").ok())
.or_else(|| super::client_id().map(str::to_owned))
.filter(|id| !id.trim().is_empty())
}
}
struct Inner<C: Connection> {
config: GoogleConfig,
accounts: Box<dyn GoogleAccounts<C>>,
}
pub fn google_router<U, C, A>(config: GoogleConfig, accounts: A) -> Router
where
U: AuthUser,
C: Connection,
A: GoogleAccounts<C>,
{
let inner = Arc::new(Inner::<C> {
config,
accounts: Box::new(accounts),
});
crate::csrf::protect(
Router::new()
.route(GOOGLE_CALLBACK_PATH, post(callback::<U, C>))
.with_state(inner),
crate::csrf::DoubleSubmit::GOOGLE,
)
}
async fn callback<U, C>(
State(inner): State<Arc<Inner<C>>>,
context: SessionContext<U, C>,
headers: HeaderMap,
Query(query): Query<ReturnTo>,
Form(params): Form<GoogleCallback>,
) -> Response
where
U: AuthUser,
C: Connection,
{
let return_to = query.return_to.filter(|value| value.len() <= MAX_RETURN_TO);
let signed_in = match sign_in(&inner, &context, params.credential, return_to.as_deref()).await {
Ok(signed_in) => signed_in,
Err(message) => return (StatusCode::INTERNAL_SERVER_ERROR, message).into_response(),
};
let wants_json = headers
.get(header::ACCEPT)
.and_then(|value| value.to_str().ok())
.is_some_and(|accept| accept.contains("application/json"));
if wants_json {
Json(signed_in).into_response()
} else {
Redirect::to(&signed_in.destination).into_response()
}
}
async fn sign_in<U, C>(
inner: &Inner<C>,
context: &SessionContext<U, C>,
credential: String,
return_to: Option<&str>,
) -> Result<GoogleSignedIn, String>
where
U: AuthUser,
C: Connection,
{
let client_id = inner
.config
.resolved_client_id()
.ok_or("GOOGLE_OAUTH_CLIENT_ID is not configured.")?;
let payload = google_oauth::AsyncClient::new(client_id)
.validate_id_token(credential)
.await
.map_err(|error| error.to_string())?;
let account = inner
.accounts
.find_or_create(
&context.db,
GoogleIdentity {
subject: payload.sub,
email: payload.email,
email_verified: payload.email_verified.unwrap_or(false),
name: payload.name,
picture: payload.picture,
},
)
.await
.map_err(|error| error.to_string())?;
context.auth_session.login_user(account.user_id);
context.auth_session.remember_user(true);
let destination = inner
.accounts
.destination(account.is_new, return_to)
.unwrap_or_else(|| {
if account.is_new {
inner.config.new_account_path.clone()
} else {
inner.config.signed_in_path.clone()
}
});
Ok(GoogleSignedIn {
destination,
is_new: account.is_new,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_callback_is_public_because_it_is_how_a_visitor_signs_in() {
assert!(crate::is_public_endpoint(GOOGLE_CALLBACK_PATH));
assert!(!crate::is_public_endpoint(
"/api/v1/google_signin_callback/extra"
));
}
#[test]
fn an_explicit_client_id_wins_and_a_blank_one_is_unset() {
let config = GoogleConfig::new("/home", "/welcome").client_id("web-client");
assert_eq!(config.resolved_client_id().as_deref(), Some("web-client"));
let blank = GoogleConfig::new("/home", "/welcome").client_id(" ");
if std::env::var("GOOGLE_OAUTH_CLIENT_ID").is_err() && super::super::client_id().is_none() {
assert_eq!(blank.resolved_client_id(), None);
}
}
}