Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
138 changes: 138 additions & 0 deletions spec/controllers/saml_callbacks_spec.cr
Original file line number Diff line number Diff line change
Expand Up @@ -143,5 +143,143 @@ module PlaceOS::Auth
result.body.should contain "oauth state mismatch"
end
end

# ---- signature enforcement + continue (matrix ID-03, ID-02/SAML) ----
#
# The header note above says the callback round-trip is covered by
# `multi_auth_saml`'s own suite. That holds for the LIBRARY, but not for
# auth.cr's configuration of it, which is our code and is conditional:
#
# want_assertions_signed: strat.idp_cert.presence || fingerprint ? true : false
# want_signature_validated: strat.idp_cert.presence || fingerprint ? true : false
#
# and `crystal-saml`'s validate_signature short-circuits `return true`
# when the document carries no <ds:Signature> at all. Whether a forged
# assertion is rejected therefore depends on OUR strat config, so it has
# to be pinned here. SAML is live for real clients, so this is not
# theoretical.
#
# Assertions are minted fresh by `Spec::SamlFixtures` with current
# timestamps — the shard's canned fixtures expired years ago and its own
# specs only use them with a 10-year clock drift that auth.cr (rightly)
# does not set.

describe "assertion signature enforcement", tags: "saml-signature" do
acs = "http://localhost/auth/adfs/callback"

signed_strat = ->(with_cert : Bool) {
authority = ::PlaceOS::Model::Authority.find_by_domain("localhost").not_nil!
strat = ::PlaceOS::Model::SamlAuthentication.new
strat.name = "sig-saml-#{Random.rand(99999)}"
strat.issuer = "https://sp.example.test/sig-#{Random.rand(99999)}"
strat.idp_sso_target_url = "https://idp.example.test/sso"
strat.assertion_consumer_service_url = acs
strat.uid_attribute = "email"
strat.idp_cert = Spec::SamlFixtures.idp_cert_pem if with_cert
strat.authority_id = authority.id
strat.save!
strat
}

post_assertion = ->(strat : ::PlaceOS::Model::SamlAuthentication, saml_response : String) {
client.post(
"/auth/adfs/callback?id=#{URI.encode_www_form(strat.id.as(String))}",
headers: HTTP::Headers{
"Host" => "localhost",
"Content-Type" => "application/x-www-form-urlencoded",
},
body: "SAMLResponse=#{URI.encode_www_form(saml_response)}&RelayState=#{URI.encode_www_form("/backoffice/")}",
)
}

build = ->(strat : ::PlaceOS::Model::SamlAuthentication, email : String) {
Spec::SamlFixtures.response_xml(
acs_url: acs,
audience: strat.issuer.as(String),
email: email,
)
}

# A forged assertion must not establish an identity. Rejection may
# surface as a 4xx or as a redirect to /auth/failure — both are fine.
# What is NOT fine is a success redirect or a created UserAuthLookup.
# On failure, report exactly what came back so the result is
# diagnosable from CI rather than guessed at.
reject_or_explain = ->(result : HTTP::Client::Response, email : String) {
lookup = ::PlaceOS::Model::UserAuthLookup.where(uid: email, provider: "adfs").first?
location = result.headers["Location"]?
rejected = result.status_code >= 400 || location.try(&.includes?("/auth/failure")) || false

if lookup || !rejected
fail "FORGED ASSERTION WAS NOT REJECTED — status=#{result.status_code} " \
"location=#{location.inspect} lookup_created=#{!lookup.nil?} " \
"body=#{result.body[0, 200].inspect}"
end
lookup.should be_nil
}

it "accepts an assertion correctly signed by the configured IdP cert" do
strat = signed_strat.call(true)
email = "saml-ok-#{Random.rand(99999)}@localhost"
xml = Spec::SamlFixtures.signed(build.call(strat, email))

result = post_assertion.call(strat, Spec::SamlFixtures.encode(xml))

# a genuine assertion must NOT be bounced to the failure page
result.headers["Location"]?.try(&.includes?("/auth/failure")).should_not be_true
ensure
strat.try &.destroy
end

it "REJECTS an assertion signed by a different (attacker) keypair" do
strat = signed_strat.call(true)
email = "saml-attacker-#{Random.rand(99999)}@localhost"
# well-formed XML, cryptographically valid signature — wrong signer
xml = Spec::SamlFixtures.signed_by_attacker(build.call(strat, email))

result = post_assertion.call(strat, Spec::SamlFixtures.encode(xml))

# "Rejected" can legitimately be a 4xx OR a redirect to /auth/failure.
# The load-bearing invariant is that NO identity was established.
reject_or_explain.call(result, email)
ensure
strat.try &.destroy
end

it "REJECTS an entirely unsigned assertion when a cert is configured" do
strat = signed_strat.call(true)
email = "saml-unsigned-#{Random.rand(99999)}@localhost"
# no <ds:Signature> node at all — crystal-saml's validate_signature
# short-circuits `return true` on a missing signature, so the only
# thing standing between this and a forged login is want_assertions_signed
xml = build.call(strat, email)

result = post_assertion.call(strat, Spec::SamlFixtures.encode(xml))

reject_or_explain.call(result, email)
ensure
strat.try &.destroy
end

it "FAILS CLOSED when the strat has no cert or fingerprint to verify against" do
# Both SAML strats on the dev server are configured exactly like this.
# Previously `want_signature_validated` / `want_assertions_signed` both
# evaluated to false here, so NOTHING was checked and any assertion
# authenticated. A strat with no trust anchor cannot verify anything,
# so it must refuse rather than accept — the legacy Ruby service's
# check does not depend on cert presence either.
strat = signed_strat.call(false)
email = "saml-nocert-#{Random.rand(99999)}@localhost"
# even a properly signed assertion must be refused: we have nothing to
# check it against, so "valid signature" is unknowable.
xml = Spec::SamlFixtures.signed(build.call(strat, email))

result = post_assertion.call(strat, Spec::SamlFixtures.encode(xml))

reject_or_explain.call(result, email)
ensure
strat.try &.destroy
end
end
end
end
19 changes: 19 additions & 0 deletions spec/fixtures/saml/attacker_cert.pem
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
-----BEGIN CERTIFICATE-----
MIIDIzCCAgugAwIBAgIUDJJfyNyqPwdmN8iCLEUYcTH7KQAwDQYJKoZIhvcNAQEL
BQAwIDEeMBwGA1UEAwwVUFBULTI1MzYgQXR0YWNrZXIgSWRQMCAXDTI2MDczMDAz
NTExN1oYDzIxMjYwNzA2MDM1MTE3WjAgMR4wHAYDVQQDDBVQUFQtMjUzNiBBdHRh
Y2tlciBJZFAwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDJleehzuIB
zD5h558La2R7HvkxZxhv3Zbo9em/IYjf8Hgxm+Xq/9CUYslady8nEOMxppxid0LH
quRD+eTL7xIojgjUwxiHe+Sfnapzj3e8klIQddRjwuAGpiqg/lb/4pXpWvwH13zl
q2GZgJ732YJyFjJT15suaBxv1ETAI1d8fWMtnnxhTcI1wJnSO5ZM8m1GLzyl/XlQ
ixTirhIXZ8d6JvDWE02yn3kjADl+uCebjOMgLQXN4Qlc/zbP70FL32xTKSqxtCM2
8y2JMNpxJCGvxXcaf5Y1pPAJWXN7x9qSKH/nP4iH343ISJxf1UQ2cc8w1nZR29tn
qgVg8XyGowxvAgMBAAGjUzBRMB0GA1UdDgQWBBTGWkgPfqJTKSsBy9NDoElAePJd
UjAfBgNVHSMEGDAWgBTGWkgPfqJTKSsBy9NDoElAePJdUjAPBgNVHRMBAf8EBTAD
AQH/MA0GCSqGSIb3DQEBCwUAA4IBAQAjKCmZcJBdjpsmrPQlw6PypsXM0FjZJLZp
AYxjqNLjurMcDTQhRrB6LzxWEtX0jSvewWUpYrL9vDc6lNlRWZhGSPrkNVRTe04f
JzSUTf9avhMl6MLBLA7Ofr7uZlxodC29N/LHaONjtyxSnRV9VJaTYAGLiRQe9i7I
IZlbbJKwZPgDh2T9/f214anUKZ4Nq8d7fP9/bWRfn2OOzHggO7WaLLmi2V6ziPJp
pf0uQxgdQFSDf9LUkiKXvL+966zj3BjCJSWqzv3uY8bVyFrjpVI1xM17X7oBXxtw
y3wd2RZXounD/3a0eMV28IQ3KQqTAkMQnrYFqHZgnhNcm8huQZzZ
-----END CERTIFICATE-----
28 changes: 28 additions & 0 deletions spec/fixtures/saml/attacker_key.pem
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
-----BEGIN PRIVATE KEY-----
MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQDJleehzuIBzD5h
558La2R7HvkxZxhv3Zbo9em/IYjf8Hgxm+Xq/9CUYslady8nEOMxppxid0LHquRD
+eTL7xIojgjUwxiHe+Sfnapzj3e8klIQddRjwuAGpiqg/lb/4pXpWvwH13zlq2GZ
gJ732YJyFjJT15suaBxv1ETAI1d8fWMtnnxhTcI1wJnSO5ZM8m1GLzyl/XlQixTi
rhIXZ8d6JvDWE02yn3kjADl+uCebjOMgLQXN4Qlc/zbP70FL32xTKSqxtCM28y2J
MNpxJCGvxXcaf5Y1pPAJWXN7x9qSKH/nP4iH343ISJxf1UQ2cc8w1nZR29tnqgVg
8XyGowxvAgMBAAECggEAKQ9sp+vA40bb6gtIvQW56M4tSd/K/XYwN/FnFDRbcenE
Qa6UjWJf9OmOQ+wE4bsx27opHnFWW982MZZkARoII9SSfevjQi19Kvntby5o4DMZ
nhqyEUPvZ8tO4Y4M4IAhsu+CaIJ/ATUAY0jsmGVoiZ51z9gZ1sEEEc811cEdH9D5
t3J5qvIgWykneXqevaV38t1yE87hmCeEwjnqw8FbFMk/VOBk/fhaIAXtsMr2wln/
sTgMDFF4JwlFm8N1Tj6e9H3dd6HRkmcHmm6nBd0TUSJT+fBUgSq6+opFNfci/+qX
2ySmtSgquW1fdVsYiidKO7n1KISsOlqbM8rt2aBtMQKBgQDyPd/0q8cI8heOoN8K
FARXx43p+ULgSxG95X0yDBqeUVtyoEdwtMmUTlsuXnl34oqnPZDfvR4s+ZuEcGD5
w1YMu5uBmf3I8xkr0ZYg4hLERe/fqQEakKU8jBc7FXlemS27EGE3P9DqEJiNdBaY
3T3VGlKWHL8OxQUzUe9wb+Rm1wKBgQDVCObDIcl5g0zw+/g56FegFXQQswoBADyC
swmyHtJe32plLmFCG3ovdk9Jjc/23XNXfx9MWxovoL/LNRh4joCRLspR01u8Mzt8
VD20lP3ldE/iWGxaDiT1zL6XSQQTjN1ktS7doDfpZaUIjyU2l1xH0Fw8+pr0Hwg0
6SsYBpeMKQKBgGukdxpAIsek3Dby4WmXD376G+O2cbM9aHrbuVA99K4ZNOuwsU4x
58lRME9gh9VwA3DheLkTTo9ps2OclFsoI9qwmx9yEEX1UGAaV/3wt6oj8b2PWgA1
+lb3YXNNzYrtwlZ3pttoLit0iHWC+m+fEFTbx4sQ5w1nGzJ83Es4TWQtAoGAJaKu
8eY0K81weFprXf+YC2X+3TxkFg0uo4BxxBAB5lTd/QYWlJE6Isxp8XqmNNymldyV
cO3fUG67s3Cr5BB40i1L/Oy0FimO5sYTKKXxvOmxezFX4wkAqpQDT8LMf4+xRStl
Hi5B8m/Mmhh/4IcY/G7o/LThTyN0VbSjsljnyRECgYBAGgwRmCd2+U5RWMgcLdtR
l3aZUA1nH7SKHVfQfjw+Sz0JS80EN2siv7JemzqyqZuduvB36QJjT+BOLRPCvFBk
mX2jVKUDua/Lo+cNE/cvdtrXpBhxpwbx9JOibnwWSq4BW0EnUo+3aNQ26fAoH1QM
xjeoqU7v7bt5G2+ZaEIjWw==
-----END PRIVATE KEY-----
19 changes: 19 additions & 0 deletions spec/fixtures/saml/idp_cert.pem
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
-----BEGIN CERTIFICATE-----
MIIDGzCCAgOgAwIBAgIUNNHCD4987Dpw8Io7B5krKTrtSggwDQYJKoZIhvcNAQEL
BQAwHDEaMBgGA1UEAwwRUFBULTI1MzYgVGVzdCBJZFAwIBcNMjYwNzMwMDM1MTE3
WhgPMjEyNjA3MDYwMzUxMTdaMBwxGjAYBgNVBAMMEVBQVC0yNTM2IFRlc3QgSWRQ
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAjhBk9kSU1MoV2vctQIZH
i9mo+POXeaot54bktpg74rQ4f9TFw5gdg2aaWCB/rv/q15/22pr8TtyIXnfBTY3e
cU7/ryF7S+k+AZFsuvZjeidnOASaDpJyazWby9AoLG7917bANBXBOk+oDFtr6c8b
6O8w5MxjQEH9HjQxjcS/xAIThQ1IOg+ZNcc5Txut6cfmZDp79fCPPXwjnJ0dXYyf
++39qMR3b+J78ft3s0LHe36PZxWgLLq+k5WZLIuioPfUjNAz5GXnZXBsC8HWCNRq
4CfcwCBAtV9PQiM5P2ZoTKTVvV29StQlc1aH88yTAt3OxfAAsN6cFZ61nN6lPTav
mwIDAQABo1MwUTAdBgNVHQ4EFgQUUesfdGugmsqulbUXmp60TUQ1a8gwHwYDVR0j
BBgwFoAUUesfdGugmsqulbUXmp60TUQ1a8gwDwYDVR0TAQH/BAUwAwEB/zANBgkq
hkiG9w0BAQsFAAOCAQEAhjIS2lXlfZyzgZSXoBKvXgaBL9NcsfDHboq18zyquvMy
dp/aAwg/VxKNjvTgrY+PS+o+ouGhwSPQnUN+mEaCSbyBM9I2pBSQa5MTzHU+4V0l
khXmELWC2RzftIaY41yxYnS962he6OADoEQnuqtCtLmvUVPfUBP8YXWwH5hKDX3O
bPi4RIk0mbYcIMCDh4FQUgRFqIJRWlORb/M1fCuKCyCak3mMgQYZb9JMG5jZoukg
zA31K7J4bLI6P8gQEk+B55tJQj7hp64WlM3CvglYzMXkjJzni6NnZLejbcb48bFI
KQ/gHJsdVR/iwD5WgEjTn9y25lwpjHdvcf39uQKiGw==
-----END CERTIFICATE-----
28 changes: 28 additions & 0 deletions spec/fixtures/saml/idp_key.pem
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
-----BEGIN PRIVATE KEY-----
MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCOEGT2RJTUyhXa
9y1AhkeL2aj485d5qi3nhuS2mDvitDh/1MXDmB2DZppYIH+u/+rXn/bamvxO3Ihe
d8FNjd5xTv+vIXtL6T4BkWy69mN6J2c4BJoOknJrNZvL0Cgsbv3XtsA0FcE6T6gM
W2vpzxvo7zDkzGNAQf0eNDGNxL/EAhOFDUg6D5k1xzlPG63px+ZkOnv18I89fCOc
nR1djJ/77f2oxHdv4nvx+3ezQsd7fo9nFaAsur6TlZksi6Kg99SM0DPkZedlcGwL
wdYI1GrgJ9zAIEC1X09CIzk/ZmhMpNW9Xb1K1CVzVofzzJMC3c7F8ACw3pwVnrWc
3qU9Nq+bAgMBAAECggEAMLuj/qzZB0PJioMnJU7K5UkFw1zAdg5wT8+AzrhbNrIO
/Z7uNmIMocJFKP0A//WvSEeKBUSMT7ssFofrKiEEEQeCA0AvjQNWb3BFBb+2sTUq
HdfKRzxqfqd1qc1esptifailt1hNICUqJw8Hbj3LO7UtpxcL5wOcAkvyQckf5Wsz
5k/PDcrWnJEMndxZjRZpEwIy3rtah0ABuvc2P417DqxO+sgJC/k4E2Hd2rD/yEGn
I7wZ3p+h2nve5u+1lh+QOsMtzeVliyPF4PcIgTVHx6eu3FV4i+XmRMZ1oamdtZRg
YsizYNfcI88RrqNudzQyvrfDVGKJjFaFcyQZVREMyQKBgQDI1fKPkL3n6JV42i+X
tRafbypEQcefdDhfRu3KX6XCBuW9CmmzyR0obwUeVyEjCDKl5KqkgD4ZrEovHpD9
mV9QPBlyyLTVptvWXXFVGy51Ny80D99KjhoGLVZ2GXhDv6JGPlZJQdBC+Ct8hPhq
USot6TWuQnsgdn8l9NkPIf3v2QKBgQC1FdTCoTOI6wqCdeENuR6jLsJGJLgggrWn
2CpB37kp2cdVJJMZMQk//LbaBzmRK0YLEwayA3TGoYGFhcgrv0TFlSm8N28k5yS7
+pYnRkyznv2sv3C/wLlwJ3vKFlUuR2ZBXgFjRGQFVF00gnnYq2K5K0VBhnrIIKPL
92sI4ZHmkwKBgHVopgIKAwtVbDSXkSUmZ5BTY6k9r17niaEhjRg3LGcusxX9xWem
VhZyONLQ6v1Y6IsVEDFVC3jkwQWk3+xxwkfcqp/w987DWleKjxvK5TD/Tugns0kZ
o9sinB40q5snp81a2gS/penQvVNxROyagIcQhCJYmTSUMabV6iMvow2ZAoGANNVO
ww5nS85PvaqI5Akb0D6HPhHeitW4DGV8RsXwby50Avpt1I7DBSFFdC1hdWJryDVf
4bYybvWWJY/XzDO86+zORXTi/6BUzmeZQZI4NLvxhWJBkiC3ueo7KsYDSkJYqj+0
JfNBP3DS1Mwwoq+2WRIv3aNP1W34fD0OxIOTnvMCgYB/VYJIuZbWCiHHC5kQTzSW
aJ9T/pdqdcWkDLeoCXm8EgfJEjMVugnDcBJWl4JjlBYy/wDd+IQzxRdN9aN00gRM
VLqafScHNq4eg3wZ+AgZflBa5CljfS5QZ/Iy0EPvSv02lmZ/0NO9Uj0ySJkxDJpR
I1NGNVSpXyWV99QCLFI/jA==
-----END PRIVATE KEY-----
127 changes: 127 additions & 0 deletions spec/spec_helpers/saml_fixtures.cr
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
require "base64"
require "uuid"
require "openssl"
require "crystal-saml"

module PlaceOS::Auth::Spec
# Builds SAML Responses that auth.cr's real validation path will accept —
# or reject for a specific, named reason.
#
# Why generate rather than use canned fixtures: `crystal-saml` ships signed
# sample responses, but their `Conditions/NotOnOrAfter` expired years ago.
# Its own specs work around that with `allowed_clock_drift: 10 years`, which
# auth.cr (rightly) does not set — production defaults apply. Loosening
# production validation to make a test pass would defeat the purpose, so
# instead we mint a fresh assertion with current timestamps and sign it with
# a keypair the spec controls.
#
# `spec/fixtures/saml/` holds two throwaway self-signed keypairs, generated
# once with openssl and committed deliberately:
# * idp_* — the "real" IdP; its cert goes on the strat as `idp_cert`
# * attacker_* — a DIFFERENT valid keypair, for the wrong-signer case
#
# Neither is a secret: they exist only to sign test XML for a disposable
# local database.
#
# SAML matters here because real clients use it (UCLA), and auth.cr only
# enforces signatures when the strat carries a cert/fingerprint:
#
# want_assertions_signed: strat.idp_cert.presence || ... ? true : false
# want_signature_validated: strat.idp_cert.presence || ... ? true : false
#
# so the tests must pin BOTH configurations.
module SamlFixtures
extend self

FIXTURE_DIR = File.join(__DIR__, "..", "fixtures", "saml")

def idp_cert_pem : String
@@idp_cert_pem ||= File.read(File.join(FIXTURE_DIR, "idp_cert.pem"))
end

def idp_key : OpenSSL::PKey::RSA
@@idp_key ||= OpenSSL::PKey::RSA.new(File.read(File.join(FIXTURE_DIR, "idp_key.pem")))
end

def idp_certificate : OpenSSL::X509::Certificate
OpenSSL::X509::Certificate.new(idp_cert_pem)
end

def attacker_key : OpenSSL::PKey::RSA
@@attacker_key ||= OpenSSL::PKey::RSA.new(File.read(File.join(FIXTURE_DIR, "attacker_key.pem")))
end

def attacker_certificate : OpenSSL::X509::Certificate
OpenSSL::X509::Certificate.new(File.read(File.join(FIXTURE_DIR, "attacker_cert.pem")))
end

# A SAML 2.0 Response with CURRENT timestamps.
#
# `audience` must equal the strat's `issuer` (multi_auth_saml passes it as
# `sp_entity_id`), and `destination` must equal the strat's
# `assertion_consumer_service_url` — both are validated.
def response_xml(
acs_url : String,
audience : String,
email : String,
idp_entity_id : String = "https://idp.example.test/metadata",
name : String = "SAML Person",
not_before : Time = 5.minutes.ago,
not_on_or_after : Time = 30.minutes.from_now,
) : String
resp_id = "_#{UUID.random.hexstring}"
assertion_id = "_#{UUID.random.hexstring}"
issued = Time.utc.to_rfc3339
nb = not_before.to_rfc3339
noa = not_on_or_after.to_rfc3339

<<-XML
<samlp:Response xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" ID="#{resp_id}" Version="2.0" IssueInstant="#{issued}" Destination="#{acs_url}">
<saml:Issuer>#{idp_entity_id}</saml:Issuer>
<samlp:Status><samlp:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/></samlp:Status>
<saml:Assertion xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" ID="#{assertion_id}" Version="2.0" IssueInstant="#{issued}">
<saml:Issuer>#{idp_entity_id}</saml:Issuer>
<saml:Subject>
<saml:NameID Format="urn:oasis:names:tc:SAML:2.0:nameid-format:persistent">#{email}</saml:NameID>
<saml:SubjectConfirmation Method="urn:oasis:names:tc:SAML:2.0:cm:bearer">
<saml:SubjectConfirmationData NotOnOrAfter="#{noa}" Recipient="#{acs_url}"/>
</saml:SubjectConfirmation>
</saml:Subject>
<saml:Conditions NotBefore="#{nb}" NotOnOrAfter="#{noa}">
<saml:AudienceRestriction><saml:Audience>#{audience}</saml:Audience></saml:AudienceRestriction>
</saml:Conditions>
<saml:AuthnStatement AuthnInstant="#{issued}" SessionIndex="#{assertion_id}">
<saml:AuthnContext><saml:AuthnContextClassRef>urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport</saml:AuthnContextClassRef></saml:AuthnContext>
</saml:AuthnStatement>
<saml:AttributeStatement>
<saml:Attribute Name="email"><saml:AttributeValue>#{email}</saml:AttributeValue></saml:Attribute>
<saml:Attribute Name="name"><saml:AttributeValue>#{name}</saml:AttributeValue></saml:Attribute>
</saml:AttributeStatement>
</saml:Assertion>
</samlp:Response>
XML
end

# Signs with the IdP key — the happy path.
def signed(xml : String, key : OpenSSL::PKey::RSA? = nil,
certificate : OpenSSL::X509::Certificate? = nil) : String
::SAML::XMLSecurity.sign_document(
xml,
key || idp_key,
certificate || idp_certificate,
UUID.random.to_s,
)
end

# Signed by a DIFFERENT, valid keypair. The XML is well-formed and the
# signature is cryptographically sound — it is simply not the IdP's.
def signed_by_attacker(xml : String) : String
signed(xml, attacker_key, attacker_certificate)
end

# Ready-to-POST form value.
def encode(xml : String) : String
Base64.strict_encode(xml)
end
end
end
Loading
Loading