Skip to main content

headless_lms_server/programs/seed/
seed_oauth_clients.rs

1use 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/// The dev/CI HMAC key used to derive every stored client-secret digest below.
15///
16/// `kubernetes/{dev,test}/headless-lms/env.yml` are `kind: Secret` manifests, so Kubernetes
17/// base64-decodes each `data:` value before injecting it: `OAUTH_TOKEN_HMAC_KEY: cGlwcHVyaQ==`
18/// reaches the process as `pippuri`. Seeded digests must be
19/// `HMAC-SHA-256(key = "pippuri", <secret>)` or client-secret validation can never match.
20#[cfg_attr(not(test), allow(dead_code))]
21const DEV_OAUTH_TOKEN_HMAC_KEY: &str = "pippuri";
22
23/// Digest of the shared "Test Client" family secret (plaintext `very-secret`),
24/// derived under [`DEV_OAUTH_TOKEN_HMAC_KEY`].
25const TEST_CLIENT_SECRET_DIGEST_HEX: &str =
26    "396b544a35b29f7d613452a165dcaebf4d71b80e981e687e91ce6d9ba9679cb2";
27
28/// Digest of the `tmc-server-introspection-dev` client secret (plaintext
29/// `for local development only, intentionally public`), derived under
30/// [`DEV_OAUTH_TOKEN_HMAC_KEY`].
31const 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(); // "very-secret"
37    let mut conn = db_pool.acquire().await?;
38    // One redirect URI per Playwright worker (ports 8765..8784) so each worker has its own callback server.
39    // Must match system-tests getRedirectUri(): http://127.0.0.1:{port}/callback
40    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), // "very-secret"
63        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), // "very-secret"
88        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), // "very-secret"
111        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    // Device-flow clients, dev/CI only; prod clients are provisioned by an operator.
130
131    // tmc-vscode: public native client for the RFC 8628 device-flow login. Same id and shape
132    // as prod (public clients have no secret to seed).
133    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    // tmc-server-introspection-dev: confidential client tmc-server uses to introspect our
160    // tokens locally. Id and secret must match tmc-server's config/secrets.yml dev defaults,
161    // and intentionally differ from prod.
162    let introspection_secret = Digest::from_str(INTROSPECTION_SECRET_DIGEST_HEX).unwrap(); // "for local development only, intentionally public"
163    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    // tmc-vscode-noscope-test: a device-flow client whose scopes exclude exercise-services, so
192    // tests can drive the scope gate (403) without borrowing the shared test client or another
193    // spec's user. Not provisioned in prod.
194    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    /// The dev/CI env manifests whose `OAUTH_TOKEN_HMAC_KEY` the seeded digests must agree with,
237    /// relative to this crate's manifest directory.
238    const DEV_ENV_MANIFESTS: [&str; 2] = [
239        "../../../kubernetes/dev/headless-lms/env.yml",
240        "../../../kubernetes/test/headless-lms/env.yml",
241    ];
242
243    /// Reads `OAUTH_TOKEN_HMAC_KEY` out of a `kind: Secret` manifest the way Kubernetes does:
244    /// the `data:` values are base64, and the *decoded* bytes are what lands in the environment.
245    ///
246    /// Deliberately not a generic YAML parse; the point is to mimic that one decoding step.
247    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    /// Pin [`DEV_OAUTH_TOKEN_HMAC_KEY`] to the value the deployed dev/CI process actually
286    /// receives. Without this, a key mismatch shows up only as `invalid_client` from every
287    /// confidential-client authentication in CI, while the offline-HMAC unit test stays green.
288    #[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    /// Pin the seeded digests to the derivation used at runtime,
302    /// `token_digest_sha256(secret, key = DEV_OAUTH_TOKEN_HMAC_KEY)`, recomputed through the real
303    /// code path rather than an offline HMAC. Digests derived under any other key can never
304    /// validate, and the token endpoint then rejects the client with `invalid_client`.
305    #[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}