auths-sdk 0.1.2

Application services layer for Auths identity operations
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
//! Federation-as-attestor: typed OIDC attestations anchored into the subject's own
//! KEL, read by policy as a deny-capable-but-never-grant-capable signal.

use auths_core::storage::keychain::KeyAlias;
use auths_oidc_port::{JwtValidator, OidcError, OidcValidationConfig};
use auths_policy::decision::Outcome;
use auths_sdk::context::AuthsContext;
use auths_sdk::workflows::federation::{
    AttestationContent, FederationError, GroupId, IdpAttestation, IdpId, LifecycleClaim, Nonce,
    OidcAttestationRequest, SamlAssertion, SamlAssertionVerifier, SamlAttestationRequest,
    attest_oidc, attest_saml, evaluate_idp_signals, verify_oidc_attestation,
    verify_saml_attestation,
};
use auths_verifier::IdentityDID;
use chrono::{DateTime, Duration, Utc};

use crate::cases::helpers::setup_signed_artifact_context;

const ISSUER: &str = "https://acme.okta.com";

fn fixed_now() -> DateTime<Utc> {
    DateTime::parse_from_rfc3339("2026-06-08T00:00:00Z")
        .expect("valid timestamp")
        .with_timezone(&Utc)
}

/// A `JwtValidator` double that returns preset, already-verified claims — the real
/// signature/issuer/audience/expiry checks are the validator's job and are exercised
/// in `auths-oidc-port`; here we exercise the attestation logic above the port.
struct FakeValidator {
    claims: serde_json::Value,
}

#[async_trait::async_trait]
impl JwtValidator for FakeValidator {
    async fn validate(
        &self,
        _token: &str,
        _config: &OidcValidationConfig,
        _now: DateTime<Utc>,
    ) -> Result<serde_json::Value, OidcError> {
        Ok(self.claims.clone())
    }
}

fn config() -> OidcValidationConfig {
    OidcValidationConfig::builder()
        .issuer(ISSUER)
        .audience("auths")
        .build()
        .expect("config")
}

fn claims(nonce: &str, groups: &[&str]) -> serde_json::Value {
    serde_json::json!({
        "iss": ISSUER,
        "sub": "okta-user-1",
        "aud": "auths",
        "nonce": nonce,
        "groups": groups,
    })
}

/// A subject identity with a real KEL to anchor into; returns the alias + DID.
fn subject() -> (tempfile::TempDir, KeyAlias, AuthsContext, IdentityDID) {
    let (tmp, alias, ctx) = setup_signed_artifact_context();
    let managed = ctx
        .identity_storage
        .load_identity()
        .expect("subject identity");
    let did = IdentityDID::new_unchecked(managed.controller_did.as_str().to_string());
    (tmp, alias, ctx, did)
}

fn request(subject: &IdentityDID, claim: LifecycleClaim, nonce: &str) -> OidcAttestationRequest {
    OidcAttestationRequest {
        subject: subject.clone(),
        claim,
        expected_nonce: Nonce::new(nonce).expect("nonce"),
        ttl_secs: 3600,
    }
}

#[tokio::test]
async fn federation_oidc_verifies_and_anchors_into_subject_kel() {
    let (_tmp, alias, ctx, did) = subject();
    let validator = FakeValidator {
        claims: claims("challenge-123", &["engineering"]),
    };
    let req = request(&did, LifecycleClaim::Employed, "challenge-123");

    let attestation = attest_oidc(
        &ctx,
        &validator,
        &config(),
        "id-token",
        &req,
        &alias,
        fixed_now(),
    )
    .await
    .expect("attest + anchor");

    assert_eq!(attestation.content.subject, did);
    assert_eq!(attestation.content.idp.as_str(), ISSUER);
    assert_eq!(attestation.content.claim, LifecycleClaim::Employed);
    assert!(
        attestation.anchored_at_seq >= 1,
        "the attestation is anchored past the inception event in the subject's own KEL"
    );
}

#[tokio::test]
async fn idp_attestation_rejects_nonce_replay() {
    let (_tmp, _alias, _ctx, did) = subject();
    let validator = FakeValidator {
        // Token carries a different nonce than the challenge we expect.
        claims: claims("stale-nonce", &[]),
    };
    let req = request(&did, LifecycleClaim::Employed, "fresh-nonce");

    let result =
        verify_oidc_attestation(&validator, &config(), "id-token", &req, fixed_now()).await;
    assert!(matches!(
        result,
        Err(auths_sdk::workflows::federation::FederationError::NonceMismatch)
    ));
}

#[tokio::test]
async fn idp_attestation_group_member_must_be_present_in_token() {
    let (_tmp, _alias, _ctx, did) = subject();
    let validator = FakeValidator {
        claims: claims("n", &["engineering"]),
    };

    // Group not in the token's groups → rejected, never coerced.
    let missing = request(
        &did,
        LifecycleClaim::GroupMember(GroupId::new("admins").unwrap()),
        "n",
    );
    let err = verify_oidc_attestation(&validator, &config(), "t", &missing, fixed_now())
        .await
        .expect_err("group not present must reject");
    assert!(matches!(
        err,
        auths_sdk::workflows::federation::FederationError::ClaimNotInToken(_)
    ));

    // Group present → ok.
    let present = request(
        &did,
        LifecycleClaim::GroupMember(GroupId::new("engineering").unwrap()),
        "n",
    );
    assert!(
        verify_oidc_attestation(&validator, &config(), "t", &present, fixed_now())
            .await
            .is_ok()
    );
}

/// The load-bearing invariant: an attestation is evidence, never authority. The only
/// thing a signal yields is a `Decision`; `Grant` has no public constructor outside
/// `auths-rp` and nothing here produces one. A positive signal must NOT allow.
#[test]
fn idp_attestation_cannot_grant() {
    let subject = IdentityDID::new_unchecked("did:keri:ESubjectIdpAttestation".to_string());
    let fresh = fixed_now() + Duration::hours(1);
    let base = AttestationContent {
        subject: subject.clone(),
        idp: IdpId::new(ISSUER).unwrap(),
        claim: LifecycleClaim::GroupMember(GroupId::new("admins").unwrap()),
        nonce: Nonce::new("n").unwrap(),
        expires_at: fresh,
    };

    // A maximally-positive signal: corroborating evidence, never an authority grant.
    let positive = IdpAttestation {
        content: base.clone(),
        anchored_at_seq: 1,
    };
    let decision = evaluate_idp_signals(&[positive.as_signal(fixed_now())]);
    assert!(
        !decision.is_allowed(),
        "an IdP attestation must never promote to an authority grant"
    );
    assert_eq!(decision.outcome, Outcome::Indeterminate);

    // A negative lifecycle signal CAN deny — the IdP is authoritative over its own
    // employment/suspension status.
    let suspended = IdpAttestation {
        content: AttestationContent {
            claim: LifecycleClaim::Suspended,
            ..base
        },
        anchored_at_seq: 1,
    };
    let deny = evaluate_idp_signals(&[suspended.as_signal(fixed_now())]);
    assert!(deny.is_denied());
}

#[test]
fn idp_attestation_expired_yields_no_signal_not_valid() {
    let subject = IdentityDID::new_unchecked("did:keri:ESubjectExpired".to_string());
    let expired = IdpAttestation {
        content: AttestationContent {
            subject,
            idp: IdpId::new(ISSUER).unwrap(),
            claim: LifecycleClaim::Employed,
            nonce: Nonce::new("n").unwrap(),
            expires_at: fixed_now() - Duration::hours(1),
        },
        anchored_at_seq: 1,
    };
    let signal = expired.as_signal(fixed_now());
    assert!(
        !signal.fresh,
        "an expired attestation is not a fresh signal"
    );

    let decision = evaluate_idp_signals(&[signal]);
    assert_eq!(
        decision.outcome,
        Outcome::Indeterminate,
        "an expired attestation is 'no signal', never still-valid authority"
    );
}

// ── SAML attestor parity ──

/// A SAML verifier double — the XML-DSig check is the production port's job; this
/// returns a preset, already-verified assertion so the attestation logic above the
/// port is exercised.
struct FakeSamlVerifier {
    assertion: SamlAssertion,
}

#[async_trait::async_trait]
impl SamlAssertionVerifier for FakeSamlVerifier {
    async fn verify(
        &self,
        _response_xml: &[u8],
        _now: DateTime<Utc>,
    ) -> Result<SamlAssertion, FederationError> {
        Ok(self.assertion.clone())
    }
}

fn saml_assertion(
    assertion_id: &str,
    audiences: &[&str],
    groups: &[&str],
    not_on_or_after: Option<DateTime<Utc>>,
) -> SamlAssertion {
    let mut attributes = std::collections::BTreeMap::new();
    if !groups.is_empty() {
        attributes.insert(
            "groups".to_string(),
            groups.iter().map(|g| g.to_string()).collect(),
        );
    }
    SamlAssertion {
        issuer: ISSUER.to_string(),
        name_id: "ad-user-1".to_string(),
        audiences: audiences.iter().map(|a| a.to_string()).collect(),
        not_before: None,
        not_on_or_after,
        assertion_id: assertion_id.to_string(),
        attributes,
    }
}

fn saml_request(subject: &IdentityDID, claim: LifecycleClaim) -> SamlAttestationRequest {
    SamlAttestationRequest {
        subject: subject.clone(),
        claim,
        expected_audience: "auths".to_string(),
        ttl_secs: 3600,
    }
}

#[tokio::test]
async fn saml_attest_anchors_into_subject_kel() {
    let (_tmp, alias, ctx, did) = subject();
    let verifier = FakeSamlVerifier {
        assertion: saml_assertion("ASSERT-1", &["auths"], &["engineering"], None),
    };
    let req = saml_request(&did, LifecycleClaim::Employed);

    let attestation = attest_saml(&ctx, &verifier, b"<saml/>", &req, &alias, fixed_now())
        .await
        .expect("attest + anchor");
    assert_eq!(attestation.content.idp.as_str(), ISSUER);
    assert_eq!(attestation.content.nonce.as_str(), "ASSERT-1");
    assert!(attestation.anchored_at_seq >= 1);
}

#[tokio::test]
async fn saml_rejects_expired_assertion() {
    let did = IdentityDID::new_unchecked("did:keri:ESamlExpired".to_string());
    let verifier = FakeSamlVerifier {
        assertion: saml_assertion(
            "A2",
            &["auths"],
            &[],
            Some(fixed_now() - Duration::hours(1)),
        ),
    };
    let req = saml_request(&did, LifecycleClaim::Employed);
    let err = verify_saml_attestation(&verifier, b"<saml/>", &req, fixed_now())
        .await
        .expect_err("expired assertion must reject");
    assert!(matches!(err, FederationError::TokenInvalid(_)));
}

#[tokio::test]
async fn saml_rejects_audience_mismatch() {
    let did = IdentityDID::new_unchecked("did:keri:ESamlAud".to_string());
    let verifier = FakeSamlVerifier {
        assertion: saml_assertion("A3", &["some-other-sp"], &[], None),
    };
    let req = saml_request(&did, LifecycleClaim::Employed);
    let err = verify_saml_attestation(&verifier, b"<saml/>", &req, fixed_now())
        .await
        .expect_err("audience mismatch must reject");
    assert!(matches!(err, FederationError::ClaimNotInToken(_)));
}

#[tokio::test]
async fn saml_group_member_must_be_present_in_attributes() {
    let did = IdentityDID::new_unchecked("did:keri:ESamlGroup".to_string());

    // Group not present → rejected, never coerced.
    let verifier = FakeSamlVerifier {
        assertion: saml_assertion("A4", &["auths"], &["engineering"], None),
    };
    let missing = saml_request(
        &did,
        LifecycleClaim::GroupMember(GroupId::new("admins").unwrap()),
    );
    assert!(matches!(
        verify_saml_attestation(&verifier, b"<saml/>", &missing, fixed_now()).await,
        Err(FederationError::ClaimNotInToken(_))
    ));

    // Group present → ok.
    let verifier = FakeSamlVerifier {
        assertion: saml_assertion("A5", &["auths"], &["admins"], None),
    };
    let present = saml_request(
        &did,
        LifecycleClaim::GroupMember(GroupId::new("admins").unwrap()),
    );
    assert!(
        verify_saml_attestation(&verifier, b"<saml/>", &present, fixed_now())
            .await
            .is_ok()
    );
}

#[tokio::test]
async fn saml_and_oidc_yield_structurally_identical_attestation() {
    let did = IdentityDID::new_unchecked("did:keri:EParitySubject".to_string());
    let shared_nonce = "SHARED-CHALLENGE";

    // OIDC: nonce comes from the id_token's `nonce` claim.
    let oidc_validator = FakeValidator {
        claims: claims(shared_nonce, &[]),
    };
    let oidc_req = request(&did, LifecycleClaim::Employed, shared_nonce);
    let oidc_content =
        verify_oidc_attestation(&oidc_validator, &config(), "t", &oidc_req, fixed_now())
            .await
            .unwrap();

    // SAML: the same fact, where the assertion ID is the anti-replay token (nonce).
    let saml_verifier = FakeSamlVerifier {
        assertion: saml_assertion(shared_nonce, &["auths"], &[], None),
    };
    let saml_req = saml_request(&did, LifecycleClaim::Employed);
    let saml_content = verify_saml_attestation(&saml_verifier, b"<saml/>", &saml_req, fixed_now())
        .await
        .unwrap();

    assert_eq!(
        oidc_content, saml_content,
        "OIDC and SAML must produce structurally identical attestation content for an equivalent fact"
    );

    // And the same deny-capable-not-grant-capable policy treatment.
    let attestation = IdpAttestation {
        content: saml_content,
        anchored_at_seq: 1,
    };
    assert!(!evaluate_idp_signals(&[attestation.as_signal(fixed_now())]).is_allowed());
}

#[tokio::test]
async fn idp_attestation_survives_jwks_rotation() {
    let (_tmp, alias, ctx, did) = subject();
    let validator = FakeValidator {
        claims: claims("n", &[]),
    };
    let req = request(&did, LifecycleClaim::Employed, "n");

    let attestation = attest_oidc(&ctx, &validator, &config(), "t", &req, &alias, fixed_now())
        .await
        .expect("attest");

    // Consume-time evaluation depends only on the anchored attestation's expiry,
    // never on re-validating the JWT — so the IdP rotating its JWKS afterwards does
    // not un-verify a valid past attestation.
    let later = fixed_now() + Duration::minutes(30);
    let signal = attestation.as_signal(later);
    assert!(signal.fresh);
    assert!(!evaluate_idp_signals(&[signal]).is_denied());
}