Skip to main content

manabrew_relay_protocol/
identity_token.rs

1//! The identity token is the only carrier of a session's name. The hub mints
2//! signed tokens (`iss` [`HUB_ISSUER`], EdDSA, verified by the relay against
3//! the hub's JWKS); a client that cannot reach the hub mints the same shape
4//! unsigned (`iss` [`SELF_ISSUER`], `alg` [`UNSIGNED_ALG`], empty signature)
5//! and the relay treats the name as unverified. Keep the claim shape in sync
6//! with `manabrew-hub/src/auth/token.rs`.
7
8use base64::engine::general_purpose::URL_SAFE_NO_PAD;
9use base64::Engine;
10use serde::{Deserialize, Serialize};
11
12pub const HUB_ISSUER: &str = "manabrew-hub";
13pub const SELF_ISSUER: &str = "manabrew-client";
14pub const RELAY_AUDIENCE: &str = "manabrew-relay";
15pub const SIGNED_ALG: &str = "EdDSA";
16pub const UNSIGNED_ALG: &str = "none";
17/// Keep in sync with `manabrew-hub/src/auth/token.rs::guest_subject` — hub
18/// guest tokens are signed like account tokens; only this namespace tells
19/// them apart.
20pub const GUEST_SUBJECT_PREFIX: &str = "guest:";
21
22#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct IdentityTokenHeader {
24    pub alg: String,
25    #[serde(default, skip_serializing_if = "Option::is_none")]
26    pub kid: Option<String>,
27}
28
29#[derive(Debug, Clone, Serialize, Deserialize)]
30pub struct IdentityTokenClaims {
31    pub sub: String,
32    #[serde(default)]
33    pub handle: String,
34    #[serde(default, skip_serializing_if = "Option::is_none")]
35    pub qualification: Option<String>,
36    pub iss: String,
37    pub aud: String,
38    pub iat: i64,
39    pub exp: i64,
40}
41
42#[derive(Debug, Clone)]
43pub struct DecodedIdentityToken {
44    pub header: IdentityTokenHeader,
45    pub claims: IdentityTokenClaims,
46    pub signing_input: String,
47    pub signature: Vec<u8>,
48}
49
50impl DecodedIdentityToken {
51    pub fn is_unsigned(&self) -> bool {
52        self.header.alg == UNSIGNED_ALG && self.signature.is_empty()
53    }
54}
55
56pub fn decode(token: &str) -> Option<DecodedIdentityToken> {
57    let mut parts = token.split('.');
58    let encoded_header = parts.next()?;
59    let encoded_claims = parts.next()?;
60    let encoded_signature = parts.next()?;
61    if parts.next().is_some() {
62        return None;
63    }
64
65    let header: IdentityTokenHeader =
66        serde_json::from_slice(&URL_SAFE_NO_PAD.decode(encoded_header).ok()?).ok()?;
67    let claims: IdentityTokenClaims =
68        serde_json::from_slice(&URL_SAFE_NO_PAD.decode(encoded_claims).ok()?).ok()?;
69    let signature = if encoded_signature.is_empty() {
70        Vec::new()
71    } else {
72        URL_SAFE_NO_PAD.decode(encoded_signature).ok()?
73    };
74
75    Some(DecodedIdentityToken {
76        header,
77        claims,
78        signing_input: format!("{encoded_header}.{encoded_claims}"),
79        signature,
80    })
81}
82
83pub fn mint_unsigned(subject: &str, handle: &str, iat: i64, ttl_s: i64) -> String {
84    let header = IdentityTokenHeader {
85        alg: UNSIGNED_ALG.to_string(),
86        kid: None,
87    };
88    let claims = IdentityTokenClaims {
89        sub: subject.to_string(),
90        handle: handle.to_string(),
91        qualification: None,
92        iss: SELF_ISSUER.to_string(),
93        aud: RELAY_AUDIENCE.to_string(),
94        iat,
95        exp: iat + ttl_s,
96    };
97    let encoded_header = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&header).expect("serialize"));
98    let encoded_claims = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&claims).expect("serialize"));
99    format!("{encoded_header}.{encoded_claims}.")
100}