use serde::{Deserialize, Serialize};
use crate::auth::{AuthSession, Secret};
use crate::error::{ApiError, ValidationDetails};
pub const ID_TOKEN_MAX_BYTES: usize = 16 * 1024;
pub const NONCE_MAX_BYTES: usize = 256;
#[derive(Clone, Debug, Serialize, Deserialize)]
#[non_exhaustive]
pub struct OAuthToken {
pub id_token: Secret,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub nonce: Option<Secret>,
}
impl OAuthToken {
pub fn new(id_token: impl Into<Secret>) -> Self {
Self { id_token: id_token.into(), nonce: None }
}
pub fn with_nonce(mut self, nonce: impl Into<Secret>) -> Self {
self.nonce = Some(nonce.into());
self
}
pub fn validate(&self) -> Result<(), ApiError> {
let mut details = ValidationDetails::new();
let token = self.id_token.expose();
let parts = token.split('.').count();
let alphabet = token.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.'));
if token.is_empty() || parts != 3 || !alphabet {
details.add("id_token", "is not a compact JWT (three base64url parts)");
}
if token.len() > ID_TOKEN_MAX_BYTES {
details.add("id_token", format!("is longer than {ID_TOKEN_MAX_BYTES} bytes"));
}
if let Some(nonce) = &self.nonce {
let text = nonce.expose();
if text.is_empty() || text.len() > NONCE_MAX_BYTES || !text.bytes().all(|b| b.is_ascii_graphic()) {
details.add("nonce", format!("must be 1 to {NONCE_MAX_BYTES} printable ASCII characters"));
}
}
details.into_result()
}
}
mod calls {
use super::*;
use crate::auth::is_valid_provider;
use crate::http_call::{HttpCall, PathParams, PayloadKind};
use crate::routes::{self, HttpMethod, Route};
#[derive(Clone, Debug)]
#[non_exhaustive]
pub struct OAuthLogin {
pub provider: String,
pub token: OAuthToken,
}
impl OAuthLogin {
pub fn new(provider: impl Into<String>, token: OAuthToken) -> Self {
Self { provider: provider.into(), token }
}
}
impl HttpCall for OAuthLogin {
type Payload = OAuthToken;
type Response = AuthSession;
const ROUTE: Route = Route::new(HttpMethod::Post, routes::auth::OAUTH, false);
const PAYLOAD: PayloadKind = PayloadKind::Json;
fn payload(&self) -> &OAuthToken {
&self.token
}
fn path_params(&self) -> PathParams {
PathParams::new().with("provider", &self.provider)
}
fn from_parts(params: &PathParams, token: OAuthToken) -> Result<Self, ApiError> {
Ok(Self::new(params.checked("provider", is_valid_provider, "is not a provider name")?, token))
}
}
}
pub use calls::OAuthLogin;
#[cfg(test)]
mod tests {
use super::*;
use crate::http_call::HttpCall;
#[test]
fn shapes() {
assert!(OAuthToken::new("eyJh.eyJz.c2ln").validate().is_ok());
assert!(OAuthToken::new("eyJh.eyJz.c2ln").with_nonce("n-0123456789").validate().is_ok());
assert!(OAuthToken::new("eyJh.eyJz").validate().is_err());
assert!(OAuthToken::new("").validate().is_err());
assert!(OAuthToken::new("a.b.c+").validate().is_err());
assert!(OAuthToken::new("a.b.c").with_nonce("").validate().is_err());
assert!(OAuthToken::new("a.b.c").with_nonce("with space").validate().is_err());
assert!(OAuthToken::new("a.b.c").with_nonce("n".repeat(NONCE_MAX_BYTES + 1)).validate().is_err());
assert!(OAuthToken::new(format!("a.b.{}", "c".repeat(ID_TOKEN_MAX_BYTES))).validate().is_err());
}
#[test]
fn json_and_path() {
let token = OAuthToken::new("a.b.c").with_nonce("n1");
let json = serde_json::to_value(&token).unwrap_or_default();
assert_eq!(json, serde_json::json!({"id_token": "a.b.c", "nonce": "n1"}));
let bare = serde_json::to_value(OAuthToken::new("a.b.c")).unwrap_or_default();
assert_eq!(bare, serde_json::json!({"id_token": "a.b.c"}));
assert!(!format!("{token:?}").contains("a.b.c"), "the token never shows in Debug");
let call = OAuthLogin::new("google", token);
assert_eq!(call.path().as_deref(), Some("/v1/auth/oauth/google"));
assert!(OAuthLogin::new("Bad Name", OAuthToken::new("a.b.c")).path().is_none());
}
}