Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/holger

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));
    }
}