Skip to content

0.3 to 0.4

Upgrade from the latest 0.3 release to 0.4. The changelog lists every other change.

Messages produced by the standard saml-rs APIs already carry the required timestamps. You still need to update code that builds those messages itself, plus any public IdpConfig struct literal and any call to RespondSlo::signing.

Apply this wherever a section below requires IssueInstant or NotOnOrAfter:

  • The attribute is unqualified. saml:IssueInstant and samlp:IssueInstant do not count.
  • The value is a UTC xs:dateTime ending in Z. Fractional seconds are accepted. +00:00 is not a substitute for Z.
  • A missing, qualified, malformed, or non-UTC value returns SamlError::ProtocolProfile.
  • An older, well-formed UTC value stays accepted. These checks add no maximum age, freshness window, or new replay default.

Whitespace around the value is normalised before a typed accessor returns it.

The check covers HTTP-POST, HTTP-Redirect, and HTTP-POST-SimpleSign. Skip this section when every request comes from the standard APIs.

Before, a request without IssueInstant could reach the normal flow:

<samlp:AuthnRequest
xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol"
ID="_request"
Version="2.0"/>

Add the timestamp:

<samlp:AuthnRequest
xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol"
ID="_request"
Version="2.0"
IssueInstant="2026-07-16T23:13:10Z"/>

A hand-built FlowResult must carry the same value:

{
"request": {
"id": "_request",
"issueInstant": "2026-07-16T23:13:10Z"
}
}

To read it from a typed request:

let issue_instant = request.issue_instant().as_str();

An inbound SSO Response and its selected Assertion each need IssueInstant. The rule covers raw POST, Redirect, and SimpleSign parsing, conversion to SsoResponse or SsoSession, solicited finish_sso, and unsolicited SSO on the bindings those typed flows support. An assertion recovered through XML Encryption is checked again after decryption.

Before, either level could omit the timestamp:

<samlp:Response
xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol"
xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion"
ID="_response"
Version="2.0">
<!-- Status and other response content -->
<saml:Assertion ID="_assertion" Version="2.0">
<!-- Assertion content -->
</saml:Assertion>
</samlp:Response>

Add both:

<samlp:Response
xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol"
xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion"
ID="_response"
Version="2.0"
IssueInstant="2026-07-20T16:00:00Z">
<!-- Status and other response content -->
<saml:Assertion
ID="_assertion"
Version="2.0"
IssueInstant="2026-07-20T16:00:01Z">
<!-- Assertion content -->
</saml:Assertion>
</samlp:Response>

A FlowResult converted to SsoResponse needs response.issueInstant. Conversion to SsoSession needs both values:

{
"response": {
"id": "_response",
"issueInstant": "2026-07-20T16:00:00Z"
},
"assertion": {
"id": "_assertion",
"issueInstant": "2026-07-20T16:00:01Z"
}
}

The new accessors are optional:

let envelope_instant = response.issue_instant().as_str();
let response_instant = session.response_issue_instant().as_str();
let assertion_instant = session.assertion_issue_instant().as_str();

IdpConfig::issuance_lifetime is a std::time::Duration. The default is 300 seconds. That duration is saml-rs policy; SAML does not require it. Zero, and a duration outside the internal time::Duration range, fail IdpConfig::validate and IdpConfigBuilder::build as SamlError::Invalid. A duration that cannot be added to the current UTC issue instant fails at issuance as SamlError::TimeWindowInvalid with TimeWindowField::IdpIssuanceExpiration.

Add the field to every public struct literal:

use std::time::Duration;
let config = IdpConfig {
entity_id,
metadata,
credentials,
issuance_lifetime: Duration::from_secs(300),
validation,
algorithms,
xml,
templates,
};

Builder call sites keep the default, or set another window:

use std::time::Duration;
let config = IdpConfig::builder(entity_id)
.sso_endpoint(sso)
.issuance_lifetime(Duration::from_secs(10 * 60))
.build()?;

Saml<Idp>::respond_sso and Saml<Idp>::initiate_sso capture the UTC clock once per response. The structured renderer and the placeholder renderer both receive that IssueInstant and the expiration derived from issuance_lifetime. A whole-second lifetime keeps the existing whole-second UTC form. A subsecond lifetime that the clock can represent keeps nanosecond precision in both values, so Conditions@NotOnOrAfter and bearer SubjectConfirmationData@NotOnOrAfter match.

The raw IdentityProvider surface keeps its five-minute lifetime and its custom replacement hook. Its standard renderers also use one captured instant.

Saml<Idp>::start_slo treats the local IdP as the SAML Session Authority and always emits an unqualified LogoutRequest@NotOnOrAfter on Redirect, POST, and POST-SimpleSign. The value is exactly issuance_lifetime after the same captured IssueInstant. Both instants are stored on PendingLogoutRequest and its snapshot. If you persist those snapshots, store issued_at and expires_at.

A custom template for that call must contain exactly one complete, unqualified root attribute:

NotOnOrAfter="{NotOnOrAfter}"

The rendered XML is validated before signing. The call fails before dispatch if the template omits the placeholder, repeats or qualifies the attribute, emits malformed XML, hard-codes another value, or changes the generated IssueInstant, subject, session indexes, destination, issuer, or ID. It also fails if the template supplies a root ds:Signature. The library places the POST XML signature. Redirect and POST-SimpleSign cover the validated XML with their detached signatures.

Saml<Sp>::start_slo stays a Session Participant flow. It does not generate NotOnOrAfter, and its pending issue and expiration fields stay unset. Raw create_logout_request and create_logout_request_with_id are unchanged and still omit a generated expiration. LOGOUT_REQUEST_TEMPLATE is unchanged. The Session Authority validator does not apply to SP custom templates.

Leave a generic inbound LogoutRequest valid when NotOnOrAfter is absent. That omission is still schema-valid. Do not reject it only because a Session Authority must set the attribute when it creates the request.

IssueInstant is required on the shared raw parser, on conversion to the typed LogoutRequest, on both typed receive_slo roles, and on HTTP-POST, HTTP-Redirect, and HTTP-POST-SimpleSign. NotOnOrAfter stays optional. When it is present, it uses the same unqualified UTC form.

Add IssueInstant. Add NotOnOrAfter only when the sender supplies an exclusive deadline:

<samlp:LogoutRequest
xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol"
ID="_request"
Version="2.0"
IssueInstant="2026-07-23T12:00:00Z"
NotOnOrAfter="2026-07-23T12:05:00Z">
<!-- Issuer and logout subject -->
</samlp:LogoutRequest>

A FlowResult converted to LogoutRequest needs request.issueInstant. Add request.notOnOrAfter only when the wire message has the attribute:

{
"request": {
"id": "_request",
"issueInstant": "2026-07-23T12:00:00Z",
"notOnOrAfter": "2026-07-23T12:05:00Z"
}
}

Optional accessors:

let issued_at = request.issue_instant().as_str();
let deadline = request.not_on_or_after().map(|instant| instant.as_str());

Update tests that used to accept an expired NotOnOrAfter. Expiration returns SamlError::TimeWindowInvalid with TimeWindowField::LogoutRequestNotOnOrAfter. The exclusive deadline is the supplied instant plus the configured NotOnOrAfter clock skew. Validation rejects when now is equal to or later than that deadline. That rejection is saml-rs policy; SAML does not require a receiver to reject it.

When typed replay storage is required and NotOnOrAfter is present, the cache entry expires at that same skew-adjusted deadline. When the attribute is absent, the existing replay_retention fallback still applies. ReplayPolicy::DisabledForCompatibility disables replay storage only. It does not disable expiration checks. No maximum age is derived from IssueInstant.

IssueInstant is required on HTTP-POST, HTTP-Redirect, and HTTP-POST-SimpleSign, including parse_logout_response, parse_logout_response_without_request_id, typed conversion from FlowResult, and typed finish_slo.

<samlp:LogoutResponse
xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol"
ID="_response"
Version="2.0"
IssueInstant="2026-07-16T23:13:10Z">
<samlp:Status>
<samlp:StatusCode
Value="urn:oasis:names:tc:SAML:2.0:status:Success"/>
</samlp:Status>
</samlp:LogoutResponse>

A hand-built FlowResult needs the same field:

{
"response": {
"id": "_response",
"issueInstant": "2026-07-16T23:13:10Z"
}
}

To read it:

let issue_instant = response.issue_instant().as_str();

These checks add no freshness window. Callers of parse_logout_response_without_request_id still own request correlation, replay protection, and any freshness policy of their own.

The XML rendered from TemplatePolicy::logout_response_template is validated before encoding or signing. Signed HTTP-POST output is validated again after signing. This checks what you generate. It does not add a new inbound rejection.

Skip this section when you use the standard LogoutResponse renderer and you do not call RespondSlo::signing. Review the template if it adds custom attributes or SAML children, omits Issuer, Status, Destination, or InResponseTo, or includes a root ds:Signature.

The rendered root must be a SAML protocol LogoutResponse with the generated ID, version 2.0, and a valid UTC IssueInstant. When the response answers a known request, InResponseTo must be present, use the XML Schema NCName form, and match that request. A signed response must set Destination to the selected SLO endpoint.

Direct children use this order:

  1. required SAML assertion Issuer;
  2. optional library-generated ds:Signature;
  3. optional protocol Extensions;
  4. exactly one final protocol Status.

Issuer must identify the responding entity. If Format is present, it must be urn:oasis:names:tc:SAML:2.0:nameid-format:entity, and the value must be an absolute URI of at most 1024 characters. Entity-format qualifiers are not allowed.

Extensions must contain at least one namespace-qualified element that is not a SAML assertion or protocol element. Status must contain exactly one leading StatusCode, then only an optional StatusMessage and an optional StatusDetail.

Keep the generated message and the template correlated:

<samlp:LogoutResponse
xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol"
xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion"
ID="{ID}"
Version="2.0"
IssueInstant="{IssueInstant}"
Destination="{Destination}"
InResponseTo="{InResponseTo}">
<saml:Issuer>{Issuer}</saml:Issuer>
<samlp:Status>
<samlp:StatusCode Value="{StatusCode}"/>
</samlp:Status>
</samlp:LogoutResponse>

Omit any root ds:Signature. saml-rs builds the embedded HTTP-POST signature, or the detached Redirect or SimpleSign signature, after the unsigned XML validates.

Malformed structure and lexical values return SamlError::ProtocolProfile. A generated ID mismatch returns SamlError::Invalid. Issuer, destination, and request-correlation mismatches keep their existing semantic errors. The failure happens before dispatch. There is no typed compatibility switch for a template that relied on undeclared attributes, the wrong child order, SAML content inside Extensions, an incomplete Status, or its own XML signature.

RespondSlo::signing is removed. Typed SLO responses are always signed, even when local validation is in compatibility mode.

Before:

let response = idp.respond_slo(
&sp,
&request,
RespondSlo::post().signing(LogoutSigning::DoNotSignForCompatibility),
)?;

After:

let response = idp.respond_slo(&sp, &request, RespondSlo::post())?;

The typed configuration needs a usable signing key and certificate. For an unsigned compatibility response, call saml_rs::raw::logout::create_logout_response with want_signed = false and keep that call outside the typed flow.

LogoutSigning remains on StartSlo. Request-signing call sites stay as they are.

Inbound parsers accept XML Schema second 60 on AuthnRequest, SSO Response and Assertion, LogoutRequest@IssueInstant, and LogoutResponse. The value is kept as sent. Standard generators still emit seconds 00 through 59.

A leap second in LogoutRequest@NotOnOrAfter can still fail deadline comparison as TimeWindowField::LogoutRequestNotOnOrAfter, because that attribute is compared with the runtime clock. That is deadline handling, not a general lexical rejection. Enforce any stricter local rule in application code after parsing.

  • Add a UTC IssueInstant ending in Z to every custom AuthnRequest.
  • Add request.issueInstant to hand-built FlowResult fixtures.
  • Add a UTC IssueInstant ending in Z to every custom SSO Response and selected Assertion.
  • Add response.issueInstant and assertion.issueInstant to hand-built SSO FlowResult fixtures.
  • Add issuance_lifetime to public IdpConfig struct literals, or keep the builder and its five-minute default.
  • Add exactly one unqualified NotOnOrAfter="{NotOnOrAfter}" attribute to custom LogoutRequest templates used by Saml<Idp>::start_slo.
  • Store both pending issued_at and expires_at for typed Session Authority logout snapshots.
  • Leave a generic inbound LogoutRequest valid when NotOnOrAfter is absent.
  • Add a UTC IssueInstant ending in Z to every custom LogoutRequest.
  • Add request.issueInstant to hand-built LogoutRequest fixtures, and request.notOnOrAfter when the wire message has it.
  • Update tests that previously accepted an expired LogoutRequest NotOnOrAfter.
  • Keep replay_retention for typed LogoutRequest replay when NotOnOrAfter is absent. When it is present, replay uses the skew-adjusted deadline.
  • Add a UTC IssueInstant ending in Z to every custom LogoutResponse.
  • Add response.issueInstant to hand-built LogoutResponse fixtures.
  • Update custom LogoutResponse templates: required root attributes, correlated InResponseTo, entity-format Issuer, and a final Status, in that child order.
  • Remove root ds:Signature elements from custom LogoutResponse templates.
  • Remove RespondSlo::signing(...). Typed responses are always signed.
  • Configure a signing key and certificate for every typed SLO responder.
  • Update tests that expected inbound leap seconds to be rejected.
  • Update tests or telemetry that expect another error for a malformed unsigned request or response.