#![allow(dead_code)]
use sha2::{Digest, Sha256};
pub trait OAuth2Transport {
fn post(&self, url: &str, body: &str) -> Result<String, String>;
}
pub struct MockTransport {
pub response: String,
}
impl OAuth2Transport for MockTransport {
fn post(&self, _url: &str, _body: &str) -> Result<String, String> {
Ok(self.response.clone())
}
}
#[derive(Clone, Debug)]
pub struct PkceChallenge {
pub code_verifier: String,
pub code_challenge: String,
}
#[derive(Clone, Debug)]
pub struct OAuth2Token {
pub access_token: String,
pub token_type: String,
pub expires_in_secs: u64,
pub refresh_token: Option<String>,
pub scope: Option<String>,
}
#[derive(Clone, Debug)]
pub struct OAuth2Config {
pub client_id: String,
pub redirect_uri: String,
pub auth_endpoint: String,
pub token_endpoint: String,
}
pub struct OAuth2Client {
pub config: OAuth2Config,
pending_verifier: Option<String>,
}
fn base64url_no_pad(bytes: &[u8]) -> String {
const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
let mut out = String::with_capacity((bytes.len() * 4).div_ceil(3));
for chunk in bytes.chunks(3) {
let b0 = chunk[0] as u32;
let b1 = chunk.get(1).copied().unwrap_or(0) as u32;
let b2 = chunk.get(2).copied().unwrap_or(0) as u32;
let v = (b0 << 16) | (b1 << 8) | b2;
out.push(ALPHABET[(v >> 18) as usize & 63] as char);
out.push(ALPHABET[(v >> 12) as usize & 63] as char);
if chunk.len() > 1 {
out.push(ALPHABET[(v >> 6) as usize & 63] as char);
}
if chunk.len() > 2 {
out.push(ALPHABET[v as usize & 63] as char);
}
}
out
}
pub fn url_encode(s: &str) -> String {
const HEX: &[u8; 16] = b"0123456789ABCDEF";
let mut out = String::with_capacity(s.len() * 3);
for &byte in s.as_bytes() {
match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(byte as char);
}
_ => {
out.push('%');
out.push(HEX[(byte >> 4) as usize] as char);
out.push(HEX[(byte & 0x0f) as usize] as char);
}
}
}
out
}
fn parse_token_response(body: &str) -> Result<OAuth2Token, String> {
let v: serde_json::Value =
serde_json::from_str(body).map_err(|e| format!("invalid JSON: {e}"))?;
if let Some(err) = v.get("error").and_then(|e| e.as_str()) {
let description = v
.get("error_description")
.and_then(|d| d.as_str())
.unwrap_or(err);
return Err(description.to_owned());
}
let access_token = v
.get("access_token")
.and_then(|t| t.as_str())
.ok_or("missing access_token in response")?
.to_owned();
let token_type = v
.get("token_type")
.and_then(|t| t.as_str())
.unwrap_or("Bearer")
.to_owned();
let expires_in_secs = v.get("expires_in").and_then(|e| e.as_u64()).unwrap_or(3600);
let refresh_token = v
.get("refresh_token")
.and_then(|r| r.as_str())
.map(|s| s.to_owned());
let scope = v
.get("scope")
.and_then(|s| s.as_str())
.map(|s| s.to_owned());
Ok(OAuth2Token {
access_token,
token_type,
expires_in_secs,
refresh_token,
scope,
})
}
pub fn generate_pkce_challenge(verifier: &str) -> PkceChallenge {
let hash = Sha256::digest(verifier.as_bytes());
let code_challenge = base64url_no_pad(&hash);
PkceChallenge {
code_verifier: verifier.to_owned(),
code_challenge,
}
}
pub fn build_authorization_url(
cfg: &OAuth2Config,
challenge: &PkceChallenge,
state: &str,
) -> String {
format!(
"{}?response_type=code&code_challenge_method=S256\
&client_id={}\
&redirect_uri={}\
&code_challenge={}\
&state={}",
cfg.auth_endpoint,
url_encode(&cfg.client_id),
url_encode(&cfg.redirect_uri),
url_encode(&challenge.code_challenge),
url_encode(state),
)
}
pub fn exchange_code_for_token_with_transport(
cfg: &OAuth2Config,
code: &str,
verifier: &str,
transport: &dyn OAuth2Transport,
) -> Result<OAuth2Token, String> {
if code.is_empty() {
return Err("empty authorization code".into());
}
if verifier.is_empty() {
return Err("empty code verifier".into());
}
let body = format!(
"grant_type=authorization_code\
&code={}\
&redirect_uri={}\
&client_id={}\
&code_verifier={}",
url_encode(code),
url_encode(&cfg.redirect_uri),
url_encode(&cfg.client_id),
url_encode(verifier),
);
let response = transport.post(&cfg.token_endpoint, &body)?;
parse_token_response(&response)
}
pub fn exchange_code_for_token(
_cfg: &OAuth2Config,
code: &str,
verifier: &str,
) -> Result<OAuth2Token, String> {
if code.is_empty() {
return Err("empty authorization code".into());
}
if verifier.is_empty() {
return Err("empty code verifier".into());
}
Err("a transport is required; use exchange_code_for_token_with_transport".into())
}
pub fn refresh_token_with_transport(
cfg: &OAuth2Config,
refresh: &str,
transport: &dyn OAuth2Transport,
) -> Result<OAuth2Token, String> {
if refresh.is_empty() {
return Err("empty refresh token".into());
}
let body = format!(
"grant_type=refresh_token\
&refresh_token={}\
&client_id={}",
url_encode(refresh),
url_encode(&cfg.client_id),
);
let response = transport.post(&cfg.token_endpoint, &body)?;
parse_token_response(&response)
}
pub fn refresh_token(cfg: &OAuth2Config, refresh: &str) -> Result<OAuth2Token, String> {
if refresh.is_empty() {
return Err("empty refresh token".into());
}
Err(format!(
"a transport is required for client '{}'; use refresh_token_with_transport",
cfg.client_id
))
}
impl OAuth2Client {
pub fn new(config: OAuth2Config) -> Self {
Self {
config,
pending_verifier: None,
}
}
pub fn start_pkce_flow(&mut self, verifier: &str, state: &str) -> String {
let challenge = generate_pkce_challenge(verifier);
self.pending_verifier = Some(verifier.to_owned());
build_authorization_url(&self.config, &challenge, state)
}
pub fn complete_flow<T: OAuth2Transport>(
&mut self,
code: &str,
transport: &T,
) -> Result<OAuth2Token, String> {
let verifier = self.pending_verifier.take().ok_or("no pending flow")?;
exchange_code_for_token_with_transport(&self.config, code, &verifier, transport)
}
pub fn refresh<T: OAuth2Transport>(
&self,
token: &OAuth2Token,
transport: &T,
) -> Result<OAuth2Token, String> {
let refresh = token
.refresh_token
.as_deref()
.ok_or("token has no refresh_token")?;
refresh_token_with_transport(&self.config, refresh, transport)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn valid_token_json(access: &str, refresh: &str) -> String {
format!(
r#"{{"access_token":"{access}","token_type":"Bearer","expires_in":3600,"refresh_token":"{refresh}","scope":"openid profile"}}"#
)
}
fn cfg() -> OAuth2Config {
OAuth2Config {
client_id: "test-client".into(),
redirect_uri: "http://localhost:8080/callback".into(),
auth_endpoint: "https://auth.example.com/authorize".into(),
token_endpoint: "https://auth.example.com/token".into(),
}
}
#[test]
fn test_pkce_challenge_is_base64url() {
let ch = generate_pkce_challenge("my-verifier-string");
assert_eq!(ch.code_challenge.len(), 43);
assert!(!ch.code_challenge.contains('='));
assert!(!ch.code_challenge.contains('+'));
assert!(!ch.code_challenge.contains('/'));
}
#[test]
fn test_pkce_verifier_preserved() {
let verifier = "verifier-abc";
let ch = generate_pkce_challenge(verifier);
assert_eq!(ch.code_verifier, verifier);
}
#[test]
fn test_pkce_known_vector() {
let verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
let challenge_obj = generate_pkce_challenge(verifier);
assert_eq!(
challenge_obj.code_challenge,
"E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
);
}
#[test]
fn test_pkce_deterministic() {
let a = generate_pkce_challenge("test-verifier-abc123");
let b = generate_pkce_challenge("test-verifier-abc123");
assert_eq!(a.code_challenge, b.code_challenge);
}
#[test]
fn test_base64url_no_pad_basic() {
assert_eq!(base64url_no_pad(&[0xffu8]), "_w");
assert_eq!(base64url_no_pad(&[]), "");
let hash = Sha256::digest(b"abc");
let encoded = base64url_no_pad(&hash);
assert_eq!(encoded.len(), 43);
assert!(!encoded.contains('='));
assert!(!encoded.contains('+'));
assert!(!encoded.contains('/'));
}
#[test]
fn test_exchange_empty_code_returns_error() {
assert!(exchange_code_for_token(&cfg(), "", "verifier").is_err());
}
#[test]
fn test_refresh_empty_token_returns_error() {
assert!(refresh_token(&cfg(), "").is_err());
}
#[test]
fn test_build_authorization_url_contains_client_id() {
let ch = generate_pkce_challenge("ver");
let url = build_authorization_url(&cfg(), &ch, "state1");
assert!(url.contains("test-client"));
}
#[test]
fn test_complete_flow_without_start_errors() {
let mut client = OAuth2Client::new(cfg());
let transport = MockTransport {
response: valid_token_json("at_x", "rt_x"),
};
assert!(client.complete_flow("code", &transport).is_err());
}
#[test]
fn test_url_encode_unreserved_unchanged() {
let s = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_.~";
assert_eq!(url_encode(s), s);
}
#[test]
fn test_url_encode_space_and_special() {
assert_eq!(url_encode("hello world"), "hello%20world");
assert_eq!(url_encode("a+b=c&d"), "a%2Bb%3Dc%26d");
assert_eq!(
url_encode("http://x.com/cb?q=1"),
"http%3A%2F%2Fx.com%2Fcb%3Fq%3D1"
);
}
#[test]
fn test_url_encode_empty() {
assert_eq!(url_encode(""), "");
}
#[test]
fn test_build_authorization_url_encodes_params() {
let cfg_special = OAuth2Config {
client_id: "client id+special".into(),
redirect_uri: "http://localhost:8080/call back?x=1".into(),
auth_endpoint: "https://auth.example.com/authorize".into(),
token_endpoint: "https://auth.example.com/token".into(),
};
let ch = generate_pkce_challenge("ver");
let url = build_authorization_url(&cfg_special, &ch, "state with spaces");
assert!(
url.contains("client%20id%2Bspecial"),
"client_id not encoded: {url}"
);
assert!(
url.contains("http%3A%2F%2Flocalhost%3A8080%2Fcall%20back%3Fx%3D1"),
"redirect_uri not encoded: {url}"
);
assert!(
url.contains("state%20with%20spaces"),
"state not encoded: {url}"
);
assert!(
url.contains("response_type=code"),
"missing response_type: {url}"
);
assert!(
url.contains("code_challenge_method=S256"),
"missing S256: {url}"
);
}
#[test]
fn test_build_authorization_url_includes_required_params() {
let ch = generate_pkce_challenge("verifier-xyz");
let url = build_authorization_url(&cfg(), &ch, "my-state");
assert!(url.starts_with("https://auth.example.com/authorize?"));
assert!(url.contains("response_type=code"));
assert!(url.contains("code_challenge_method=S256"));
assert!(url.contains("code_challenge="));
assert!(url.contains("state=my-state"));
assert!(url.contains("redirect_uri="));
}
#[test]
fn test_exchange_code_parses_valid_response() {
let transport = MockTransport {
response: valid_token_json("access_tok_abc", "refresh_tok_xyz"),
};
let tok =
exchange_code_for_token_with_transport(&cfg(), "auth-code-1", "verifier-1", &transport)
.expect("should parse valid response");
assert_eq!(tok.access_token, "access_tok_abc");
assert_eq!(tok.token_type, "Bearer");
assert_eq!(tok.expires_in_secs, 3600);
assert_eq!(tok.refresh_token.as_deref(), Some("refresh_tok_xyz"));
assert_eq!(tok.scope.as_deref(), Some("openid profile"));
}
#[test]
fn test_exchange_code_handles_error_response() {
let transport = MockTransport {
response: r#"{"error":"invalid_grant","error_description":"Code expired"}"#.into(),
};
let result =
exchange_code_for_token_with_transport(&cfg(), "bad-code", "verifier", &transport);
assert!(result.is_err(), "expected error");
assert!(
result.unwrap_err().contains("Code expired"),
"error should mention Code expired"
);
}
#[test]
fn test_exchange_code_error_without_description_uses_error_field() {
let transport = MockTransport {
response: r#"{"error":"unauthorized_client"}"#.into(),
};
let result = exchange_code_for_token_with_transport(&cfg(), "code", "verifier", &transport);
assert!(result.is_err());
assert!(result.unwrap_err().contains("unauthorized_client"));
}
#[test]
fn test_exchange_code_empty_code_returns_error() {
let transport = MockTransport {
response: "{}".into(),
};
let result = exchange_code_for_token_with_transport(&cfg(), "", "verifier", &transport);
assert!(result.is_err());
assert!(result.unwrap_err().contains("empty authorization code"));
}
#[test]
fn test_exchange_code_empty_verifier_returns_error() {
let transport = MockTransport {
response: "{}".into(),
};
let result = exchange_code_for_token_with_transport(&cfg(), "code", "", &transport);
assert!(result.is_err());
assert!(result.unwrap_err().contains("empty code verifier"));
}
#[test]
fn test_refresh_token_parses_response() {
let transport = MockTransport {
response: valid_token_json("new_access_token", "new_refresh_token"),
};
let tok = refresh_token_with_transport(&cfg(), "old-refresh-token", &transport)
.expect("should parse valid response");
assert_eq!(tok.access_token, "new_access_token");
assert_eq!(tok.token_type, "Bearer");
assert_eq!(tok.expires_in_secs, 3600);
assert_eq!(tok.refresh_token.as_deref(), Some("new_refresh_token"));
}
#[test]
fn test_refresh_token_handles_error_response() {
let transport = MockTransport {
response: r#"{"error":"invalid_grant","error_description":"Refresh token expired"}"#
.into(),
};
let result = refresh_token_with_transport(&cfg(), "stale-rt", &transport);
assert!(result.is_err());
assert!(result.unwrap_err().contains("Refresh token expired"));
}
#[test]
fn test_refresh_token_empty_refresh_returns_error() {
let transport = MockTransport {
response: "{}".into(),
};
let result = refresh_token_with_transport(&cfg(), "", &transport);
assert!(result.is_err());
assert!(result.unwrap_err().contains("empty refresh token"));
}
#[test]
fn test_pkce_flow_roundtrip() {
let transport = MockTransport {
response: valid_token_json("full_flow_token", "full_flow_rt"),
};
let mut client = OAuth2Client::new(cfg());
let url = client.start_pkce_flow("secure-verifier-1234", "state-xyz");
assert!(url.starts_with("https://auth.example.com/authorize?"));
assert!(url.contains("response_type=code"));
assert!(url.contains("code_challenge_method=S256"));
assert!(url.contains("test-client"));
assert!(url.contains("state-xyz"));
let tok = client
.complete_flow("returned-auth-code", &transport)
.expect("complete_flow should succeed");
assert_eq!(tok.access_token, "full_flow_token");
assert_eq!(tok.refresh_token.as_deref(), Some("full_flow_rt"));
}
#[test]
fn test_client_refresh() {
let transport = MockTransport {
response: valid_token_json("refreshed_at", "new_rt"),
};
let client = OAuth2Client::new(cfg());
let initial_token = OAuth2Token {
access_token: "old_at".into(),
token_type: "Bearer".into(),
expires_in_secs: 0,
refresh_token: Some("old_rt".into()),
scope: None,
};
let tok = client
.refresh(&initial_token, &transport)
.expect("refresh should succeed");
assert_eq!(tok.access_token, "refreshed_at");
}
#[test]
fn test_client_refresh_no_refresh_token_errors() {
let transport = MockTransport {
response: "{}".into(),
};
let client = OAuth2Client::new(cfg());
let no_refresh_token = OAuth2Token {
access_token: "at".into(),
token_type: "Bearer".into(),
expires_in_secs: 3600,
refresh_token: None,
scope: None,
};
assert!(client.refresh(&no_refresh_token, &transport).is_err());
}
#[test]
fn test_client_start_pkce_flow_consumes_verifier_once() {
let transport = MockTransport {
response: valid_token_json("tok_a", "rt_a"),
};
let mut client = OAuth2Client::new(cfg());
client.start_pkce_flow("ver-111", "st1");
let _ = client
.complete_flow("code1", &transport)
.expect("first complete_flow should succeed");
let transport2 = MockTransport {
response: valid_token_json("tok_b", "rt_b"),
};
assert!(client.complete_flow("code2", &transport2).is_err());
}
#[test]
fn test_parse_token_response_missing_access_token() {
let result = parse_token_response(r#"{"token_type":"Bearer"}"#);
assert!(result.is_err());
assert!(result.unwrap_err().contains("missing access_token"));
}
#[test]
fn test_parse_token_response_invalid_json() {
let result = parse_token_response("not-json{{{");
assert!(result.is_err());
assert!(result.unwrap_err().contains("invalid JSON"));
}
#[test]
fn test_parse_token_response_minimal_valid() {
let result = parse_token_response(r#"{"access_token":"minimal_tok"}"#);
assert!(result.is_ok());
let tok = result.unwrap();
assert_eq!(tok.access_token, "minimal_tok");
assert_eq!(tok.token_type, "Bearer");
assert_eq!(tok.expires_in_secs, 3600);
assert!(tok.refresh_token.is_none());
assert!(tok.scope.is_none());
}
}