Skip to content

Commit a2c1e9c

Browse files
astrawclaude
andcommitted
feat: expose token_expiry to read a token's embedded expiry (Claude Opus 5)
Dependants hand out tokens in logged URLs and QR codes, and want to tell an operator when one stops working. Doing that meant re-deriving this crate's token wire format at each call site, in Rust and again in a wasm frontend -- three copies of a format only this crate should know. `token_expiry` decodes the version and expiry and returns the instant. It deliberately does NOT verify the MAC: the key is not always in scope at a display site, and a wrong-key token is not a case a "valid until" label needs to distinguish. The doc comment says plainly that the result is unauthenticated and must never gate a request; the middleware remains the only thing that decides authorization. `verify_token` and `token_expiry` now share a `split_token` helper so the layout is written down once. `OffsetDateTime` is re-exported so callers can name the return type without depending on `time` directly. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 7d01e10 commit a2c1e9c

2 files changed

Lines changed: 79 additions & 12 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2020
### Added
2121

2222
- Authentication failures now emit specific diagnostic messages distinguishing no session cookie, invalid session signature, expired session, no access token, malformed token, invalid token signature, and expired access token. The `ValidationErrors` type carries all relevant error reasons, allowing clients to provide actionable feedback. (Claude Haiku 4.5)
23+
- `token_expiry(&str)` reads the expiry embedded in a token so a server can tell an operator when a URL or QR code it handed out stops working, without callers re-deriving the token wire format. It does not verify the signature and must not be used to authorize anything. `OffsetDateTime` is re-exported for its return type. (Claude Opus 5)
2324

2425
## [0.3.0](https://github.com/strawlab/axum-token-auth/compare/v0.2.1...v0.3.0) - 2026-06-16
2526

src/lib.rs

Lines changed: 78 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,8 @@ use axum::{
133133
};
134134

135135
use base64::Engine as _;
136-
use cookie::time::{Duration, OffsetDateTime, PrimitiveDateTime};
136+
pub use cookie::time::OffsetDateTime;
137+
use cookie::time::{Duration, PrimitiveDateTime};
137138
pub use cookie::{Key, SameSite};
138139
use futures_util::future::BoxFuture;
139140
use hmac::{Hmac, Mac};
@@ -323,30 +324,58 @@ enum TokenCheckResult {
323324
UnknownVersion,
324325
}
325326

327+
/// Split a token into its `(version, expiry_unix, mac)` parts without
328+
/// interpreting any of them, or `None` if it is not decodable or is too short
329+
/// to hold the fixed-size header.
330+
///
331+
/// Nothing here is authenticated: the caller decides what to do with the parts.
332+
fn split_token(token: &str) -> Option<(u8, i64, Vec<u8>)> {
333+
let buf = TOKEN_B64.decode(token).ok()?;
334+
// Layout: version (1 byte) ‖ expiry (8 bytes) ‖ MAC. Split it without any
335+
// indexing that could panic on a short or truncated token.
336+
let (&version, rest) = buf.split_first()?;
337+
let (expiry_bytes, mac_bytes) = rest.split_first_chunk::<8>()?;
338+
Some((
339+
version,
340+
i64::from_le_bytes(*expiry_bytes),
341+
mac_bytes.to_vec(),
342+
))
343+
}
344+
345+
/// Read the expiry embedded in a token **without verifying its signature**.
346+
///
347+
/// This exists so a server can tell an operator when a token it hands out (in a
348+
/// logged URL, a QR code) stops working, without every caller re-deriving this
349+
/// crate's wire format. It returns `None` for anything that is not a
350+
/// well-formed token of a recognised version.
351+
///
352+
/// SECURITY: the result is unauthenticated. Anyone can craft a string with any
353+
/// expiry they like, so this must be used for display and diagnostics only —
354+
/// never to decide whether a request is authorized. The middleware's own check
355+
/// ([AuthConfig::into_layer]) is the only thing that may make that decision.
356+
pub fn token_expiry(token: &str) -> Option<OffsetDateTime> {
357+
let (version, expiry_unix, _mac) = split_token(token)?;
358+
if version != TOKEN_VERSION {
359+
return None;
360+
}
361+
OffsetDateTime::from_unix_timestamp(expiry_unix).ok()
362+
}
363+
326364
/// Verify a token produced by [sign_token]: check the version, the signature (in
327365
/// constant time), and that it has not yet expired relative to `now`. Returns a
328366
/// [TokenCheckResult] so callers can distinguish expiry from signature failure
329367
/// and emit specific diagnostic messages.
330368
fn verify_token(key: &Key, token: &str, now: OffsetDateTime) -> TokenCheckResult {
331-
let Ok(buf) = TOKEN_B64.decode(token) else {
332-
return TokenCheckResult::Malformed;
333-
};
334-
// Layout: version (1 byte) ‖ expiry (8 bytes) ‖ MAC. Split it without any
335-
// indexing that could panic on a short or truncated token.
336-
let Some((&version, rest)) = buf.split_first() else {
369+
let Some((version, expiry_unix, mac_bytes)) = split_token(token) else {
337370
return TokenCheckResult::Malformed;
338371
};
339372
if version != TOKEN_VERSION {
340373
return TokenCheckResult::UnknownVersion;
341374
}
342-
let Some((expiry_bytes, mac_bytes)) = rest.split_first_chunk::<8>() else {
343-
return TokenCheckResult::Malformed;
344-
};
345-
let expiry_unix = i64::from_le_bytes(*expiry_bytes);
346375

347376
// Constant-time signature check.
348377
if token_mac(key, version, expiry_unix)
349-
.verify_slice(mac_bytes)
378+
.verify_slice(&mac_bytes)
350379
.is_err()
351380
{
352381
return TokenCheckResult::BadSignature;
@@ -1354,6 +1383,43 @@ mod tests {
13541383
));
13551384
}
13561385

1386+
/// [token_expiry] reports the embedded expiry for display, without needing
1387+
/// the key and without caring whether the token has already expired.
1388+
#[test]
1389+
fn token_expiry_reads_the_embedded_instant() {
1390+
let key = Key::generate();
1391+
let now = OffsetDateTime::from_unix_timestamp(1_700_000_000).unwrap();
1392+
let expiry = now + Duration::minutes(30);
1393+
let token = sign_token(&key, expiry);
1394+
1395+
assert_eq!(token_expiry(&token), Some(expiry));
1396+
// Still readable once expired -- that is the point of showing it.
1397+
assert!(matches!(
1398+
verify_token(&key, &token, expiry + Duration::seconds(1)),
1399+
TokenCheckResult::Expired(_)
1400+
));
1401+
assert_eq!(token_expiry(&token), Some(expiry));
1402+
}
1403+
1404+
/// [token_expiry] is deliberately unauthenticated, so it reads a token
1405+
/// signed by a stranger -- but it still refuses anything that is not a
1406+
/// well-formed token of a known version.
1407+
#[test]
1408+
fn token_expiry_rejects_unparseable_input() {
1409+
let expiry = OffsetDateTime::from_unix_timestamp(1_700_000_000).unwrap();
1410+
let foreign = sign_token(&Key::generate(), expiry);
1411+
assert_eq!(token_expiry(&foreign), Some(expiry));
1412+
1413+
assert_eq!(token_expiry(""), None);
1414+
assert_eq!(token_expiry("not base64!!"), None);
1415+
// Decodes cleanly but is shorter than the fixed header.
1416+
assert_eq!(token_expiry(&TOKEN_B64.encode([1u8, 2, 3])), None);
1417+
1418+
let mut bytes = TOKEN_B64.decode(&foreign).unwrap();
1419+
bytes[0] = bytes[0].wrapping_add(1);
1420+
assert_eq!(token_expiry(&TOKEN_B64.encode(bytes)), None);
1421+
}
1422+
13571423
#[test]
13581424
fn expiry_saturates_instead_of_panicking() {
13591425
let now = OffsetDateTime::from_unix_timestamp(1_700_000_000).unwrap();

0 commit comments

Comments
 (0)