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}