NAME
Punk::Plugin::SAML - sign in with SAML 2.0 identity providers
SYNOPSIS
package MyApp;
use Punk;
use Punk::Plugin::SAML;
host 'https://app.example.com';
session secret => secret('session_key');
auth model => 'User';
plugin 'SAML' => {
secret => secret('saml.flow_key'),
};
saml_idp okta => {
metadata => 'https://example.okta.com/app/abc123/sso/saml/metadata',
};
saml_login '/saml' => { on_login => sub {
my ($c, $identity) = @_;
my $user = $c->model('User')->find_or_create_by_email(
$identity->{attributes}{email}[0]);
$c->login($user);
return; # the plugin redirects
} };
DESCRIPTION
This plugin signs users into a Punk application through a SAML 2.0 identity provider: their Okta, their Entra ID, their ADFS. It implements the Web Browser SSO profile as a service provider, with HTTP-Redirect outbound and HTTP-POST inbound, which is the combination every identity provider supports.
DEVELOPMENT OVER PLAIN HTTP IS NOT SUPPORTED
The identity provider answers by POSTing to this application from its own origin. That is a cross-site POST, and a cookie with SameSite=Lax is not sent on one: Lax covers top-level navigations with safe methods only. Punk's session cookie is Lax, as it should be, so at the moment the assertion arrives the session is not there.
So this plugin keeps its flow record in a cookie of its own with SameSite=None, which is the only value a browser sends on that request, and which browsers drop unless Secure is also set. Secure needs https. That is why the plugin refuses to boot when host is not https, and it refuses loudly: over plain http nothing fails at startup, every login fails at the assertion consumer as unsolicited, and nothing in the log says why.
Two ways to work locally:
a local
httpscertificate, andhost 'https://localhost:5000'Punk::Test, which does not go through a browser and has no
SameSiteto contend with
OPTIONS
Given to plugin 'SAML' = { ... }>. An unknown option croaks, naming what was available.
secret-
Required, and the plugin croaks at the
pluginline without it. It signs the flow cookie. A secret minted per worker fails one login in four the moment a redirect lands on a worker other than the one that started it, and that failure looks like an intermittent fault at the identity provider rather than a missing option.punk saml keygenerates one.A list rotates: the first signs, all of them verify, so a key can be replaced with both live.
entity_id-
How the identity provider knows this application. Defaults to this application's metadata URL, which is the convention every provider's setup screen expects.
prefix-
Where this plugin's own paths live. Default
/saml. key,cert-
The service provider's private key and certificate, in PEM. Needed only for
sign_requestsandsign_metadata. sign_requests-
Sign the
AuthnRequeston the redirect. Croaks withoutkey, because an option that silently did nothing would have told the deployer the opposite of the truth. Turns itself on when a provider's metadata saysWantAuthnRequestsSigned="true", and croaks then if there is nokey, because every login would otherwise fail. sign_metadata-
Put an enveloped signature on the published metadata, for the providers that refuse unsigned metadata by policy. Croaks without both
keyandcert. require_signed-
One of
assertion,response,eitherorboth. Defaulteither: a verified signature on theResponseor on theAssertionsatisfies it, because providers differ on which they sign by default and a fresh integration should not fail on that. A signature on theResponsecovers theAssertioninside it. An unrecognised word is refused rather than defaulted, since a misspelling that quietly meanteitherwould be a weaker check than the deployment asked for. Failure codeno_signature. skew-
Seconds of clock disagreement tolerated, on both edges. Default 120, because identity providers' clocks are worse than anyone expects. An
expiredin the log against an assertion that looks fresh usually means the two clocks differ by more than this. flow_ttl-
Seconds a login may take, from the redirect to the assertion. Default 600. Also the flow cookie's
Max-Age. max_response-
Bytes of
SAMLResponseaccepted before decoding. Default 262144, which is ten times the largest realResponseanyone has seen. The cap is on the encoded field because that is the byte count an attacker controls at the door. Failure codebad_base64. allow_sha1-
Permits a SHA-1
DigestMethod. It does not permit anrsa-sha1signature, which is refused whatever this is set to. Failure codealg_refused. allow_idp_initiated-
See "IDP-INITIATED SIGN-IN". Off by default. Failure code
unsolicited. default_to-
Where a login lands when nothing said otherwise. Default
/. name_id_format-
Sent as
NameIDPolicy. Unset by default, which lets the provider choose, and that is what works everywhere: a format the provider cannot satisfy comes back as aResponderstatus and a failed login. force_authn-
Ask the provider to re-authenticate even if the user has a session there.
metadata-
Serve this application's metadata at
<prefix>/metadata. On by default: an application that does not publish its metadata has to be configured by hand at the other end, every time. metadata_refresh-
Seconds between refetches of a provider's metadata on an unknown signer. Default 3600. See "KEY ROTATION".
render-
A coderef or the name of a context method, replacing the failure page.
host is not an option of this plugin but it is required by it: the plugin croaks at boot when the application has no host, or when it is not https. The assertion consumer URL derives from it and the identity provider compares that URL against what it was configured with character for character, so it cannot be guessed from a request header.
PROVIDERS
saml_idp okta => { metadata => 'https://.../metadata' };
saml_idp entra => { metadata => 'file:/etc/myapp/entra.xml' };
saml_idp adfs => {
entity_id => 'http://adfs.example.com/adfs/services/trust',
sso_url => 'https://adfs.example.com/adfs/ls/',
certs => [ $pem1, $pem2 ],
};
Repeatable; the name is what the login route uses. metadata and the explicit trio are exclusive, and giving both croaks rather than letting one silently decide which key verifies an assertion.
Metadata is fetched or read once, at boot, before the server forks. A fetch that fails at boot is a croak, not a warning: an application whose only login is SAML cannot sign anyone in without its provider, and refusing to start says so at deploy time, in the deploy log, to the person deploying. A warning would say it at the first login, to a user, as a 403.
What is read: the entity id; the SingleSignOnService with the HTTP-Redirect binding; every KeyDescriptor that is for signing; the NameIDFormats, which are kept for punk saml idp to print; WantAuthnRequestsSigned.
A KeyDescriptor counts as a signing key when use="signing" or when there is no use attribute at all - the specification says a missing use means the key serves both purposes, and providers omit it constantly. All of them are kept, in the order published, and every one is tried: a provider mid-rotation publishes two.
A provider that offers only the HTTP-POST binding for single sign-on is refused at boot. This plugin sends by redirect, and a request sent by redirect to a POST-only endpoint is an error the provider phrases unhelpfully, at a user, hours later.
Federation metadata - an EntitiesDescriptor holding several entities - needs entity_id naming which one. Without it the refusal lists the ids found, so it can be fixed in one step.
Certificate validity
Certificate validity dates are not enforced by default. Providers routinely sign with certificates that expired months ago, the trust here is the certificate the deployment configured rather than a chain, and refusing one helps nobody. enforce_cert_validity turns the check on for a deployment whose policy requires it. punk saml idp prints the fingerprints so an operator can see a rotation coming.
THE LOGIN
saml_login '/saml' => {
on_login => sub { my ($c, $identity) = @_; ... }, # required
on_error => sub { my ($c, $error) = @_; ... }, # optional
};
Declared once. One application has one login mount, and every provider lives under it.
on_login receives the context and the verified identity. It returns a response to answer for itself, or nothing, and the plugin redirects to wherever the login started.
An on_login that does not call $c->login has verified an assertion and signed nobody in. That is legal - an application may want the identity for something other than a session - but it is the common mistake, and it looks like a login that works followed by a guard that refuses.
The identity
{
idp => 'okta',
name_id => 'jo@example.com',
name_id_format => 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress',
attributes => { email => ['jo@example.com'],
groups => ['staff', 'eng'] },
friendly => { 'E-Mail Address' => ['jo@example.com'] },
session_index => '_a1b2...',
authn_instant => 1725600000,
not_on_or_after => 1725600300,
assertion_id => '_c3d4...',
raw => $xml_bytes,
}
Every attribute value is an arrayref, always, including when there is one value. SAML attributes are multi-valued and groups is the one everybody uses; an API that returned a scalar for one value and an arrayref for two would be the bug in every on_login body on the day a user joins a second group. A provider that sends the same Name twice has its values merged rather than replaced.
friendly is the same map keyed by FriendlyName, for the providers that set one, because the Name some providers send is a URL nobody wants to type.
Authorise on attributes, never on friendly. Name is the identifier and is unique; FriendlyName is a display label the provider may put on more than one attribute, and when it does, their values are merged under that one key. Two attributes meaning different things can therefore arrive as one entry in friendly. It is for showing to a person, not for deciding what one may do.
raw is the bytes as received, for an application that must keep an audit record of what it accepted. session_index is recorded so that a later release can add logout without a schema change.
Composing with Punk::Auth
auth_guard needs nothing new: it looks at the session, $c->login wrote it, and a SAML user is a user.
saml_login '/saml' => { on_login => sub {
my ($c, $identity) = @_;
my $email = $identity->{attributes}{email}[0]
or return $c->text('no email attribute from the provider', 403);
$c->login(find_or_create_by_email($c, $email));
return;
} };
WHAT IS CHECKED
In order. Cheap before expensive, structural before cryptographic, and the signature before anything the signature protects is read. Each check names the code it fails with.
The encoded
SAMLResponseis withinmax_response-bad_base64It is strict standard base64. Line wrapping is accepted, because several providers wrap at 76 columns; the base64url alphabet is not -
bad_base64It parses as XML. A
DOCTYPEis refused wherever it stands, so external entities, XXE and entity expansion are not reachable from a request at all -xml_parseTwo elements carrying the same
IDare refused at parse, before anything is verified: "which element does this signature name" is the whole of the wrapping attack -xml_parseThe root is
samlp:Responseand itsVersionis2.0-xml_shapeThe
StatusCodeisSuccess. The nested code and anyStatusMessagego to the log -statusThere is no
EncryptedAssertion-encrypted_assertionThere is exactly one
Assertion-xml_shapeA signature is present as required by
require_signed-no_signatureEvery signature present verifies: the algorithm is accepted (
alg_refused), the reference digest matches (bad_digest), and a configured key verifies it (bad_signature, orno_keywhen none is configured)The
Issueris the provider's entity id -unknown_issuerThe
Destination, when present, is this application's assertion consumer URL -bad_destinationConditions/@NotBeforeis not in the future beyondskew-not_yet_validConditions/@NotOnOrAfterhas not passed, allowingskew-expiredAn
AudienceRestrictionnames this application's entity id -bad_audienceThere is a
NameIDand a bearerSubjectConfirmation-xml_shapeThe
SubjectConfirmationData/@Recipientis this application's assertion consumer URL, and its window has not closed -bad_destination,expiredThe
InResponseTomatches the login this application started -bad_in_response_toA response to no login at all is refused unless
allow_idp_initiated-unsolicitedThere is an
AuthnStatement, which is what makes this an authentication rather than an attribute query -xml_shapeThe assertion has not been presented before -
replayEvery timestamp is a well-formed
xs:dateTimein UTC. A timezone offset is refused: Core section 1.3.3 requires UTC -bad_datetime
Every one of these produces the same page for the browser: "sign-in failed" and a link to try again. The code is written to the log with the provider name. The reason never reaches the browser, because a verifier that tells the far side which check it failed is telling an attacker which one to work on next.
The signature rule
The reason SAML libraries have had a decade of authentication bypasses is that the signed thing and the read thing were allowed to differ. An attacker takes a signed assertion for their own account, adds an unsigned one for the administrator's, and arranges the document so that the verifier finds one and the attribute reader finds the other. Every published variant of the attack is that.
The rule here, applied without exception:
The element whose child the signature is, is the element the signature covers, is the element the identity is read from.
Concretely: a ds:Signature counts only as a direct child, so one placed inside Extensions, inside Subject or inside another assertion's Advice is not looked at and makes nothing signed. There must be exactly one Reference, and its URI must be # plus the ID of the element the signature is a child of - and that is checked by identity, not by comparing id strings, so an element that merely looks the same will not do. The transforms must be exactly enveloped-signature then exclusive canonicalisation: XPath, XSLT and base64 transforms are refused, because those are the transforms that let a signature cover something other than what it appears to. KeyInfo in the message is never trusted; every key tried is one the deployment configured.
Once the signature is proven, the identity is read from that element and no other. There is no second lookup and no search from the document root.
KEY ROTATION
Providers rotate signing keys by publishing the new certificate in metadata ahead of the switch. Every certificate a provider publishes is kept and every one is tried, so a rotation that is published in advance works without any action here.
A rotation that is not published in advance is handled by re-reading the metadata. When a Response fails because no configured key verifies its signature, and the provider was configured with metadata, the metadata is read again and the Response is verified once more against whatever it now publishes. A provider given as an explicit entity_id/sso_url/certs trio is never refetched: those are the values the deployment supplied, and they are not replaced from the network.
The refetch is rate limited by metadata_refresh, which defaults to an hour. That is not politeness. A failed signature is also what a forged Response produces, so without a limit anyone able to POST to the assertion consumer URL could make this application fetch a URL on demand, as often as they liked. The limit is held per worker process, so a deployment running N workers permits at most N refetches per interval.
Set metadata_refresh to 0 to turn the refetch off entirely and require a restart after an unannounced rotation.
A failure to reach the provider during a refetch is not an error page: it is logged, and the sign-in is refused as it would have been anyway.
enforce_cert_validity makes an expired certificate a refusal instead of a warning; see "Certificate validity" for why it is off.
IDP-INITIATED SIGN-IN
A Response that answers no request this application made. Off by default, and the default is the interesting part.
An attacker who can get a victim's browser to POST a valid assertion for the attacker's account signs the victim into the attacker's account. Everything the victim then does - a document uploaded, an address typed, a card added - happens in an account the attacker controls and can read at leisure. It is a quiet attack and it does not look like one from the inside.
Turning it on is a decision that the identity provider is trusted to that degree, and that the flow record, which is single use and is the defence against a replayed assertion in an application-initiated login, is not needed. With it on, a login lands at default_to, or at the provider's RelayState after $c->safe_path has flattened it.
WHAT IS NOT SUPPORTED
- Single logout
-
Not offered.
$c->logoutis local logout: it ends this application's session and leaves the session at the identity provider alone, which is what nearly every service provider actually does.Front-channel single logout asks the provider to send a
LogoutRequestthrough the user's browser to every service provider in the session and wait for each answer; it fails if any one of them is slow, down or buggy, and the user's browser is the message bus. Back-channel single logout needs a SOAP endpoint. Most deployments have it configured and few have it working.session_indexandname_idare recorded in the session so that a later release can add service-provider-initiated logout without a schema change. - Encrypted assertions
-
Refused, with code
encrypted_assertion, and the message names the setting to turn off at the provider. XML encryption is a second cryptographic surface with its own history of padding-oracle attacks, and the transport is already TLS. rsa-sha1signatures-
Refused, with code
alg_refused, whateverallow_sha1is set to.allow_sha1reaches theDigestMethodonly, which is the less harmful of the two. Every identity provider deployed today signs with SHA-256 by default. - SHA-384 and SHA-512 digests
-
Recognised and refused, with code
alg_refused. Signature algorithms RS/ES 256, 384 and 512 are all accepted; it is theDigestMethodside that is limited to SHA-256, which is what every provider in the field uses. - The artifact binding, the SOAP binding, attribute queries
-
Not offered. They need a back-channel client, and nobody deploys them.
AttributeQuery, just-in-time provisioning, SCIM, group sync-
Not this plugin's. The attributes it delivers are what those are built from.
COMMANDS
punk saml key
A secret for the flow cookie, which is what the secret croak names.
punk saml metadata
This application's metadata, for the operator setting up the provider with no server running.
$ punk saml metadata
<md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata"
entityID="https://app.example.com/saml/metadata">
<md:SPSSODescriptor AuthnRequestsSigned="false"
WantAssertionsSigned="true"
protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
<md:AssertionConsumerService index="0" isDefault="true"
Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
Location="https://app.example.com/saml/acs"/>
</md:SPSSODescriptor>
</md:EntityDescriptor>
punk saml idp <file-or-url>
What this plugin reads from a provider's metadata. What to run when a provider's metadata was refused, and what to paste into a ticket to their administrator.
$ punk saml idp /tmp/okta-metadata.xml
entity_id https://idp.example.com/entity
sso_url https://idp.example.com/sso
signed WantAuthnRequestsSigned=false
cert 0 sha256:8a3093e2c8b880683ce231d950ea23915ae5031dd667580c69d8be82de7e6120
nameid urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress
Exit status 0 when the metadata was read, 1 when it was refused, and the refusal prints as refused: code: message.
punk saml verify <file>
The phase-6 checks over a saved SAMLResponse, the base64 as it came or the XML, printing the first refusal with its code. --at moves the clock, so an assertion saved yesterday can be checked today.
This is the tool for the support ticket that says "SSO stopped working". The operator saves the POST from the browser's network tab, runs this, and the answer is audience or expired or unknown_issuer. The ticket is then answerable without a reproduction.
SETTING UP A PROVIDER
Two values go from this application into the provider's console, and one comes back.
The assertion consumer URL, which is
hostplus the login mount plus/acs. Providers call it the ACS URL, the Reply URL, or the Single Sign-On URL. Runpunk saml metadataand read theAssertionConsumerServiceLocation; that exact string is what the provider must be given, because it compares it character for character.The entity id, which defaults to this application's metadata URL. Providers call it the Entity ID, the Identifier, or the Audience URI. It is the
entityIDattribute in the same document.The provider's metadata URL comes back, and goes into
saml_idp. Every provider can produce one; some of them put it behind a login, in which case download it once and usefile:, or use the explicitentity_id/sso_url/certsform.
Then punk saml idp <their-metadata> before starting the application, which reads the metadata the same way the plugin will and prints what it found. A provider that this plugin cannot use fails there, at a terminal, rather than at a user's first login.
Per-provider notes
These subsections are unverified. They describe what each provider calls the two values above, and nothing has been confirmed against a real tenant of any of them. Console layouts and default attribute names change, and a walkthrough that reads as observed and is not is worse than an honest gap. Treat them as a starting vocabulary, not as instructions.
- Okta
-
The application is created as a SAML 2.0 app. Okta's "Single sign on URL" is the assertion consumer URL and its "Audience URI (SP Entity ID)" is the entity id. Attribute statements are configured explicitly, so the attribute carrying the email address is whatever the administrator named it - check with
punk saml verifyagainst a real assertion rather than assuming. - Entra ID
-
Formerly Azure AD. The "Reply URL (Assertion Consumer Service URL)" is the assertion consumer URL and the "Identifier (Entity ID)" is the entity id. Entra names attributes with schema URLs rather than short names, which is what
friendlyexists for. - Keycloak
-
A client in a realm. The "Valid redirect URIs" and "Master SAML Processing URL" are involved; which one carries the assertion consumer URL depends on the version. Keycloak publishes descriptor metadata per realm.
- ADFS
-
A relying party trust. The assertion consumer URL is an endpoint of type "SAML Assertion Consumer" with the POST binding; the entity id is the relying party identifier. Claim rules decide which attributes are sent, and by default there may be none.
METHODS
Called by Punk's plugin keyword rather than by an application.
new
register ($app, \%opts)
Validates the options, records them on the application, and installs the keywords and helpers. The keywords are also installed by use Punk::Plugin::SAML, which is what makes them available to the parser: saml_idp and saml_login are bareword calls, and the plugin line runs too late to help a line that has already been compiled.
SEE ALSO
Punk, Punk::Auth, Punk::OAuth2, Punk::SAML::Response, Punk::SAML::Error, File::Raw::XML, Crypt::JWS.
Specifications: SAML 2.0 Core, Bindings (section 3.4.4.1 for the redirect signature), Profiles (section 4.1 for Web Browser SSO), Metadata, and Exclusive XML Canonicalization 1.0.
AUTHOR
LNATION, <email at lnation.org>
LICENSE AND COPYRIGHT
This software is Copyright (c) 2026 by LNATION.
This is free software, licensed under the Artistic License 2.0.