headless_lms_server/programs/seed/
seed_oauth_clients.rs1use std::str::FromStr;
2
3use headless_lms_models::{
4 library::oauth::{Digest, GrantTypeName, pkce},
5 oauth_client,
6};
7use sqlx::{Pool, Postgres};
8use uuid::Uuid;
9
10pub struct SeedOAuthClientsResult {
11 pub client_db_id: Uuid,
12}
13
14#[cfg_attr(not(test), allow(dead_code))]
21const DEV_OAUTH_TOKEN_HMAC_KEY: &str = "pippuri";
22
23const TEST_CLIENT_SECRET_DIGEST_HEX: &str =
26 "396b544a35b29f7d613452a165dcaebf4d71b80e981e687e91ce6d9ba9679cb2";
27
28const INTROSPECTION_SECRET_DIGEST_HEX: &str =
32 "aca61813af4f1b77f72cc2db856aa9ff4ea4080c188359b1edc51393c824abd5";
33
34pub async fn seed_oauth_clients(db_pool: Pool<Postgres>) -> anyhow::Result<SeedOAuthClientsResult> {
35 info!("Inserting OAuth Clients");
36 let secret = Digest::from_str(TEST_CLIENT_SECRET_DIGEST_HEX).unwrap(); let mut conn = db_pool.acquire().await?;
38 let mut redirect_uris: Vec<String> = (8765..=8784)
41 .map(|p| format!("http://127.0.0.1:{p}/callback"))
42 .collect();
43 redirect_uris.push("https://localhost.emobix.co.uk:8443/test/a/testing/callback".to_string());
44
45 let scopes = vec![
46 "openid".to_string(),
47 "profile".to_string(),
48 "email".to_string(),
49 "offline_access".to_string(),
50 ];
51 let allowed_grant_types = vec![
52 GrantTypeName::AuthorizationCode,
53 GrantTypeName::RefreshToken,
54 ];
55 let pkce_methods_allowed = vec![pkce::PkceMethod::S256];
56 let allowed_origins = vec!["http://localhost".to_string()];
57
58 let new_client_parms = oauth_client::NewClientParams {
59 client_name: "Test Client",
60 application_type: oauth_client::ApplicationType::Web,
61 client_id: "test-client-id",
62 client_secret: Some(&secret), client_secret_expires_at: None,
64 redirect_uris: redirect_uris.as_slice(),
65 allowed_grant_types: &allowed_grant_types,
66 scopes: scopes.as_slice(),
67 allowed_origins: Some(allowed_origins.as_slice()),
68 bearer_allowed: true,
69 pkce_methods_allowed: &pkce_methods_allowed,
70 post_logout_redirect_uris: None,
71 require_pkce: true,
72 token_endpoint_auth_method: oauth_client::TokenEndpointAuthMethod::ClientSecretPost,
73 };
74
75 let client = if let Some(existing) =
76 oauth_client::OAuthClient::find_by_client_id_optional(&mut conn, "test-client-id").await?
77 {
78 existing
79 } else {
80 oauth_client::OAuthClient::insert(&mut conn, new_client_parms).await?
81 };
82
83 let new_client_parms_2 = oauth_client::NewClientParams {
84 client_name: "Test Client 2",
85 application_type: oauth_client::ApplicationType::Web,
86 client_id: "test-client-id-2",
87 client_secret: Some(&secret), client_secret_expires_at: None,
89 redirect_uris: redirect_uris.as_slice(),
90 allowed_grant_types: &allowed_grant_types,
91 scopes: scopes.as_slice(),
92 allowed_origins: Some(allowed_origins.as_slice()),
93 bearer_allowed: true,
94 pkce_methods_allowed: &pkce_methods_allowed,
95 post_logout_redirect_uris: None,
96 require_pkce: false,
97 token_endpoint_auth_method: oauth_client::TokenEndpointAuthMethod::ClientSecretPost,
98 };
99 if oauth_client::OAuthClient::find_by_client_id_optional(&mut conn, "test-client-id-2")
100 .await?
101 .is_none()
102 {
103 let _client_2 = oauth_client::OAuthClient::insert(&mut conn, new_client_parms_2).await?;
104 }
105
106 let new_client_parms_3 = oauth_client::NewClientParams {
107 client_name: "Test Client 3",
108 application_type: oauth_client::ApplicationType::Web,
109 client_id: "test-client-id-3",
110 client_secret: Some(&secret), client_secret_expires_at: None,
112 redirect_uris: redirect_uris.as_slice(),
113 allowed_grant_types: &allowed_grant_types,
114 scopes: scopes.as_slice(),
115 allowed_origins: Some(allowed_origins.as_slice()),
116 bearer_allowed: true,
117 pkce_methods_allowed: &pkce_methods_allowed,
118 post_logout_redirect_uris: None,
119 require_pkce: false,
120 token_endpoint_auth_method: oauth_client::TokenEndpointAuthMethod::ClientSecretPost,
121 };
122 if oauth_client::OAuthClient::find_by_client_id_optional(&mut conn, "test-client-id-3")
123 .await?
124 .is_none()
125 {
126 let _client_3 = oauth_client::OAuthClient::insert(&mut conn, new_client_parms_3).await?;
127 }
128
129 let device_grant_types = vec![GrantTypeName::DeviceCode, GrantTypeName::RefreshToken];
134 let device_redirect_uris = vec!["urn:ietf:wg:oauth:2.0:oob".to_string()];
135 let exercise_services_scopes = vec!["exercise-services".to_string()];
136 let tmc_vscode_params = oauth_client::NewClientParams {
137 client_id: "tmc-vscode",
138 client_name: "TMC VSCode extension",
139 application_type: oauth_client::ApplicationType::Native,
140 token_endpoint_auth_method: oauth_client::TokenEndpointAuthMethod::None,
141 client_secret: None,
142 client_secret_expires_at: None,
143 redirect_uris: device_redirect_uris.as_slice(),
144 post_logout_redirect_uris: None,
145 allowed_grant_types: &device_grant_types,
146 scopes: exercise_services_scopes.as_slice(),
147 require_pkce: true,
148 pkce_methods_allowed: &pkce_methods_allowed,
149 allowed_origins: None,
150 bearer_allowed: true,
151 };
152 if oauth_client::OAuthClient::find_by_client_id_optional(&mut conn, "tmc-vscode")
153 .await?
154 .is_none()
155 {
156 oauth_client::OAuthClient::insert(&mut conn, tmc_vscode_params).await?;
157 }
158
159 let introspection_secret = Digest::from_str(INTROSPECTION_SECRET_DIGEST_HEX).unwrap(); let no_grants: Vec<GrantTypeName> = vec![];
164 let no_scopes: Vec<String> = vec![];
165 let introspection_params = oauth_client::NewClientParams {
166 client_id: "tmc-server-introspection-dev",
167 client_name: "tmc-server token introspection (dev)",
168 application_type: oauth_client::ApplicationType::Service,
169 token_endpoint_auth_method: oauth_client::TokenEndpointAuthMethod::ClientSecretPost,
170 client_secret: Some(&introspection_secret),
171 client_secret_expires_at: None,
172 redirect_uris: device_redirect_uris.as_slice(),
173 post_logout_redirect_uris: None,
174 allowed_grant_types: &no_grants,
175 scopes: no_scopes.as_slice(),
176 require_pkce: false,
177 pkce_methods_allowed: &pkce_methods_allowed,
178 allowed_origins: None,
179 bearer_allowed: false,
180 };
181 if oauth_client::OAuthClient::find_by_client_id_optional(
182 &mut conn,
183 "tmc-server-introspection-dev",
184 )
185 .await?
186 .is_none()
187 {
188 oauth_client::OAuthClient::insert(&mut conn, introspection_params).await?;
189 }
190
191 let noscope_grant_types = vec![GrantTypeName::DeviceCode];
195 let openid_scopes = vec!["openid".to_string()];
196 let noscope_params = oauth_client::NewClientParams {
197 client_id: "tmc-vscode-noscope-test",
198 client_name: "TMC VSCode device client without exercise-services (test)",
199 application_type: oauth_client::ApplicationType::Native,
200 token_endpoint_auth_method: oauth_client::TokenEndpointAuthMethod::None,
201 client_secret: None,
202 client_secret_expires_at: None,
203 redirect_uris: device_redirect_uris.as_slice(),
204 post_logout_redirect_uris: None,
205 allowed_grant_types: &noscope_grant_types,
206 scopes: openid_scopes.as_slice(),
207 require_pkce: true,
208 pkce_methods_allowed: &pkce_methods_allowed,
209 allowed_origins: None,
210 bearer_allowed: true,
211 };
212 if oauth_client::OAuthClient::find_by_client_id_optional(&mut conn, "tmc-vscode-noscope-test")
213 .await?
214 .is_none()
215 {
216 oauth_client::OAuthClient::insert(&mut conn, noscope_params).await?;
217 }
218
219 Ok(SeedOAuthClientsResult {
220 client_db_id: client.id,
221 })
222}
223
224#[cfg(test)]
225mod tests {
226 use std::{fs, path::Path, str::FromStr};
227
228 use base64::{Engine, prelude::BASE64_STANDARD};
229 use headless_lms_models::library::oauth::{Digest, token_digest_sha256};
230 use secrecy::SecretString;
231
232 use super::{
233 DEV_OAUTH_TOKEN_HMAC_KEY, INTROSPECTION_SECRET_DIGEST_HEX, TEST_CLIENT_SECRET_DIGEST_HEX,
234 };
235
236 const DEV_ENV_MANIFESTS: [&str; 2] = [
239 "../../../kubernetes/dev/headless-lms/env.yml",
240 "../../../kubernetes/test/headless-lms/env.yml",
241 ];
242
243 fn hmac_key_from_manifest(relative_path: &str) -> String {
248 let path = Path::new(env!("CARGO_MANIFEST_DIR")).join(relative_path);
249 let manifest = fs::read_to_string(&path)
250 .unwrap_or_else(|e| panic!("failed to read {}: {e}", path.display()));
251
252 assert!(
253 manifest.contains("kind: Secret"),
254 "{} is expected to be a kind: Secret manifest; if it became a ConfigMap the values are \
255 no longer base64-decoded and this test (and the seeded digests) must change",
256 path.display()
257 );
258 assert!(
259 manifest.contains("\ndata:"),
260 "{} is expected to use a base64 `data:` block, not `stringData:`; if that changed the \
261 seeded digests must be recomputed under the raw value",
262 path.display()
263 );
264
265 let encoded = manifest
266 .lines()
267 .find_map(|line| line.trim().strip_prefix("OAUTH_TOKEN_HMAC_KEY:"))
268 .unwrap_or_else(|| panic!("OAUTH_TOKEN_HMAC_KEY not found in {}", path.display()))
269 .trim()
270 .trim_matches('"')
271 .to_string();
272
273 let decoded = BASE64_STANDARD
274 .decode(encoded.as_bytes())
275 .unwrap_or_else(|e| {
276 panic!(
277 "OAUTH_TOKEN_HMAC_KEY in {} is not valid base64, but a `data:` value must \
278 be: {e}",
279 path.display()
280 )
281 });
282 String::from_utf8(decoded).expect("OAUTH_TOKEN_HMAC_KEY must decode to UTF-8")
283 }
284
285 #[test]
289 fn dev_hmac_key_matches_kubernetes_env_manifests() {
290 for manifest in DEV_ENV_MANIFESTS {
291 assert_eq!(
292 hmac_key_from_manifest(manifest),
293 DEV_OAUTH_TOKEN_HMAC_KEY,
294 "DEV_OAUTH_TOKEN_HMAC_KEY must equal the base64-decoded OAUTH_TOKEN_HMAC_KEY from \
295 {manifest}, since that decoded value is what Kubernetes puts in the environment \
296 and what config.rs then reads"
297 );
298 }
299 }
300
301 #[test]
306 fn seeded_secret_digests_match_dev_hmac_key() {
307 let key = SecretString::new(DEV_OAUTH_TOKEN_HMAC_KEY.to_string().into());
308
309 let test_client = token_digest_sha256("very-secret", &key);
310 assert_eq!(
311 test_client.as_slice(),
312 Digest::from_str(TEST_CLIENT_SECRET_DIGEST_HEX)
313 .unwrap()
314 .as_slice(),
315 "Test Client secret digest must be HMAC-SHA-256(DEV_OAUTH_TOKEN_HMAC_KEY, \
316 \"very-secret\")"
317 );
318
319 let introspection =
320 token_digest_sha256("for local development only, intentionally public", &key);
321 assert_eq!(
322 introspection.as_slice(),
323 Digest::from_str(INTROSPECTION_SECRET_DIGEST_HEX)
324 .unwrap()
325 .as_slice(),
326 "introspection secret digest must be HMAC-SHA-256(DEV_OAUTH_TOKEN_HMAC_KEY, \
327 <dev secret>)"
328 );
329 }
330}