Skip to main content

headless_lms_server/domain/oauth/
introspect_response.rs

1use serde::{Deserialize, Serialize};
2
3/// Response from the OAuth 2.0 token introspection endpoint (RFC 7662).
4///
5/// This response indicates whether a token is active and includes metadata
6/// about the token if it is active.
7///
8/// **This is a cross-repo wire contract.** tmc-server's
9/// `app/services/courses_mooc_fi_token_introspector.rb` reads `active`, `sub`, `scope`,
10/// `exp`, `iss`, `token_type`, `upstream_id` and `client_bearer_allowed` by name from this
11/// JSON. The handler declares its body as `serde_json::Value`, so the OpenAPI drift gate
12/// cannot see a rename here โ€” `tests::golden_serialized_shape` is the only thing that can.
13#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Default)]
14pub struct IntrospectResponse {
15    /// Whether the token is active (required).
16    pub active: bool,
17
18    /// Space-separated list of scopes (optional, only if active).
19    #[serde(skip_serializing_if = "Option::is_none")]
20    pub scope: Option<String>,
21
22    /// Client identifier (optional, only if active).
23    #[serde(skip_serializing_if = "Option::is_none")]
24    pub client_id: Option<String>,
25
26    /// Username/subject (optional, only if active and token has user).
27    #[serde(skip_serializing_if = "Option::is_none")]
28    pub username: Option<String>,
29
30    /// Expiration timestamp as Unix time (optional, only if active).
31    #[serde(skip_serializing_if = "Option::is_none")]
32    pub exp: Option<i64>,
33
34    /// Issued at timestamp as Unix time (optional, only if active).
35    #[serde(skip_serializing_if = "Option::is_none")]
36    pub iat: Option<i64>,
37
38    /// Subject identifier (optional, only if active and token has user).
39    #[serde(skip_serializing_if = "Option::is_none")]
40    pub sub: Option<String>,
41
42    /// Audience (optional, only if active).
43    #[serde(skip_serializing_if = "Option::is_none")]
44    pub aud: Option<Vec<String>>,
45
46    /// Issuer (optional, only if active).
47    #[serde(skip_serializing_if = "Option::is_none")]
48    pub iss: Option<String>,
49
50    /// JWT ID (optional, only if active).
51    #[serde(skip_serializing_if = "Option::is_none")]
52    pub jti: Option<String>,
53
54    /// Token type: "Bearer" or "DPoP" (optional, only if active).
55    #[serde(skip_serializing_if = "Option::is_none")]
56    pub token_type: Option<String>,
57
58    /// The token owner's legacy TMC `upstream_id`, when the token has a user and
59    /// that user has one. A non-standard claim consumed by tmc-server: it lets
60    /// tmc-server resolve a courses.mooc.fi token to a local user by upstream id
61    /// while the `courses_mooc_fi_user_id` backfill is still incomplete.
62    #[serde(skip_serializing_if = "Option::is_none")]
63    pub upstream_id: Option<i32>,
64
65    /// Whether the client the token was **issued to** (the one named by `client_id` in this
66    /// same response, not the introspecting caller) may present it as a plain Bearer
67    /// credential. Non-standard; it lets a resource server introspecting our tokens apply the
68    /// same `bearer_allowed = false` rejection
69    /// `domain::exercise_services::token::UserFromOAuthToken` applies here.
70    ///
71    /// Privileged, gated like `upstream_id`: disclosed only to a confidential caller, and
72    /// *omitted* rather than serialized as `false` when withheld, so a `false` is always an
73    /// authoritative denial.
74    ///
75    /// **Consumers must fail closed:** an absent member means "not disclosed" or "server
76    /// predates it", never "allowed", so treat it as not permitted and reject the token.
77    #[serde(skip_serializing_if = "Option::is_none")]
78    pub client_bearer_allowed: Option<bool>,
79}
80
81impl IntrospectResponse {
82    /// The RFC 7662 ยง2.2 minimal negative response: `{"active": false}` and nothing else.
83    /// Disclosing any metadata alongside `active: false` would leak token existence.
84    pub fn inactive() -> Self {
85        Self::default()
86    }
87}
88
89#[cfg(test)]
90mod tests {
91    use super::*;
92    use serde_json::json;
93
94    /// Golden shape test for the cross-repo wire contract (see the type's doc comment).
95    ///
96    /// Asserts on the serialized JSON rather than the struct, so a `#[serde(rename)]`, a
97    /// retyped member, or a dropped `skip_serializing_if` fails here. Equality is exact:
98    /// *adding* a member trips this too, which is deliberate โ€” the tmc-server fixture at
99    /// `spec/fixtures/courses_mooc_fi_introspection/` is a hand-mirrored copy of the active
100    /// response below and has to be updated in the same change.
101    #[test]
102    fn golden_serialized_shape() {
103        let active = IntrospectResponse {
104            active: true,
105            scope: Some("exercise-services".to_string()),
106            client_id: Some("tmc-server-introspection-dev".to_string()),
107            username: Some("11111111-2222-3333-4444-555555555555".to_string()),
108            exp: Some(1767225600),
109            iat: Some(1767222000),
110            sub: Some("11111111-2222-3333-4444-555555555555".to_string()),
111            // Every access token this server mints is created with `audience: None`, so `aud`
112            // is always absent in practice; tmc-server therefore cannot and does not verify it.
113            aud: None,
114            iss: Some("https://courses.mooc.fi/api/v0/main-frontend/oauth".to_string()),
115            jti: Some("123e4567-e89b-12d3-a456-426614174000".to_string()),
116            token_type: Some("Bearer".to_string()),
117            upstream_id: Some(42),
118            client_bearer_allowed: Some(true),
119        };
120
121        assert_eq!(
122            serde_json::to_value(&active).unwrap(),
123            json!({
124                "active": true,
125                "scope": "exercise-services",
126                "client_id": "tmc-server-introspection-dev",
127                "username": "11111111-2222-3333-4444-555555555555",
128                "exp": 1767225600,
129                "iat": 1767222000,
130                "sub": "11111111-2222-3333-4444-555555555555",
131                "iss": "https://courses.mooc.fi/api/v0/main-frontend/oauth",
132                "jti": "123e4567-e89b-12d3-a456-426614174000",
133                "token_type": "Bearer",
134                "upstream_id": 42,
135                "client_bearer_allowed": true
136            })
137        );
138
139        assert_eq!(
140            serde_json::to_value(IntrospectResponse::inactive()).unwrap(),
141            json!({ "active": false })
142        );
143
144        // `aud` is a JSON array of strings when populated, not a bare string.
145        assert_eq!(
146            serde_json::to_value(IntrospectResponse {
147                aud: Some(vec!["tmc-server".to_string()]),
148                ..IntrospectResponse::inactive()
149            })
150            .unwrap()["aud"],
151            json!(["tmc-server"])
152        );
153    }
154
155    /// The privileged members are omitted, never serialized as `false`/`null`, when withheld
156    /// from a non-confidential caller โ€” tmc-server fails closed on absence, so an emitted
157    /// `false` must always be an authoritative denial.
158    #[test]
159    fn withheld_privileged_members_are_omitted() {
160        let withheld = serde_json::to_value(IntrospectResponse {
161            active: true,
162            upstream_id: None,
163            client_bearer_allowed: None,
164            ..IntrospectResponse::inactive()
165        })
166        .unwrap();
167
168        assert!(withheld.get("client_bearer_allowed").is_none());
169        assert!(withheld.get("upstream_id").is_none());
170
171        let denied = serde_json::to_value(IntrospectResponse {
172            active: true,
173            client_bearer_allowed: Some(false),
174            ..IntrospectResponse::inactive()
175        })
176        .unwrap();
177        assert_eq!(denied["client_bearer_allowed"], json!(false));
178    }
179}