server/lib/src/repo_token.rs
//! **Repository tokens** — the credential a package client carries (lane P3, 2026-10-21).
//!
//! A person signs in with a passkey. `cargo`, `pip`, `npm` and `mvn` cannot: they send one
//! secret on every request, in the spelling their ecosystem fixed long ago. A repository
//! token is that secret.
//!
//! * **Minted by a signed-in person** (a passkey session, stepped up in the last ten
//! minutes, the rule an invite follows), for one or more repositories, read or read and
//! write, with an expiry (1 to 366 days). It is refused at mint when its owner could not do
//! the same thing themselves: read needs read on every repository named, write needs write.
//! * **Kept as a hash only.** The secret is `holger_rt_` and 43 characters of base64url
//! (256 bits); the store holds `blake3(secret)` and nothing else, so a stolen store mints
//! nothing. The prefix is what makes a leaked token findable by a secret scanner.
//! * **Acts as its owner and never as more.** Each use is decided again: the owner must still
//! hold an enabled passkey, and the request is then authorized as the owner, with the
//! groups they hold NOW, by the same gates every other credential passes. The token only
//! narrows that: to its repositories, and to reads unless it was minted for writing.
//! * **Revocable and listed.** A revoked token stays in the store, marked, so the audit still
//! names it and its holder is told why it stopped (`holger-token-1`), as an expired one is
//! (`holger-token-2`). An unknown secret gets the plain 401 a forged one gets.
//! * **For package doors only.** [`crate::auth::validate_request`], the gate every other door
//! calls, refuses a repository token outright; only the repository door's
//! [`crate::auth::validate_request_for`] takes one, because only it knows the repository and
//! whether the request writes. So a token never reaches an admin door, the passkey door,
//! or the door that mints tokens.
//!
//! The store is `repository-tokens.json` beside the passkey store (`<data_root>/holger-
//! passkeys/`), which the twin already carries and a recharge already restores.
//!
//! Wire: gRPC `holger.token.v1.TokenService` (Mint, List, Revoke), the console's door; the
//! operator's CLI is `holger-server token` over the same service.
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use base64::Engine as _;
use serde::{Deserialize, Serialize};
use tonic::{Request, Response, Status};
use crate::auth::{AuthConfig, AuthIdentity};
use crate::grpc::holger_token_proto as pb;
use crate::grpc::HolgerGrpc;
use crate::lock::LockOrRecover as _;
/// Every repository token starts with this, so a leaked one is findable.
pub const PREFIX: &str = "holger_rt_";
/// The `AuthIdentity::method` a token's use resolves to.
pub const METHOD: &str = "repository-token";
/// The store's file, beside the passkey store.
pub const FILE: &str = "repository-tokens.json";
/// The longest a token may live, in days.
pub const MAX_DAYS: u32 = 366;
/// The life a mint gets when it names none, in days.
pub const DEFAULT_DAYS: u32 = 90;
/// The most live tokens one person may hold (a mint loop is a store that grows forever).
pub const MAX_PER_OWNER: usize = 50;
/// The longest label.
pub const MAX_LABEL: usize = 64;
/// `last_used` is written at most this often per token, so a busy client is not a write
/// per request.
const TOUCH_EVERY_SECS: i64 = 60;
const B64: base64::engine::general_purpose::GeneralPurpose =
base64::engine::general_purpose::URL_SAFE_NO_PAD;
/// Pure: **does this credential claim to be a repository token?**
pub fn is_token(credential: &str) -> bool {
credential.starts_with(PREFIX)
}
/// Pure: the stored form of a secret — `blake3(secret)`, base64url. Also the token's id.
pub fn hash_of(secret: &str) -> String {
B64.encode(blake3::hash(secret.as_bytes()).as_bytes())
}
/// What a token may do with its repositories.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Scope {
Read,
ReadWrite,
}
impl Scope {
pub fn writes(self) -> bool {
self == Scope::ReadWrite
}
fn wire(self) -> pb::TokenScope {
match self {
Scope::Read => pb::TokenScope::Read,
Scope::ReadWrite => pb::TokenScope::ReadWrite,
}
}
}
/// Who revoked a token, and when.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Revoked {
pub at: i64,
pub by: String,
}
/// One stored token. There is no secret in it.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RepoToken {
/// [`hash_of`] the secret: the lookup key and the public id.
pub id: String,
pub owner: String,
pub owner_display: String,
pub label: String,
pub repositories: Vec<String>,
pub scope: Scope,
pub created: i64,
pub expires: i64,
#[serde(default)]
pub last_used: Option<i64>,
#[serde(default)]
pub revoked: Option<Revoked>,
}
/// Where a token stands at a moment.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum State {
Live,
Expired,
Revoked,
}
impl State {
pub fn word(self) -> &'static str {
match self {
State::Live => "live",
State::Expired => "expired",
State::Revoked => "revoked",
}
}
}
impl RepoToken {
pub fn state(&self, now: i64) -> State {
if self.revoked.is_some() {
State::Revoked
} else if now >= self.expires {
State::Expired
} else {
State::Live
}
}
fn row(&self, now: i64) -> pb::TokenRow {
pb::TokenRow {
id: self.id.clone(),
label: self.label.clone(),
owner: self.owner.clone(),
owner_display: self.owner_display.clone(),
repositories: self.repositories.clone(),
scope: self.scope.wire() as i32,
created: self.created,
expires: self.expires,
last_used: self.last_used.unwrap_or(0),
state: self.state(now).word().to_owned(),
revoked_at: self.revoked.as_ref().map(|r| r.at).unwrap_or(0),
revoked_by: self
.revoked
.as_ref()
.map(|r| r.by.clone())
.unwrap_or_default(),
}
}
}
/// **A refusal with its code**: the HTTP status, the frozen code, and the words, which never
/// name another person, another repository or what the token is for.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Refused {
pub status: u16,
pub code: &'static holger_errcode::ErrCode,
pub words: String,
}
impl Refused {
fn new(status: u16, code: &'static holger_errcode::ErrCode, words: impl Into<String>) -> Self {
Self {
status,
code,
words: words.into(),
}
}
/// The one line a client is shown: `[holger-token-<n>] words`.
pub fn line(&self) -> String {
self.code.tag(&self.words)
}
fn grpc(&self) -> Status {
let code = match self.status {
400 => tonic::Code::InvalidArgument,
401 => tonic::Code::Unauthenticated,
403 => tonic::Code::PermissionDenied,
404 => tonic::Code::NotFound,
409 | 429 => tonic::Code::ResourceExhausted,
_ => tonic::Code::Internal,
};
crate::grpc::coded(self.code, code, &self.words)
}
}
fn revoked() -> Refused {
Refused::new(
401,
&holger_errcode::TOKEN_REVOKED,
"this repository token was revoked; mint a new one",
)
}
fn expired() -> Refused {
Refused::new(
401,
&holger_errcode::TOKEN_EXPIRED,
"this repository token has expired; mint a new one",
)
}
fn owner_gone() -> Refused {
Refused::new(401, &holger_errcode::TOKEN_OWNER_GONE, "the person this repository token belongs to holds no enabled passkey on this server any more")
}
fn out_of_scope() -> Refused {
Refused::new(
403,
&holger_errcode::TOKEN_OUT_OF_SCOPE,
"this repository token is not for this repository",
)
}
fn read_only() -> Refused {
Refused::new(
403,
&holger_errcode::TOKEN_READ_ONLY,
"this repository token may only read",
)
}
fn invalid(words: impl Into<String>) -> Refused {
Refused::new(400, &holger_errcode::TOKEN_MINT_INVALID, words)
}
/// What a mint answers: the secret, once, and the stored row.
#[derive(Debug, Clone)]
pub struct Minted {
pub secret: String,
pub token: RepoToken,
}
/// What a person asks for at mint.
#[derive(Debug, Clone)]
pub struct Ask {
pub label: String,
pub repositories: Vec<String>,
pub scope: Scope,
/// 0: [`DEFAULT_DAYS`].
pub days: u32,
}
/// The token store: one JSON file, written atomically, under one lock.
#[derive(Debug)]
pub struct TokenStore {
path: PathBuf,
lock: Mutex<()>,
}
impl TokenStore {
/// The store in `dir` (the passkey store's directory).
pub fn in_dir(dir: &Path) -> Self {
Self {
path: dir.join(FILE),
lock: Mutex::new(()),
}
}
fn load(&self) -> Vec<RepoToken> {
std::fs::read(&self.path)
.ok()
.and_then(|b| serde_json::from_slice(&b).ok())
.unwrap_or_default()
}
fn save(&self, tokens: &[RepoToken]) -> Result<(), Refused> {
let fail = |e: String| Refused::new(500, &holger_errcode::TOKEN_STORE, e);
if let Some(dir) = self.path.parent() {
std::fs::create_dir_all(dir)
.map_err(|e| fail(format!("cannot create the token store: {e}")))?;
}
let bytes = serde_json::to_vec_pretty(tokens)
.map_err(|e| fail(format!("cannot serialize the token store: {e}")))?;
let tmp = self.path.with_extension("tmp");
let _ = tmp;
// HOL-NCSC: the store of token hashes is written 0600 (temporary + rename).
crate::keyfile::write_private(&self.path, &bytes)
.map_err(|e| fail(format!("cannot write the token store: {e}")))
}
/// Every token, live or not.
pub fn all(&self) -> Vec<RepoToken> {
self.load()
}
/// The stored row a secret names, if any.
pub fn find(&self, secret: &str) -> Option<RepoToken> {
let id = hash_of(secret);
self.load().into_iter().find(|t| t.id == id)
}
/// **Mint** for `owner`: the request is checked (repositories, label, days, the owner's
/// count); the caller has already checked that the owner may do what is asked.
pub fn mint(
&self,
owner: &str,
owner_display: &str,
ask: &Ask,
now: i64,
) -> Result<Minted, Refused> {
let label = ask.label.trim();
if label.chars().count() > MAX_LABEL {
return Err(invalid(format!(
"a label is at most {MAX_LABEL} characters"
)));
}
let mut repositories: Vec<String> = ask
.repositories
.iter()
.map(|r| r.trim().to_owned())
.filter(|r| !r.is_empty())
.collect();
repositories.sort();
repositories.dedup();
if repositories.is_empty() {
return Err(invalid("a repository token names at least one repository"));
}
let days = if ask.days == 0 {
DEFAULT_DAYS
} else {
ask.days
};
if days > MAX_DAYS {
return Err(invalid(format!(
"a repository token lives at most {MAX_DAYS} days"
)));
}
let mut raw = [0u8; 32];
getrandom::fill(&mut raw).map_err(|e| {
Refused::new(
500,
&holger_errcode::TOKEN_STORE,
format!("no randomness for the token: {e}"),
)
})?;
let secret = format!("{PREFIX}{}", B64.encode(raw));
let token = RepoToken {
id: hash_of(&secret),
owner: owner.to_owned(),
owner_display: owner_display.to_owned(),
label: label.to_owned(),
repositories,
scope: ask.scope,
created: now,
expires: now + i64::from(days) * 86_400,
last_used: None,
revoked: None,
};
let _held = self.lock.lock_or_recover();
let mut all = self.load();
if all
.iter()
.filter(|t| t.owner == owner && t.state(now) == State::Live)
.count()
>= MAX_PER_OWNER
{
return Err(Refused::new(
429,
&holger_errcode::TOKEN_MINT_INVALID,
format!("you hold {MAX_PER_OWNER} live repository tokens; revoke one first"),
));
}
all.push(token.clone());
self.save(&all)?;
Ok(Minted { secret, token })
}
/// **Revoke** `id` as `by`. `may` decides whether `by` may revoke that token; a token
/// `by` may not touch is answered exactly as one that does not exist.
pub fn revoke(
&self,
id: &str,
by: &str,
may: impl Fn(&RepoToken) -> bool,
now: i64,
) -> Result<RepoToken, Refused> {
let _held = self.lock.lock_or_recover();
let mut all = self.load();
let Some(t) = all.iter_mut().find(|t| t.id == id && may(t)) else {
return Err(Refused::new(
404,
&holger_errcode::TOKEN_NOT_FOUND,
"no such repository token",
));
};
if t.revoked.is_none() {
t.revoked = Some(Revoked {
at: now,
by: by.to_owned(),
});
}
let out = t.clone();
self.save(&all)?;
Ok(out)
}
/// Record a use, at most once a minute per token.
pub fn touch(&self, id: &str, now: i64) {
let _held = self.lock.lock_or_recover();
let mut all = self.load();
let Some(t) = all.iter_mut().find(|t| t.id == id) else {
return;
};
if t.last_used.is_some_and(|at| now - at < TOUCH_EVERY_SECS) {
return;
}
t.last_used = Some(now);
if let Err(e) = self.save(&all) {
log::warn!("repository token: last use not recorded: {}", e.words);
}
}
/// Pure over the store: **the token `secret` names, for `repo`, writing or not, at
/// `now`** — before the owner is asked about. `Ok(None)`: no such token.
pub fn check(
&self,
secret: &str,
repo: &str,
write: bool,
now: i64,
) -> Result<Option<RepoToken>, Refused> {
let Some(t) = self.find(secret) else {
return Ok(None);
};
match t.state(now) {
State::Revoked => return Err(revoked()),
State::Expired => return Err(expired()),
State::Live => {}
}
if !t.repositories.iter().any(|r| r == repo) {
return Err(out_of_scope());
}
if write && !t.scope.writes() {
return Err(read_only());
}
Ok(Some(t))
}
}
/// The token store beside the passkey store `auth` names, if this process holds one.
pub fn store_for(
auth: &AuthConfig,
) -> Option<(crate::passkey::SharedPasskeyDoor, Arc<TokenStore>)> {
let passkeys = crate::passkey::door_for(auth)?;
let tokens = stores_by_dir(&passkeys.root());
Some((passkeys, tokens))
}
/// One [`TokenStore`] per directory, for the life of the process, so its lock is one lock.
fn stores_by_dir(dir: &Path) -> Arc<TokenStore> {
static ALL: std::sync::OnceLock<Mutex<std::collections::HashMap<PathBuf, Arc<TokenStore>>>> =
std::sync::OnceLock::new();
ALL.get_or_init(Default::default)
.lock_or_recover()
.entry(dir.to_path_buf())
.or_insert_with(|| Arc::new(TokenStore::in_dir(dir)))
.clone()
}
/// **The person `subject` as this server knows them now**: their enabled passkeys' groups and
/// name. `None` when they hold no enabled passkey.
pub fn owner_now(passkeys: &crate::passkey::PasskeyDoor, subject: &str) -> Option<AuthIdentity> {
let me = passkeys.person(subject)?;
Some(AuthIdentity {
method: METHOD.to_owned(),
..me
})
}
/// **A repository token, decided for one request** to `repo` (writing or not): the identity it
/// acts as — its owner, now — or the refusal. `Ok(None)`: not a token this server minted (the
/// caller answers the plain 401).
pub fn resolve(
auth: &AuthConfig,
secret: &str,
repo: &str,
write: bool,
) -> Result<Option<AuthIdentity>, Refused> {
let Some((passkeys, tokens)) = store_for(auth) else {
return Ok(None);
};
let now = crate::clock::now_unix_secs() as i64;
let Some(t) = tokens.check(secret, repo, write, now)? else {
return Ok(None);
};
let Some(owner) = owner_now(&passkeys, &t.owner) else {
return Err(owner_gone());
};
tokens.touch(&t.id, now);
Ok(Some(owner))
}
/// May `owner` themselves do `scope` on `repo`? The gates the repository door asks of any
/// credential, asked of the owner.
pub fn owner_may(g: &HolgerGrpc, owner: &AuthIdentity, repo: &str, scope: Scope) -> bool {
let cfg = &g.auth_config;
if !cfg.rbac_enabled() {
return true;
}
let role = cfg.role_for_identity_in_repo(repo, owner);
if !g.read_gate.clears_floor(repo, role) {
return false;
}
// An owner-only repository (lane P1) admits its admins, its owner and the people granted,
// whatever their role elsewhere: the same two predicates the repository door asks.
if scope.writes() {
g.read_gate.write_admits(repo, owner, role)
} else {
role.can_read() && g.read_gate.owner_admits(repo, owner, role)
}
}
fn now() -> i64 {
crate::clock::now_unix_secs() as i64
}
/// The bearer a gRPC request carries.
fn bearer_of<T>(request: &Request<T>) -> Option<String> {
request
.metadata()
.get("authorization")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
.map(str::to_owned)
}
impl HolgerGrpc {
fn token_audit(
&self,
who: &str,
repos: &str,
id: &str,
source: &str,
status: u16,
detail: String,
) {
let ev = crate::audit::AuditEvent::new(
who,
crate::audit::AuditAction::Token,
repos,
id,
source,
status,
0,
)
.with_detail(detail);
self.record_audit(ev);
}
fn is_admin(&self, id: &AuthIdentity) -> bool {
!self.auth_config.rbac_enabled()
|| self
.auth_config
.role_for_identity_in_repo("", id)
.can_admin()
}
}
#[tonic::async_trait]
impl pb::token_service_server::TokenService for Arc<HolgerGrpc> {
async fn mint(
&self,
request: Request<pb::MintTokenRequest>,
) -> Result<Response<pb::MintTokenReply>, Status> {
let source = self.audit_peer(&request);
let identity = self
.authenticate(&request, "mint a repository token")
.await?;
let person = identity.filter(|i| i.method == "passkey");
let (Some(person), Some((passkeys, tokens))) = (person, store_for(&self.auth_config))
else {
return Err(Refused::new(
403,
&holger_errcode::TOKEN_MINT_PERSON,
"a repository token is minted by a person signed in with a passkey",
)
.grpc());
};
if !passkeys.caller_stepped_up(&person, bearer_of(&request).as_deref()) {
return Err(Refused::new(403, &holger_errcode::TOKEN_MINT_STEP_UP, "minting a repository token needs a fresh touch of your key (a user-verified login from the last 10 minutes)").grpc());
}
let m = request.get_ref();
let scope = match pb::TokenScope::try_from(m.scope).unwrap_or(pb::TokenScope::Unspecified) {
pb::TokenScope::ReadWrite => Scope::ReadWrite,
_ => Scope::Read,
};
let ask = Ask {
label: m.label.clone(),
repositories: m.repositories.clone(),
scope,
days: m.days,
};
// A token never grants more than its owner holds: every repository is one this
// server serves and one the person may use this way themselves. Both answers are
// the same refusal, so a mint cannot probe for a repository's existence.
for repo in ask
.repositories
.iter()
.map(|r| r.trim())
.filter(|r| !r.is_empty())
{
if self.routes.lookup(repo).is_none() || !owner_may(self, &person, repo, scope) {
self.token_audit(
&person.subject,
repo,
"",
&source,
403,
"repository token refused: beyond the owner's duties".into(),
);
return Err(Refused::new(
403,
&holger_errcode::TOKEN_MINT_BEYOND_OWNER,
format!(
"you may not {} '{repo}' yourself, so no token of yours may",
if scope.writes() { "write" } else { "read" }
),
)
.grpc());
}
}
let display = person
.display
.clone()
.unwrap_or_else(|| person.subject.clone());
let minted = tokens
.mint(&person.subject, &display, &ask, now())
.map_err(|r| r.grpc())?;
let t = &minted.token;
self.token_audit(
&person.subject,
&t.repositories.join(","),
&t.id,
&source,
200,
format!(
"repository token minted: label={:?} scope={:?} expires={}",
t.label, t.scope, t.expires
),
);
Ok(Response::new(pb::MintTokenReply {
secret: minted.secret,
token: Some(t.row(now())),
words: "the token is shown once: copy it now; Holger keeps only its hash".to_owned(),
}))
}
async fn list(
&self,
request: Request<pb::ListTokensRequest>,
) -> Result<Response<pb::ListTokensReply>, Status> {
let identity = self
.authenticate(&request, "list repository tokens")
.await?;
let Some(who) = identity else {
return Err(crate::grpc::coded(
&holger_errcode::AUTH_UNAUTHENTICATED,
tonic::Code::Unauthenticated,
"valid credentials required to list repository tokens",
));
};
let Some((_, tokens)) = store_for(&self.auth_config) else {
return Ok(Response::new(pb::ListTokensReply { tokens: vec![] }));
};
let admin = self.is_admin(&who);
let at = now();
let rows = tokens
.all()
.iter()
.filter(|t| admin || t.owner == who.subject)
.map(|t| t.row(at))
.collect();
Ok(Response::new(pb::ListTokensReply { tokens: rows }))
}
async fn revoke(
&self,
request: Request<pb::RevokeTokenRequest>,
) -> Result<Response<pb::RevokeTokenReply>, Status> {
let source = self.audit_peer(&request);
let identity = self
.authenticate(&request, "revoke a repository token")
.await?;
let Some(who) = identity else {
return Err(crate::grpc::coded(
&holger_errcode::AUTH_UNAUTHENTICATED,
tonic::Code::Unauthenticated,
"valid credentials required to revoke a repository token",
));
};
let Some((_, tokens)) = store_for(&self.auth_config) else {
return Err(Refused::new(
404,
&holger_errcode::TOKEN_NOT_FOUND,
"no such repository token",
)
.grpc());
};
let admin = self.is_admin(&who);
let id = request.get_ref().id.trim().to_owned();
let t = tokens
.revoke(
&id,
&who.subject,
|t| admin || t.owner == who.subject,
now(),
)
.map_err(|r| r.grpc())?;
self.token_audit(
&who.subject,
&t.repositories.join(","),
&t.id,
&source,
200,
format!(
"repository token revoked: label={:?} owner={}",
t.label, t.owner
),
);
Ok(Response::new(pb::RevokeTokenReply {
token: Some(t.row(now())),
}))
}
}
/// **The operator's side of the door**: `TokenService` over gRPC with the operator's own
/// credential (`holger-server token`). A refusal is its tagged message.
pub mod client {
use super::pb;
use pb::token_service_client::TokenServiceClient;
fn with_bearer<T>(body: T, bearer: &str) -> Result<tonic::Request<T>, String> {
let mut r = tonic::Request::new(body);
r.metadata_mut().insert(
"authorization",
format!("Bearer {bearer}")
.parse()
.map_err(|_| "the credential is not a header value".to_string())?,
);
Ok(r)
}
async fn connect(
server: &str,
) -> Result<TokenServiceClient<tonic::transport::Channel>, String> {
TokenServiceClient::connect(crate::twin_follow::endpoint_of(server))
.await
.map_err(|e| format!("holger's gRPC door at {server} was not reached: {e}"))
}
/// `TokenService.Mint`.
pub async fn mint(
server: &str,
bearer: &str,
req: pb::MintTokenRequest,
) -> Result<pb::MintTokenReply, String> {
connect(server)
.await?
.mint(with_bearer(req, bearer)?)
.await
.map(|r| r.into_inner())
.map_err(|s| s.message().to_string())
}
/// `TokenService.List`.
pub async fn list(server: &str, bearer: &str) -> Result<pb::ListTokensReply, String> {
connect(server)
.await?
.list(with_bearer(pb::ListTokensRequest {}, bearer)?)
.await
.map(|r| r.into_inner())
.map_err(|s| s.message().to_string())
}
/// `TokenService.Revoke`.
pub async fn revoke(
server: &str,
bearer: &str,
id: &str,
) -> Result<pb::RevokeTokenReply, String> {
connect(server)
.await?
.revoke(with_bearer(
pb::RevokeTokenRequest { id: id.to_owned() },
bearer,
)?)
.await
.map(|r| r.into_inner())
.map_err(|s| s.message().to_string())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn ask(repos: &[&str], scope: Scope, days: u32) -> Ask {
Ask {
label: "laptop".into(),
repositories: repos.iter().map(|s| s.to_string()).collect(),
scope,
days,
}
}
#[test]
fn the_store_keeps_only_the_hash_and_the_secret_is_prefixed() {
let dir = tempfile::tempdir().unwrap();
let s = TokenStore::in_dir(dir.path());
let m = s
.mint(
"passkey:a",
"anna",
&ask(&["crates"], Scope::Read, 0),
1_000,
)
.unwrap();
assert!(
m.secret.starts_with(PREFIX) && m.secret.len() == PREFIX.len() + 43,
"256 bits after the prefix"
);
let on_disk = std::fs::read_to_string(dir.path().join(FILE)).unwrap();
assert!(
!on_disk.contains(&m.secret) && !on_disk.contains(&m.secret[PREFIX.len()..]),
"no secret on disk"
);
assert!(on_disk.contains(&hash_of(&m.secret)));
assert_eq!(m.token.expires, 1_000 + 90 * 86_400, "the default life");
}
#[test]
fn a_token_is_held_to_its_repositories_its_scope_and_its_life() {
let dir = tempfile::tempdir().unwrap();
let s = TokenStore::in_dir(dir.path());
let r = s
.mint(
"passkey:a",
"anna",
&ask(&["crates", "pypi"], Scope::Read, 1),
1_000,
)
.unwrap();
assert!(s
.check(&r.secret, "crates", false, 1_001)
.unwrap()
.is_some());
assert_eq!(
s.check(&r.secret, "npm", false, 1_001).unwrap_err().code,
&holger_errcode::TOKEN_OUT_OF_SCOPE
);
assert_eq!(
s.check(&r.secret, "crates", true, 1_001).unwrap_err().code,
&holger_errcode::TOKEN_READ_ONLY
);
assert_eq!(
s.check(&r.secret, "crates", false, 1_000 + 86_400)
.unwrap_err()
.code,
&holger_errcode::TOKEN_EXPIRED
);
assert!(
s.check("holger_rt_not-a-token", "crates", false, 1_001)
.unwrap()
.is_none(),
"unknown: the plain 401"
);
let w = s
.mint(
"passkey:a",
"anna",
&ask(&["crates"], Scope::ReadWrite, 30),
1_000,
)
.unwrap();
assert!(s.check(&w.secret, "crates", true, 1_001).unwrap().is_some());
}
#[test]
fn a_revoked_token_is_refused_by_its_own_code_and_stays_listed() {
let dir = tempfile::tempdir().unwrap();
let s = TokenStore::in_dir(dir.path());
let m = s
.mint(
"passkey:a",
"anna",
&ask(&["crates"], Scope::Read, 0),
1_000,
)
.unwrap();
let nobody = s
.revoke(&m.token.id, "passkey:b", |t| t.owner == "passkey:b", 1_001)
.unwrap_err();
assert_eq!(
(nobody.status, nobody.code),
(404, &holger_errcode::TOKEN_NOT_FOUND),
"another person's token is not there for them"
);
let t = s
.revoke(&m.token.id, "passkey:a", |t| t.owner == "passkey:a", 1_002)
.unwrap();
assert_eq!(t.state(1_003), State::Revoked);
let r = s.check(&m.secret, "crates", false, 1_003).unwrap_err();
assert_eq!((r.status, r.code), (401, &holger_errcode::TOKEN_REVOKED));
assert!(r.line().starts_with("[holger-token-1] "), "{}", r.line());
assert_eq!(s.all().len(), 1, "kept for the audit");
}
#[test]
fn a_mint_names_a_repository_lives_at_most_a_year_and_has_a_short_label() {
let dir = tempfile::tempdir().unwrap();
let s = TokenStore::in_dir(dir.path());
for bad in [
ask(&[], Scope::Read, 0),
ask(&[" "], Scope::Read, 0),
ask(&["crates"], Scope::Read, MAX_DAYS + 1),
] {
assert_eq!(
s.mint("passkey:a", "anna", &bad, 1).unwrap_err().code,
&holger_errcode::TOKEN_MINT_INVALID
);
}
let mut long = ask(&["crates"], Scope::Read, 0);
long.label = "x".repeat(MAX_LABEL + 1);
assert_eq!(
s.mint("passkey:a", "anna", &long, 1).unwrap_err().status,
400
);
}
#[test]
fn last_use_is_written_at_most_once_a_minute() {
let dir = tempfile::tempdir().unwrap();
let s = TokenStore::in_dir(dir.path());
let m = s
.mint(
"passkey:a",
"anna",
&ask(&["crates"], Scope::Read, 0),
1_000,
)
.unwrap();
s.touch(&m.token.id, 2_000);
s.touch(&m.token.id, 2_030);
assert_eq!(s.all()[0].last_used, Some(2_000));
s.touch(&m.token.id, 2_061);
assert_eq!(s.all()[0].last_used, Some(2_061));
}
}