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.
Timestamps
Section titled “Timestamps”Apply this wherever a section below requires IssueInstant or
NotOnOrAfter:
- The attribute is unqualified.
saml:IssueInstantandsamlp:IssueInstantdo not count. - The value is a UTC
xs:dateTimeending inZ. Fractional seconds are accepted.+00:00is not a substitute forZ. - 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.
AuthnRequest
Section titled “AuthnRequest”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();Response and assertion
Section titled “Response and assertion”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
Section titled “IdpConfig”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.
Logout request
Section titled “Logout request”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.
Inbound logout
Section titled “Inbound logout”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.
Logout response
Section titled “Logout response”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.
Response template
Section titled “Response template”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:
- required SAML assertion
Issuer; - optional library-generated
ds:Signature; - optional protocol
Extensions; - 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.
Signing
Section titled “Signing”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.
Leap seconds
Section titled “Leap seconds”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.
Checklist
Section titled “Checklist”- Add a UTC
IssueInstantending inZto every customAuthnRequest. - Add
request.issueInstantto hand-builtFlowResultfixtures. - Add a UTC
IssueInstantending inZto every custom SSOResponseand selectedAssertion. - Add
response.issueInstantandassertion.issueInstantto hand-built SSOFlowResultfixtures. - Add
issuance_lifetimeto publicIdpConfigstruct literals, or keep the builder and its five-minute default. - Add exactly one unqualified
NotOnOrAfter="{NotOnOrAfter}"attribute to custom LogoutRequest templates used bySaml<Idp>::start_slo. - Store both pending
issued_atandexpires_atfor typed Session Authority logout snapshots. - Leave a generic inbound LogoutRequest valid when
NotOnOrAfteris absent. - Add a UTC
IssueInstantending inZto every customLogoutRequest. - Add
request.issueInstantto hand-built LogoutRequest fixtures, andrequest.notOnOrAfterwhen the wire message has it. - Update tests that previously accepted an expired LogoutRequest
NotOnOrAfter. - Keep
replay_retentionfor typed LogoutRequest replay whenNotOnOrAfteris absent. When it is present, replay uses the skew-adjusted deadline. - Add a UTC
IssueInstantending inZto every customLogoutResponse. - Add
response.issueInstantto hand-built LogoutResponse fixtures. - Update custom LogoutResponse templates: required root attributes, correlated
InResponseTo, entity-formatIssuer, and a finalStatus, in that child order. - Remove root
ds:Signatureelements 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.