1use async_trait::async_trait;
13use chrono::{DateTime, Utc};
14use secrets_core::engine::{
15 CredentialShape, EngineDoc, EngineError, EngineResult, GeneratedCredential, PathDoc,
16 SecretsEngine, TtlDoc,
17};
18use secrets_core::lease::Lease;
19use secrets_core::mount::ConfigRoleStore;
20use secrets_core::storage::StorageBackend;
21use serde::{Deserialize, Serialize};
22use serde_json::json;
23use uuid::Uuid;
24
25const STORE: ConfigRoleStore = ConfigRoleStore::new("m365/config/", "m365/roles/");
26const MOUNT: &str = "m365/creds/";
27const DEFAULT_AUTHORITY: &str = "https://login.microsoftonline.com";
28const DEFAULT_SCOPE: &str = "https://graph.microsoft.com/.default";
29const CLIENT_ASSERTION_TYPE: &str = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer";
30
31const CLIENT_ASSERTION_TTL_SECONDS: i64 = 300;
35
36const MIN_CONFIGURABLE_TTL_SECONDS: i64 = 600;
39const MAX_CONFIGURABLE_TTL_SECONDS: i64 = 86_399;
41
42#[derive(Debug, Clone, Serialize, Deserialize)]
48#[serde(tag = "type", rename_all = "snake_case")]
49pub enum CredentialKind {
50 Secret { client_secret: String },
53 Certificate {
57 private_key_pem: String,
58 x5t_s256: String,
59 },
60 Federated { token_file: String },
64}
65
66impl CredentialKind {
67 fn describes_root_credential(&self) -> &'static str {
70 match self {
71 Self::Secret { .. } => "a client secret",
72 Self::Certificate { .. } => "a certificate private key",
73 Self::Federated { .. } => "nothing — the workload's own OIDC token",
74 }
75 }
76}
77
78#[derive(Debug, Clone, Serialize, Deserialize)]
80pub struct M365Config {
81 pub tenant_id: String,
82 pub client_id: String,
83 pub credential: CredentialKind,
84 #[serde(default = "default_authority")]
86 pub authority: String,
87}
88
89fn default_authority() -> String {
90 DEFAULT_AUTHORITY.to_string()
91}
92
93fn default_scope() -> String {
94 DEFAULT_SCOPE.to_string()
95}
96
97#[derive(Debug, Clone, Serialize, Deserialize)]
102pub struct RoleConfig {
103 pub target: String,
105 #[serde(default = "default_scope")]
108 pub scope: String,
109 #[serde(default)]
114 pub resource_hint: Option<String>,
115}
116
117#[derive(Debug, Deserialize)]
118struct TokenResponse {
119 access_token: String,
120 expires_in: i64,
121 #[serde(default)]
122 token_type: Option<String>,
123}
124
125#[derive(Debug, Serialize, PartialEq, Eq)]
126struct ClientAssertionClaims {
127 aud: String,
129 iss: String,
130 sub: String,
131 jti: String,
132 iat: i64,
133 nbf: i64,
134 exp: i64,
135}
136
137#[derive(Default)]
138pub struct M365Engine {
139 http: reqwest::Client,
140}
141
142impl M365Engine {
143 pub fn new() -> Self {
144 Self::default()
145 }
146
147 fn token_endpoint(config: &M365Config) -> String {
148 format!(
149 "{}/{}/oauth2/v2.0/token",
150 config.authority.trim_end_matches('/'),
151 config.tenant_id
152 )
153 }
154
155 fn assertion_claims(
156 client_id: &str,
157 token_endpoint: &str,
158 now: DateTime<Utc>,
159 ) -> ClientAssertionClaims {
160 ClientAssertionClaims {
161 aud: token_endpoint.to_string(),
162 iss: client_id.to_string(),
165 sub: client_id.to_string(),
166 jti: Uuid::new_v4().to_string(),
167 iat: now.timestamp(),
168 nbf: now.timestamp(),
169 exp: now.timestamp() + CLIENT_ASSERTION_TTL_SECONDS,
170 }
171 }
172
173 fn assertion_header(x5t_s256: &str) -> jsonwebtoken::Header {
176 let mut header = jsonwebtoken::Header::new(jsonwebtoken::Algorithm::RS256);
177 header.x5t_s256 = Some(x5t_s256.to_string());
178 header
179 }
180
181 fn client_assertion(
182 client_id: &str,
183 private_key_pem: &str,
184 x5t_s256: &str,
185 token_endpoint: &str,
186 now: DateTime<Utc>,
187 ) -> EngineResult<String> {
188 let key = jsonwebtoken::EncodingKey::from_rsa_pem(private_key_pem.as_bytes()).map_err(|e| {
189 EngineError::InvalidRequest(format!(
190 "m365/config credential.private_key_pem is not a valid RSA PEM: {e}"
191 ))
192 })?;
193 jsonwebtoken::encode(
194 &Self::assertion_header(x5t_s256),
195 &Self::assertion_claims(client_id, token_endpoint, now),
196 &key,
197 )
198 .map_err(|e| EngineError::Other(format!("failed to sign the client assertion: {e}")))
199 }
200
201 fn read_federated_token(token_file: &str) -> EngineResult<String> {
204 let raw = std::fs::read_to_string(token_file).map_err(|e| {
205 EngineError::InvalidRequest(format!(
206 "cannot read the federated token file '{token_file}': {e}. On Kubernetes \
207 this is the path AZURE_FEDERATED_TOKEN_FILE points at, and it must be \
208 projected into this server's own pod."
209 ))
210 })?;
211 let token = raw.trim().to_string();
212 if token.is_empty() {
213 return Err(EngineError::InvalidRequest(format!(
214 "the federated token file '{token_file}' is empty"
215 )));
216 }
217 Ok(token)
218 }
219
220 async fn request_access_token(
221 &self,
222 config: &M365Config,
223 scope: &str,
224 now: DateTime<Utc>,
225 ) -> EngineResult<TokenResponse> {
226 let endpoint = Self::token_endpoint(config);
227 let mut form: Vec<(&str, String)> = vec![
228 ("grant_type", "client_credentials".to_string()),
229 ("client_id", config.client_id.clone()),
230 ("scope", scope.to_string()),
231 ];
232
233 match &config.credential {
234 CredentialKind::Secret { client_secret } => {
235 form.push(("client_secret", client_secret.clone()));
236 }
237 CredentialKind::Certificate {
238 private_key_pem,
239 x5t_s256,
240 } => {
241 let assertion = Self::client_assertion(
242 &config.client_id,
243 private_key_pem,
244 x5t_s256,
245 &endpoint,
246 now,
247 )?;
248 form.push(("client_assertion_type", CLIENT_ASSERTION_TYPE.to_string()));
249 form.push(("client_assertion", assertion));
250 }
251 CredentialKind::Federated { token_file } => {
252 let assertion = Self::read_federated_token(token_file)?;
253 form.push(("client_assertion_type", CLIENT_ASSERTION_TYPE.to_string()));
254 form.push(("client_assertion", assertion));
255 }
256 }
257
258 let response = self
259 .http
260 .post(&endpoint)
261 .form(&form)
262 .send()
263 .await
264 .map_err(|e| EngineError::Provider(format!("Entra ID request failed: {e}")))?;
265
266 let status = response.status();
267 let text = response.text().await.unwrap_or_default();
268 if !status.is_success() {
269 return Err(EngineError::Provider(format!(
272 "Entra ID returned {status} for {endpoint}: {text}"
273 )));
274 }
275 serde_json::from_str(&text)
276 .map_err(|e| EngineError::Provider(format!("unexpected Entra ID response: {e}")))
277 }
278
279 fn scope_description(role: &RoleConfig, config: &M365Config) -> Vec<String> {
280 let mut scoped = vec![
281 format!("tenant:{}", config.tenant_id),
282 format!("app:{}", config.client_id),
283 format!("scope:{}", role.scope),
284 ];
285 if role.scope.ends_with("/.default") {
286 scoped.push(
289 "permissions:ALL application permissions consented to this app — \
290 .default cannot request a subset, so the app registration is the \
291 only boundary the token itself carries"
292 .to_string(),
293 );
294 }
295 match &role.resource_hint {
296 Some(hint) => scoped.push(format!("resource-narrowing:{hint}")),
297 None => scoped.push(
298 "resource-narrowing:NONE RECORDED — app-only Graph permissions are \
299 tenant-wide unless an admin narrowed them"
300 .to_string(),
301 ),
302 }
303 scoped
304 }
305}
306
307#[async_trait]
308impl SecretsEngine for M365Engine {
309 fn doc(&self) -> EngineDoc {
310 EngineDoc {
311 provider: "Microsoft 365 (Microsoft Graph)".to_string(),
312 mechanism: "app-only OAuth 2 client-credentials access tokens from Entra \
313 ID, authenticated with a client secret, a certificate \
314 assertion, or — with no stored secret — a federated identity \
315 credential"
316 .to_string(),
317 shape: CredentialShape::MintExpiryOnly,
318 revocable: false,
319 revoke_effect: "nothing at the provider. No Microsoft API revokes an \
320 issued Graph access token, so revoking a lease only \
321 deletes our record of it and stops renewal — the token \
322 keeps working until it expires. revokeSignInSessions \
323 invalidates refresh tokens and future issuance, not live \
324 access tokens, and Continuous Access Evaluation is the \
325 only near-real-time path: it needs both the resource and \
326 the client to be CAE-capable, and reacts only to critical \
327 events such as the identity being disabled."
328 .to_string(),
329 ttl: TtlDoc::range(
330 MIN_CONFIGURABLE_TTL_SECONDS,
331 MAX_CONFIGURABLE_TTL_SECONDS,
332 "Entra decides, and randomises the default between 60 and 90 minutes, \
333 so the lease is set from the returned expires_in rather than any \
334 figure we choose. A tenant-side Configurable Token Lifetime policy \
335 can pin it between 10 minutes and 23:59:59; 10 minutes is the \
336 defensible choice here precisely because revocation is unavailable.",
337 ),
338 scoping: "not in the token. Client credentials can only ask for \
339 …/.default, which carries every application permission the app \
340 has been granted, so narrowing has to happen at the resource: \
341 an Exchange Application Access Policy for mail, Sites.Selected \
342 for SharePoint and OneDrive, resource-specific consent for \
343 Teams. Record what you did in the role's resource_hint, since \
344 Entra does not report it back to us."
345 .to_string(),
346 root_credential: "depends on m365/config/{target}.credential: a client \
347 secret or a certificate private key — or, with the \
348 federated variant, nothing at all. Prefer federated: \
349 the assertion is this server's own platform-issued OIDC \
350 token, so there is no durable secret to store, rotate \
351 or leak, and only the trust configuration on the app \
352 registration is durable."
353 .to_string(),
354 paths: vec![
355 PathDoc::new(
356 "m365/config/{target}",
357 &["POST", "GET", "DELETE"],
358 "sudo",
359 "register the tenant, client id and credential. GET reports only \
360 whether it is configured — the credential is never returned.",
361 ),
362 PathDoc::new(
363 "m365/roles/{role}",
364 &["POST", "GET", "DELETE"],
365 "create / read / sudo",
366 "define one consumer's target, scope and recorded resource narrowing",
367 ),
368 PathDoc::new(
369 "m365/creds/{role}",
370 &["GET"],
371 "read",
372 "mint a Graph access token and open a lease",
373 ),
374 PathDoc::new("m365/help", &["GET"], "authenticated", "this document"),
375 ],
376 docs_url: Some("docs/delegation/microsoft-365.md".to_string()),
377 caveats: vec![
378 "…/.default is the only usable scope for client credentials, so the \
379 app registration IS the scope boundary — you cannot ask for less at \
380 request time, and a consumer receives everything the app was \
381 consented."
382 .to_string(),
383 "App-only Graph permissions are tenant-wide by default. Mail is \
384 narrowed with an Exchange Application Access Policy, whose \
385 propagation can exceed an hour; SharePoint and OneDrive with \
386 Sites.Selected, which grants zero sites until an admin grants \
387 per-site roles — though the Graph Search API queries a tenant-wide \
388 index and bypasses it; Teams with resource-specific consent."
389 .to_string(),
390 "Because no issued token can be revoked, a Configurable Token \
391 Lifetime policy is the real containment control. Its minimum is 10 \
392 minutes."
393 .to_string(),
394 "CAE-eligible tokens live 24–28 hours, far longer than the 60–90 \
395 minute headline, precisely because they can be re-evaluated. Do not \
396 assume the short figure holds everywhere."
397 .to_string(),
398 "The federated variant removes the stored secret but does not make \
399 this shape E: the consumer still receives a bearer token from us. \
400 True federation means the consumer exchanging its own identity \
401 directly — see the federation mount."
402 .to_string(),
403 "Admin consent is required for application permissions, and a missing \
404 consent surfaces as an opaque authorisation failure rather than \
405 anything that says 'nobody clicked approve'."
406 .to_string(),
407 ],
408 }
409 }
410
411 async fn read(&self, storage: &dyn StorageBackend, path: &str) -> EngineResult<serde_json::Value> {
412 STORE.handle_read::<RoleConfig>(storage, path).await
413 }
414
415 async fn write(
416 &self,
417 storage: &dyn StorageBackend,
418 path: &str,
419 data: serde_json::Value,
420 ) -> EngineResult<()> {
421 STORE.handle_write::<M365Config, RoleConfig>(storage, path, data).await
422 }
423
424 async fn delete(&self, storage: &dyn StorageBackend, path: &str) -> EngineResult<()> {
425 STORE.handle_delete(storage, path).await
426 }
427
428 async fn list(&self, storage: &dyn StorageBackend, prefix: &str) -> EngineResult<Vec<String>> {
429 STORE.handle_list(storage, prefix).await
430 }
431
432 async fn generate(
433 &self,
434 storage: &dyn StorageBackend,
435 role_name: &str,
436 ) -> EngineResult<GeneratedCredential> {
437 let role: RoleConfig = STORE.require_role(storage, role_name).await?;
438 let config: M365Config = STORE.require_config(storage, &role.target).await?;
439
440 let now = Utc::now();
441 let token = self.request_access_token(&config, &role.scope, now).await?;
442
443 let lease = Lease {
444 id: Uuid::new_v4(),
445 token_id_hash: String::new(),
447 engine_mount: MOUNT.to_string(),
448 internal_data: json!({
452 "role": role_name,
453 "tenant_id": config.tenant_id,
454 "client_id": config.client_id,
455 "credential_type": config.credential.describes_root_credential(),
456 }),
457 issued_at: now,
458 expires_at: now + chrono::Duration::seconds(token.expires_in),
461 };
462
463 Ok(GeneratedCredential::new(
464 json!({
465 "access_token": token.access_token,
466 "token_type": token.token_type.unwrap_or_else(|| "Bearer".to_string()),
467 "expires_in": token.expires_in,
468 }),
469 lease,
470 Self::scope_description(&role, &config),
471 ))
472 }
473
474 async fn revoke(&self, _storage: &dyn StorageBackend, lease: &Lease) -> EngineResult<()> {
475 tracing::warn!(
479 lease_id = %lease.id,
480 expires_at = %lease.expires_at,
481 "m365 lease revoked locally only: Microsoft cannot invalidate an issued \
482 Graph access token, so it remains usable until it expires"
483 );
484 Ok(())
485 }
486}
487
488#[cfg(test)]
489mod tests {
490 use super::*;
491
492 fn config(credential: CredentialKind) -> M365Config {
493 M365Config {
494 tenant_id: "00000000-0000-0000-0000-0000000000aa".to_string(),
495 client_id: "11111111-1111-1111-1111-1111111111bb".to_string(),
496 credential,
497 authority: default_authority(),
498 }
499 }
500
501 fn role() -> RoleConfig {
502 RoleConfig {
503 target: "acme".to_string(),
504 scope: default_scope(),
505 resource_hint: None,
506 }
507 }
508
509 #[test]
510 fn credential_kinds_round_trip_with_a_type_tag() {
511 let cases = [
512 (
513 CredentialKind::Secret {
514 client_secret: "s3cret".to_string(),
515 },
516 "secret",
517 ),
518 (
519 CredentialKind::Certificate {
520 private_key_pem: "pem".to_string(),
521 x5t_s256: "thumb".to_string(),
522 },
523 "certificate",
524 ),
525 (
526 CredentialKind::Federated {
527 token_file: "/var/run/token".to_string(),
528 },
529 "federated",
530 ),
531 ];
532
533 for (credential, tag) in cases {
534 let value = serde_json::to_value(&credential).unwrap();
535 assert_eq!(value["type"], tag, "unexpected tag for {credential:?}");
536
537 let parsed: CredentialKind = serde_json::from_value(value).unwrap();
538 assert_eq!(
539 serde_json::to_value(&parsed).unwrap(),
540 serde_json::to_value(&credential).unwrap(),
541 "{tag} did not survive a round trip"
542 );
543 }
544 }
545
546 #[test]
548 fn config_parses_the_documented_shape() {
549 let parsed: M365Config = serde_json::from_value(json!({
550 "tenant_id": "tenant",
551 "client_id": "client",
552 "credential": { "type": "federated", "token_file": "/var/run/secrets/azure/token" },
553 }))
554 .unwrap();
555 assert_eq!(parsed.authority, DEFAULT_AUTHORITY);
556 assert!(matches!(parsed.credential, CredentialKind::Federated { .. }));
557 }
558
559 #[test]
560 fn token_endpoint_is_the_v2_tenant_endpoint() {
561 let config = config(CredentialKind::Secret {
562 client_secret: "x".to_string(),
563 });
564 assert_eq!(
565 M365Engine::token_endpoint(&config),
566 format!(
567 "https://login.microsoftonline.com/{}/oauth2/v2.0/token",
568 config.tenant_id
569 )
570 );
571 }
572
573 #[test]
576 fn client_assertion_claims_are_short_lived_and_endpoint_bound() {
577 let config = config(CredentialKind::Secret {
578 client_secret: "x".to_string(),
579 });
580 let endpoint = M365Engine::token_endpoint(&config);
581 let now = Utc::now();
582 let claims = M365Engine::assertion_claims(&config.client_id, &endpoint, now);
583
584 assert_eq!(claims.aud, endpoint);
585 assert_eq!(claims.iss, config.client_id);
586 assert_eq!(claims.sub, config.client_id);
587 assert_eq!(claims.exp - claims.iat, CLIENT_ASSERTION_TTL_SECONDS);
588 assert!(claims.exp - claims.iat <= 600, "assertion must be short-lived");
589
590 let other = M365Engine::assertion_claims(&config.client_id, &endpoint, now);
591 assert_ne!(claims.jti, other.jti, "jti must be unique per assertion");
592 }
593
594 #[test]
595 fn assertion_header_carries_the_thumbprint_as_x5t_s256() {
596 let header = M365Engine::assertion_header("Zm9vYmFy");
597 assert_eq!(header.alg, jsonwebtoken::Algorithm::RS256);
598
599 let encoded = serde_json::to_value(&header).unwrap();
600 assert_eq!(
601 encoded["x5t#S256"], "Zm9vYmFy",
602 "Entra selects the registered key by this header, so the RFC spelling matters"
603 );
604 }
605
606 #[test]
607 fn rejects_a_certificate_that_is_not_a_pem() {
608 let err = M365Engine::client_assertion(
609 "client",
610 "not a pem",
611 "thumb",
612 "https://login.microsoftonline.com/t/oauth2/v2.0/token",
613 Utc::now(),
614 )
615 .unwrap_err();
616 assert!(matches!(err, EngineError::InvalidRequest(_)), "got {err:?}");
617 }
618
619 #[test]
620 fn missing_federated_token_file_is_a_clear_invalid_request() {
621 let path = format!("/nonexistent/{}/token", Uuid::new_v4());
622 let err = M365Engine::read_federated_token(&path).unwrap_err();
623 match err {
624 EngineError::InvalidRequest(message) => {
625 assert!(message.contains(&path), "error should name the path: {message}");
626 assert!(
627 message.contains("AZURE_FEDERATED_TOKEN_FILE"),
628 "error should point at the convention: {message}"
629 );
630 }
631 other => panic!("expected InvalidRequest, got {other:?}"),
632 }
633 }
634
635 #[test]
636 fn federated_token_is_read_and_trimmed() {
637 let path = std::env::temp_dir().join(format!("m365-{}.jwt", Uuid::new_v4()));
638 std::fs::write(&path, " header.payload.signature\n").unwrap();
639
640 let token = M365Engine::read_federated_token(path.to_str().unwrap()).unwrap();
641 assert_eq!(token, "header.payload.signature");
642
643 std::fs::write(&path, "\n\n").unwrap();
644 let err = M365Engine::read_federated_token(path.to_str().unwrap()).unwrap_err();
645 assert!(matches!(err, EngineError::InvalidRequest(_)), "got {err:?}");
646
647 std::fs::remove_file(&path).unwrap();
648 }
649
650 #[test]
653 fn scope_description_admits_that_default_is_not_narrowing() {
654 let config = config(CredentialKind::Federated {
655 token_file: "/var/run/token".to_string(),
656 });
657 let scoped = M365Engine::scope_description(&role(), &config);
658
659 assert!(scoped.iter().any(|s| s.contains("permissions:ALL")));
660 assert!(scoped.iter().any(|s| s.contains("resource-narrowing:NONE RECORDED")));
661 assert!(scoped.contains(&format!("tenant:{}", config.tenant_id)));
662 }
663
664 #[test]
665 fn scope_description_reports_recorded_resource_narrowing() {
666 let config = config(CredentialKind::Secret {
667 client_secret: "x".to_string(),
668 });
669 let role = RoleConfig {
670 resource_hint: Some("Sites.Selected: reports-site (read)".to_string()),
671 ..role()
672 };
673 let scoped = M365Engine::scope_description(&role, &config);
674
675 assert!(
676 scoped
677 .iter()
678 .any(|s| s == "resource-narrowing:Sites.Selected: reports-site (read)")
679 );
680 assert!(!scoped.iter().any(|s| s.contains("NONE RECORDED")));
681 }
682
683 #[test]
684 fn doc_agrees_with_its_shape() {
685 let doc = M365Engine::new().doc();
686 assert_eq!(doc.shape, CredentialShape::MintExpiryOnly);
687 assert_eq!(doc.revocable, doc.shape.revocable());
688 assert!(!doc.revocable, "Graph access tokens cannot be revoked");
689 assert!(!doc.ttl.fixed, "a CTL policy can move this lifetime");
690 assert_eq!(doc.ttl.min_seconds, Some(MIN_CONFIGURABLE_TTL_SECONDS));
691 }
692
693 #[test]
696 fn doc_does_not_overstate_revocation() {
697 let doc = M365Engine::new().doc();
698 assert!(doc.revoke_effect.starts_with("nothing at the provider"));
699 assert!(doc.revoke_effect.contains("Continuous Access Evaluation"));
700 }
701
702 #[test]
703 fn federated_credential_reports_storing_no_secret() {
704 let federated = CredentialKind::Federated {
705 token_file: "/var/run/token".to_string(),
706 };
707 assert!(federated.describes_root_credential().starts_with("nothing"));
708 assert_eq!(
709 CredentialKind::Secret {
710 client_secret: "x".to_string()
711 }
712 .describes_root_credential(),
713 "a client secret"
714 );
715 }
716}