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
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct IdentityTokenHeader {
20    pub alg: String,
21    #[serde(default, skip_serializing_if = "Option::is_none")]
22    pub kid: Option<String>,
23}
24
25#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct IdentityTokenClaims {
27    pub sub: String,
28    #[serde(default)]
29    pub handle: String,
30    pub iss: String,
31    pub aud: String,
32    pub iat: i64,
33    pub exp: i64,
34}
35
36#[derive(Debug, Clone)]
37pub struct DecodedIdentityToken {
38    pub header: IdentityTokenHeader,
39    pub claims: IdentityTokenClaims,
40    pub signing_input: String,
41    pub signature: Vec<u8>,
42}
43
44impl DecodedIdentityToken {
45    pub fn is_unsigned(&self) -> bool {
46        self.header.alg == UNSIGNED_ALG && self.signature.is_empty()
47    }
48}
49
50pub fn decode(token: &str) -> Option<DecodedIdentityToken> {
51    let mut parts = token.split('.');
52    let encoded_header = parts.next()?;
53    let encoded_claims = parts.next()?;
54    let encoded_signature = parts.next()?;
55    if parts.next().is_some() {
56        return None;
57    }
58
59    let header: IdentityTokenHeader =
60        serde_json::from_slice(&URL_SAFE_NO_PAD.decode(encoded_header).ok()?).ok()?;
61    let claims: IdentityTokenClaims =
62        serde_json::from_slice(&URL_SAFE_NO_PAD.decode(encoded_claims).ok()?).ok()?;
63    let signature = if encoded_signature.is_empty() {
64        Vec::new()
65    } else {
66        URL_SAFE_NO_PAD.decode(encoded_signature).ok()?
67    };
68
69    Some(DecodedIdentityToken {
70        header,
71        claims,
72        signing_input: format!("{encoded_header}.{encoded_claims}"),
73        signature,
74    })
75}
76
77pub fn mint_unsigned(subject: &str, handle: &str, iat: i64, ttl_s: i64) -> String {
78    let header = IdentityTokenHeader {
79        alg: UNSIGNED_ALG.to_string(),
80        kid: None,
81    };
82    let claims = IdentityTokenClaims {
83        sub: subject.to_string(),
84        handle: handle.to_string(),
85        iss: SELF_ISSUER.to_string(),
86        aud: RELAY_AUDIENCE.to_string(),
87        iat,
88        exp: iat + ttl_s,
89    };
90    let encoded_header = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&header).expect("serialize"));
91    let encoded_claims = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&claims).expect("serialize"));
92    format!("{encoded_header}.{encoded_claims}.")
93}