use core::fmt;
use base64::Engine as _;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use sha2::{Digest, Sha256};
const VERIFIER_MIN: usize = 43;
const VERIFIER_MAX: usize = 128;
#[derive(Clone, PartialEq, Eq)]
pub struct CodeVerifier(String);
impl fmt::Debug for CodeVerifier {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str("CodeVerifier(…)")
}
}
impl CodeVerifier {
pub fn new(value: impl Into<String>) -> Result<Self, PkceError> {
let value = value.into();
let length = value.chars().count();
if !(VERIFIER_MIN..=VERIFIER_MAX).contains(&length) {
return Err(PkceError::VerifierLength { length });
}
let offending = value
.as_bytes()
.iter()
.copied()
.find(|byte| !is_unreserved(*byte));
if let Some(byte) = offending {
return Err(PkceError::VerifierCharset { byte });
}
Ok(Self(value))
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
#[must_use]
pub fn challenge(&self, method: ChallengeMethod) -> CodeChallenge {
match method {
ChallengeMethod::Plain => CodeChallenge(self.0.clone()),
ChallengeMethod::S256 => {
let digest = Sha256::digest(self.0.as_bytes());
CodeChallenge(URL_SAFE_NO_PAD.encode(digest))
}
}
}
}
impl fmt::Display for CodeVerifier {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(&self.0)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
pub enum ChallengeMethod {
Plain,
#[default]
S256,
}
impl ChallengeMethod {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Plain => "plain",
Self::S256 => "S256",
}
}
}
impl fmt::Display for ChallengeMethod {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.as_str())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CodeChallenge(String);
impl CodeChallenge {
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Display for CodeChallenge {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(&self.0)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
pub enum PkceError {
#[error("a code verifier must be 43 to 128 characters, got {length}")]
VerifierLength {
length: usize,
},
#[error(
"a code verifier may only contain ALPHA, DIGIT, '-', '.', '_' and '~'; found 0x{byte:02X}"
)]
VerifierCharset {
byte: u8,
},
}
const fn is_unreserved(byte: u8) -> bool {
byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~')
}
#[cfg(test)]
mod tests {
use super::{ChallengeMethod, CodeVerifier, PkceError, VERIFIER_MAX, VERIFIER_MIN};
const RFC_VERIFIER: &str = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
const RFC_CHALLENGE: &str = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM";
#[test]
fn the_rfc_vector_derives_the_documented_challenge() -> Result<(), PkceError> {
let verifier = CodeVerifier::new(RFC_VERIFIER)?;
let challenge = verifier.challenge(ChallengeMethod::S256);
assert_eq!(
challenge.as_str(),
RFC_CHALLENGE,
"RFC 7636 Appendix A pins this derivation"
);
Ok(())
}
#[test]
fn the_rfc_vector_is_at_the_grammar_minimum() {
assert_eq!(RFC_VERIFIER.chars().count(), VERIFIER_MIN);
assert_eq!(RFC_CHALLENGE.chars().count(), VERIFIER_MIN);
}
#[test]
fn plain_is_the_verifier_unchanged() -> Result<(), PkceError> {
let verifier = CodeVerifier::new(RFC_VERIFIER)?;
assert_eq!(
verifier.challenge(ChallengeMethod::Plain).as_str(),
RFC_VERIFIER
);
Ok(())
}
#[test]
fn both_length_bounds_are_enforced() {
let shortest = "a".repeat(VERIFIER_MIN);
let longest = "a".repeat(VERIFIER_MAX);
assert!(
CodeVerifier::new(shortest).is_ok(),
"{VERIFIER_MIN} characters is the grammar minimum"
);
assert!(
CodeVerifier::new(longest).is_ok(),
"{VERIFIER_MAX} characters is the grammar maximum"
);
let too_short = "a".repeat(VERIFIER_MIN - 1);
let too_long = "a".repeat(VERIFIER_MAX + 1);
assert_eq!(
CodeVerifier::new(too_short),
Err(PkceError::VerifierLength {
length: VERIFIER_MIN - 1
})
);
assert_eq!(
CodeVerifier::new(too_long),
Err(PkceError::VerifierLength {
length: VERIFIER_MAX + 1
})
);
}
#[test]
fn the_charset_is_exactly_unreserved() {
fn with_first(first: char) -> String {
std::iter::once(first)
.chain(std::iter::repeat_n('a', VERIFIER_MIN - 1))
.collect()
}
for byte in b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~" {
assert!(
CodeVerifier::new(with_first(char::from(*byte))).is_ok(),
"0x{byte:02X} is unreserved and must be accepted"
);
}
for byte in *b"+/= %*!" {
assert_eq!(
CodeVerifier::new(with_first(char::from(byte))),
Err(PkceError::VerifierCharset { byte }),
"0x{byte:02X} is not unreserved"
);
}
}
#[test]
fn a_multi_byte_character_is_a_charset_failure() {
let candidate: String = std::iter::once('€')
.chain(std::iter::repeat_n('a', VERIFIER_MIN - 1))
.collect();
assert_eq!(
CodeVerifier::new(candidate),
Err(PkceError::VerifierCharset { byte: 0xE2 })
);
}
#[test]
fn debug_never_renders_the_verifier() -> Result<(), PkceError> {
let verifier = CodeVerifier::new(RFC_VERIFIER)?;
let rendered = format!("{verifier:?}");
assert_eq!(rendered, "CodeVerifier(…)");
assert!(
!rendered.contains(RFC_VERIFIER),
"the verifier leaked: {rendered}"
);
Ok(())
}
#[test]
fn display_is_the_full_verifier() -> Result<(), PkceError> {
let verifier = CodeVerifier::new(RFC_VERIFIER)?;
assert_eq!(verifier.to_string(), RFC_VERIFIER);
assert_eq!(verifier.as_str(), RFC_VERIFIER);
Ok(())
}
#[test]
fn the_method_spells_itself_the_way_the_rfc_writes_it() {
assert_eq!(ChallengeMethod::Plain.as_str(), "plain");
assert_eq!(ChallengeMethod::S256.as_str(), "S256");
assert_eq!(ChallengeMethod::default(), ChallengeMethod::S256);
}
#[test]
fn an_s256_challenge_is_always_the_right_size() -> Result<(), PkceError> {
for length in [VERIFIER_MIN, VERIFIER_MIN + 1, VERIFIER_MAX] {
let verifier = CodeVerifier::new("a".repeat(length))?;
let challenge = verifier.challenge(ChallengeMethod::S256);
assert_eq!(
challenge.as_str().chars().count(),
VERIFIER_MIN,
"a challenge derived from a {length}-character verifier"
);
}
Ok(())
}
}