1use rustlavel_auth::{base64, random};
14use sha2::{Digest, Sha256};
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
18pub enum ChallengeMethod {
19 #[default]
21 S256,
22 Plain,
26}
27
28impl ChallengeMethod {
29 pub fn as_str(self) -> &'static str {
30 match self {
31 ChallengeMethod::S256 => "S256",
32 ChallengeMethod::Plain => "plain",
33 }
34 }
35
36 pub fn parse(raw: &str) -> Option<ChallengeMethod> {
37 match raw {
38 "S256" => Some(ChallengeMethod::S256),
39 "plain" => Some(ChallengeMethod::Plain),
40 _ => None,
41 }
42 }
43}
44
45impl std::fmt::Display for ChallengeMethod {
46 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
47 f.write_str(self.as_str())
48 }
49}
50
51#[derive(Clone)]
57pub struct Pkce {
58 verifier: String,
59 method: ChallengeMethod,
60}
61
62impl Pkce {
63 pub fn generate() -> Pkce {
68 Pkce {
69 verifier: base64::encode_url(&random::bytes(32)),
70 method: ChallengeMethod::S256,
71 }
72 }
73
74 pub fn from_verifier(verifier: impl Into<String>) -> Result<Pkce, String> {
80 let verifier = verifier.into();
81 validate_verifier(&verifier)?;
82 Ok(Pkce { verifier, method: ChallengeMethod::S256 })
83 }
84
85 pub fn plain(mut self) -> Pkce {
87 self.method = ChallengeMethod::Plain;
88 self
89 }
90
91 pub fn verifier(&self) -> &str {
92 &self.verifier
93 }
94
95 pub fn method(&self) -> ChallengeMethod {
96 self.method
97 }
98
99 pub fn challenge(&self) -> String {
101 match self.method {
102 ChallengeMethod::S256 => s256(&self.verifier),
103 ChallengeMethod::Plain => self.verifier.clone(),
104 }
105 }
106}
107
108impl std::fmt::Debug for Pkce {
109 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
110 f.debug_struct("Pkce")
111 .field("verifier", &"<redacted>")
112 .field("method", &self.method)
113 .finish()
114 }
115}
116
117pub fn s256(verifier: &str) -> String {
119 base64::encode_url(&Sha256::digest(verifier.as_bytes()))
120}
121
122pub fn verify(verifier: &str, challenge: &str, method: ChallengeMethod) -> bool {
128 if validate_verifier(verifier).is_err() {
129 return false;
130 }
131
132 let derived = match method {
133 ChallengeMethod::S256 => s256(verifier),
134 ChallengeMethod::Plain => verifier.to_string(),
135 };
136
137 rustlavel_auth::constant_time_eq(derived.as_bytes(), challenge.as_bytes())
138}
139
140pub fn validate_verifier(verifier: &str) -> Result<(), String> {
145 if !(43..=128).contains(&verifier.len()) {
146 return Err(format!(
147 "a PKCE verifier must be 43 to 128 characters (RFC 7636 §4.1); this one is {}",
148 verifier.len()
149 ));
150 }
151
152 match verifier.bytes().find(|byte| {
153 !matches!(byte, b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~')
154 }) {
155 Some(byte) => Err(format!(
156 "a PKCE verifier may only contain [A-Za-z0-9-._~]; this one contains {:?}",
157 byte as char
158 )),
159 None => Ok(()),
160 }
161}
162
163#[cfg(test)]
164mod tests {
165 use super::*;
166
167 #[test]
168 fn matches_the_worked_example_from_rfc_7636() {
169 let verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
171 assert_eq!(s256(verifier), "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM");
172 }
173
174 #[test]
175 fn a_generated_verifier_satisfies_the_grammar() {
176 let pkce = Pkce::generate();
177
178 assert_eq!(pkce.verifier().len(), 43, "32 bytes of entropy, unpadded");
179 validate_verifier(pkce.verifier()).expect("generated verifiers must be valid");
180 }
181
182 #[test]
183 fn two_verifiers_are_never_the_same() {
184 assert_ne!(Pkce::generate().verifier(), Pkce::generate().verifier());
185 }
186
187 #[test]
188 fn the_challenge_is_not_the_verifier_under_s256() {
189 let pkce = Pkce::generate();
190 assert_ne!(pkce.challenge(), pkce.verifier());
191 assert_eq!(pkce.challenge(), s256(pkce.verifier()));
192 }
193
194 #[test]
195 fn plain_leaks_the_verifier_into_the_url() {
196 let pkce = Pkce::generate().plain();
199 assert_eq!(pkce.challenge(), pkce.verifier());
200 }
201
202 #[test]
203 fn the_right_verifier_passes_and_a_wrong_one_does_not() {
204 let pkce = Pkce::generate();
205 let challenge = pkce.challenge();
206
207 assert!(verify(pkce.verifier(), &challenge, ChallengeMethod::S256));
208 assert!(!verify(Pkce::generate().verifier(), &challenge, ChallengeMethod::S256));
209 }
210
211 #[test]
212 fn a_verifier_cannot_be_replayed_under_the_wrong_method() {
213 let pkce = Pkce::generate();
216 let challenge = pkce.challenge();
217
218 assert!(!verify(&challenge, &challenge, ChallengeMethod::S256));
219 }
220
221 #[test]
222 fn verification_refuses_a_malformed_verifier_outright() {
223 assert!(!verify("", &s256(""), ChallengeMethod::S256));
226 assert!(!verify("short", &s256("short"), ChallengeMethod::S256));
227 }
228
229 #[test]
230 fn the_grammar_error_says_what_is_wrong() {
231 let too_short = validate_verifier("abc").unwrap_err();
232 assert!(too_short.contains("43 to 128"), "got {too_short}");
233
234 let long_enough_but_illegal = format!("{}/{}", "a".repeat(21), "b".repeat(21));
235 let bad_character = validate_verifier(&long_enough_but_illegal).unwrap_err();
236 assert!(bad_character.contains('/'), "got {bad_character}");
237 }
238
239 #[test]
240 fn a_verifier_at_each_boundary_is_accepted() {
241 assert!(validate_verifier(&"a".repeat(43)).is_ok());
242 assert!(validate_verifier(&"a".repeat(128)).is_ok());
243 assert!(validate_verifier(&"a".repeat(42)).is_err());
244 assert!(validate_verifier(&"a".repeat(129)).is_err());
245 }
246
247 #[test]
248 fn from_verifier_rejects_what_the_provider_would_reject() {
249 assert!(Pkce::from_verifier("too-short").is_err());
250 assert!(Pkce::from_verifier("a".repeat(43)).is_ok());
251 }
252
253 #[test]
254 fn methods_round_trip_through_their_wire_spelling() {
255 assert_eq!(ChallengeMethod::parse("S256"), Some(ChallengeMethod::S256));
256 assert_eq!(ChallengeMethod::parse("plain"), Some(ChallengeMethod::Plain));
257 assert_eq!(ChallengeMethod::parse("s256"), None, "the spelling is case-sensitive");
258 assert_eq!(ChallengeMethod::parse("none"), None);
259 }
260
261 #[test]
262 fn debug_does_not_print_the_verifier() {
263 let pkce = Pkce::generate();
264 let printed = format!("{pkce:?}");
265
266 assert!(!printed.contains(pkce.verifier()), "the verifier reached a log");
267 assert!(printed.contains("redacted"));
268 }
269}