use std::marker::PhantomData;
use std::sync::Mutex;
use ppoppo_sdk_core::discovery::{Discovery, DiscoveryError, fetch_discovery};
use ppoppo_sdk_core::scopes::ConsentScopes;
use url::Url;
use crate::oauth::{AuthClient, OAuthConfig};
use crate::pkce;
use crate::refresh_source::{RefreshTokenSource, TokenStore};
use crate::scope_grant::{ScopeNotCovered, ensure_covers};
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct NativeConfig {
pub issuer: Url,
pub client_id: String,
pub resource: Option<String>,
}
impl NativeConfig {
#[must_use]
pub fn new(issuer: Url, client_id: impl Into<String>) -> Self {
Self { issuer, client_id: client_id.into(), resource: None }
}
#[must_use]
pub fn with_resource(mut self, resource: impl Into<String>) -> Self {
self.resource = Some(resource.into());
self
}
}
#[derive(Debug, thiserror::Error)]
pub enum NativeAuthInitError {
#[error("discovery fetch failed: {0}")]
Discovery(#[from] DiscoveryError),
#[error("OAuth client construction failed: {0}")]
OAuthClient(String),
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum CallbackError {
#[error("no authorization is pending (never started, or already consumed)")]
NoPendingAuthorization,
#[error("state mismatch (CSRF defense triggered)")]
StateMismatch,
#[error("authorization denied by the server: {error}{}", .description.as_deref().map(|d| format!(" — {d}")).unwrap_or_default())]
AuthorizationDenied {
error: String,
description: Option<String>,
},
#[error("malformed callback: {0}")]
MalformedCallback(&'static str),
#[error("token exchange failed: {0}")]
TokenExchange(String),
#[error(transparent)]
ScopeNotCovered(#[from] ScopeNotCovered),
#[error("token response carried no refresh_token — cannot build a renewable credential")]
MissingRefreshToken,
#[error("token store failure: {0}")]
TokenStore(String),
}
struct PendingAuthorization {
state: String,
code_verifier: String,
redirect_uri: String,
}
pub struct NativeAuthFlow<S: ConsentScopes> {
config: NativeConfig,
discovery: Discovery,
pending: Mutex<Option<PendingAuthorization>>,
_scope: PhantomData<S>,
}
impl<S: ConsentScopes> std::fmt::Debug for NativeAuthFlow<S> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("NativeAuthFlow")
.field("config", &self.config)
.field("discovery", &self.discovery)
.finish_non_exhaustive()
}
}
impl<S: ConsentScopes> NativeAuthFlow<S> {
pub async fn new(config: NativeConfig) -> Result<Self, NativeAuthInitError> {
let discovery = fetch_discovery(&config.issuer).await?;
Ok(Self { config, discovery, pending: Mutex::new(None), _scope: PhantomData })
}
#[must_use]
pub fn start(&self, redirect_uri: &str) -> Url {
let state = pkce::generate_state();
let code_verifier = pkce::generate_code_verifier();
let code_challenge = pkce::generate_code_challenge(&code_verifier);
let url = build_native_authorize_url(
&self.discovery.authorization_endpoint,
&self.config.client_id,
redirect_uri,
&state,
&code_challenge,
&S::scope_line(),
self.config.resource.as_deref(),
);
let mut slot = self.pending.lock().unwrap_or_else(|e| e.into_inner());
*slot = Some(PendingAuthorization {
state,
code_verifier,
redirect_uri: redirect_uri.to_owned(),
});
url
}
pub fn token_source<T: TokenStore>(
&self,
store: T,
) -> Result<RefreshTokenSource<AuthClient, T, S>, NativeAuthInitError> {
let client = AuthClient::try_new(self.oauth_config())
.map_err(|e| NativeAuthInitError::OAuthClient(e.to_string()))?;
Ok(RefreshTokenSource::new(client, store))
}
pub async fn complete<T: TokenStore>(
&self,
callback_query: &str,
store: T,
) -> Result<RefreshTokenSource<AuthClient, T, S>, CallbackError> {
let pending = {
let mut slot = self.pending.lock().unwrap_or_else(|e| e.into_inner());
slot.take()
}
.ok_or(CallbackError::NoPendingAuthorization)?;
let callback = CallbackParams::parse(callback_query)?;
if callback.state != pending.state {
return Err(CallbackError::StateMismatch);
}
let code = match callback.outcome {
CallbackOutcome::Code(code) => code,
CallbackOutcome::Error { error, description } => {
return Err(CallbackError::AuthorizationDenied { error, description });
}
};
let exchange_client = AuthClient::try_new(
self.oauth_config().with_redirect_uri(pending.redirect_uri),
)
.map_err(|e| CallbackError::TokenExchange(e.to_string()))?;
let tokens = exchange_client
.exchange_code(&code, &pending.code_verifier)
.await
.map_err(|e| CallbackError::TokenExchange(e.to_string()))?;
ensure_covers::<S>(tokens.scope.as_deref())?;
let refresh_token = tokens.refresh_token.ok_or(CallbackError::MissingRefreshToken)?;
store
.save(&refresh_token)
.await
.map_err(|e| CallbackError::TokenStore(e.to_string()))?;
self.token_source(store)
.map_err(|e| CallbackError::TokenExchange(e.to_string()))
}
fn oauth_config(&self) -> OAuthConfig {
let mut config = OAuthConfig::new(self.config.client_id.clone())
.with_auth_url(self.discovery.authorization_endpoint.clone())
.with_token_url(self.discovery.token_endpoint.clone());
if let Some(resource) = &self.config.resource {
config = config.with_resource(resource.clone());
}
config
}
}
enum CallbackOutcome {
Code(String),
Error { error: String, description: Option<String> },
}
struct CallbackParams {
state: String,
outcome: CallbackOutcome,
}
impl CallbackParams {
fn parse(query: &str) -> Result<Self, CallbackError> {
let mut state = None;
let mut code = None;
let mut error = None;
let mut description = None;
for (key, value) in url::form_urlencoded::parse(query.as_bytes()) {
match key.as_ref() {
"state" if state.is_none() => state = Some(value.into_owned()),
"code" if code.is_none() => code = Some(value.into_owned()),
"error" if error.is_none() => error = Some(value.into_owned()),
"error_description" if description.is_none() => {
description = Some(value.into_owned());
}
_ => {}
}
}
let state = state.ok_or(CallbackError::MalformedCallback("no `state` parameter"))?;
let outcome = match (code, error) {
(Some(_), Some(_)) => {
return Err(CallbackError::MalformedCallback(
"carries both `code` and `error`",
));
}
(Some(code), None) => CallbackOutcome::Code(code),
(None, Some(error)) => CallbackOutcome::Error { error, description },
(None, None) => {
return Err(CallbackError::MalformedCallback(
"carries neither `code` nor `error`",
));
}
};
Ok(Self { state, outcome })
}
}
fn build_native_authorize_url(
authorization_endpoint: &Url,
client_id: &str,
redirect_uri: &str,
state: &str,
code_challenge: &str,
scope: &str,
resource: Option<&str>,
) -> Url {
let mut url = authorization_endpoint.clone();
{
let mut pairs = url.query_pairs_mut();
pairs
.append_pair("response_type", "code")
.append_pair("client_id", client_id)
.append_pair("redirect_uri", redirect_uri)
.append_pair("state", state)
.append_pair("code_challenge", code_challenge)
.append_pair("code_challenge_method", "S256")
.append_pair("scope", scope);
if let Some(r) = resource {
pairs.append_pair("resource", r);
}
}
url
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
struct Notify;
impl ConsentScopes for Notify {
const SCOPES: &'static [&'static str] = &["chat.read", "contact.read", "chat.ack"];
}
fn authorize(resource: Option<&str>) -> String {
build_native_authorize_url(
&"http://localhost:3100/oauth/authorize".parse().unwrap(),
"cnc_01ky26fzxzjvzpy3",
"http://127.0.0.1:54321/callback",
"st",
"chal",
&Notify::scope_line(),
resource,
)
.into()
}
#[test]
fn authorize_url_emits_no_nonce() {
assert!(!authorize(None).contains("nonce"), "native flow must not request an id_token");
}
#[test]
fn authorize_url_carries_the_tier_scope_line() {
assert!(
authorize(None).contains("scope=chat.read+contact.read+chat.ack"),
"{}",
authorize(None)
);
}
#[test]
fn authorize_url_carries_pkce_s256_and_no_secret() {
let url = authorize(None);
assert!(url.contains("code_challenge=chal"));
assert!(url.contains("code_challenge_method=S256"));
assert!(!url.contains("client_secret"), "a native client is a public client");
}
#[test]
fn authorize_url_carries_the_resource_byte_identically() {
let url = authorize(Some("http://localhost:3200"));
let got = Url::parse(&url)
.unwrap()
.query_pairs()
.find(|(k, _)| k == "resource")
.map(|(_, v)| v.into_owned());
assert_eq!(
got.as_deref(),
Some("http://localhost:3200"),
"the resource must reach the wire byte-identically — PAS rejects \
the trailing-slash form with `invalid_target` (verified live)"
);
}
#[test]
fn authorize_url_carries_the_redirect_uri_byte_identically() {
let raw = "http://127.0.0.1:54321/callback";
let got = Url::parse(&authorize(None))
.unwrap()
.query_pairs()
.find(|(k, _)| k == "redirect_uri")
.map(|(_, v)| v.into_owned());
assert_eq!(got.as_deref(), Some(raw));
}
#[test]
fn authorize_url_omits_resource_when_absent() {
assert!(!authorize(None).contains("resource="));
}
#[test]
fn callback_parses_a_success_redirect() {
let p = CallbackParams::parse("code=abc&state=xyz").unwrap();
assert_eq!(p.state, "xyz");
assert!(matches!(p.outcome, CallbackOutcome::Code(c) if c == "abc"));
}
#[test]
fn callback_parses_an_error_redirect() {
let p = CallbackParams::parse(
"error=access_denied&error_description=User+declined&state=xyz",
)
.unwrap();
match p.outcome {
CallbackOutcome::Error { error, description } => {
assert_eq!(error, "access_denied");
assert_eq!(description.as_deref(), Some("User declined"));
}
CallbackOutcome::Code(_) => panic!("must parse as an error redirect"),
}
}
#[test]
fn callback_without_state_is_malformed() {
assert!(matches!(
CallbackParams::parse("code=abc"),
Err(CallbackError::MalformedCallback(_))
));
assert!(matches!(
CallbackParams::parse("error=access_denied"),
Err(CallbackError::MalformedCallback(_))
));
}
#[test]
fn callback_with_neither_code_nor_error_is_malformed() {
assert!(matches!(
CallbackParams::parse("state=xyz"),
Err(CallbackError::MalformedCallback(_))
));
}
#[test]
fn callback_with_both_code_and_error_is_refused() {
assert!(matches!(
CallbackParams::parse("code=abc&error=access_denied&state=xyz"),
Err(CallbackError::MalformedCallback(_))
));
}
#[test]
fn duplicate_parameters_take_the_first_occurrence() {
let p = CallbackParams::parse("code=real&state=xyz&code=injected").unwrap();
assert!(matches!(p.outcome, CallbackOutcome::Code(c) if c == "real"));
}
#[test]
fn callback_values_are_percent_decoded() {
let p = CallbackParams::parse("code=a%2Fb%2Bc&state=s%20t").unwrap();
assert_eq!(p.state, "s t");
assert!(matches!(p.outcome, CallbackOutcome::Code(c) if c == "a/b+c"));
}
}