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