├── .github
└── workflows
│ └── test.yml
├── LICENSE
├── README.md
├── entity_diagram_1.png
├── entity_diagram_2.png
├── examples
├── hello-world
│ ├── README.md
│ └── main.go
├── persistent
│ ├── README.md
│ ├── docker-compose.yml
│ ├── init.sql
│ └── main.go
└── saml-todo-app
│ ├── README.md
│ ├── docker-compose.yml
│ ├── init.sql
│ └── main.go
├── go.mod
├── go.sum
├── saml.go
├── saml_test.go
└── tests
├── after_conditions_not_on_or_after.xml
├── after_subject_confirmation_data_not_on_or_after.xml
├── before_conditions_not_before.xml
├── invalid_signature.xml
├── unsigned.xml
├── valid.xml
├── valid_idp_metadata.xml
├── wrong_issuer.xml
└── wrong_recipient.xml
/.github/workflows/test.yml:
--------------------------------------------------------------------------------
1 | name: tests
2 | on: push
3 | jobs:
4 | build:
5 | runs-on: ubuntu-latest
6 | steps:
7 | - uses: actions/checkout@v2
8 | - uses: actions/setup-go@v1
9 | with:
10 | go-version: "1.13"
11 | - run: go test ./...
12 | - run: go vet ./...
13 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Copyright (c) 2020 Ulysse Carion
2 |
3 | Permission is hereby granted, free of charge, to any person obtaining a copy
4 | of this software and associated documentation files (the "Software"), to deal
5 | in the Software without restriction, including without limitation the rights
6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7 | copies of the Software, and to permit persons to whom the Software is
8 | furnished to do so, subject to the following conditions:
9 |
10 | The above copyright notice and this permission notice shall be included in all
11 | copies or substantial portions of the Software.
12 |
13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19 | SOFTWARE.
20 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # saml
2 |
3 | [](https://pkg.go.dev/mod/github.com/ucarion/saml?tab=overview)
4 | [](https://github.com/ucarion/saml/actions)
5 |
6 | This package is a Golang implementation of [Secure Assertion Markup Language
7 | v2.0][oasis], commonly known as "SAML". This package features:
8 |
9 | 1. **An extremely simple interface that's easy to integrate with.** Many Golang
10 | implementations give you tons of functions and types you can use, and it's
11 | not clear what you're meant to do. This package only gives you two functions:
12 | one for accepting SAML logins, and another for parsing Identity Provider
13 | metadata into useful information.
14 |
15 | 1. **No presumption of how your application works.** SAML is a legacy, but
16 | important, protocol. You do not want to put SAML everywhere in your
17 | application; instead, you should evaluate how to wedge support for SAML into
18 | your existing authentication as unintrusively as possible.
19 |
20 | To that end, this package does not attempt to intercept with HTTP handlers or
21 | presume whether you're making a single-tenant or multi-tenant system.
22 | Instead, this package gives you useful, secure building blocks that you can
23 | fit into your systems.
24 |
25 | 1. **An emphasis on security.** There are a lot of ways to introduce security
26 | vulnerabilities in SAML implementations, and most existing Golang packages
27 | make it too easy to make common mistakes.
28 |
29 | This package only gives you secure ways to handle SAML logins. You cannot
30 | skip security checks. In particular, this package will always verify that
31 | SAML responses are authentic, not expired, issued by the identity provider
32 | you expected, and intended to be consumed by you.
33 |
34 | ## Installation
35 |
36 | You can install this package by running:
37 |
38 | ```bash
39 | go get github.com/ucarion/saml
40 | ```
41 |
42 | ## Usage
43 |
44 | > For working, self-contained demos of how you can use this package, check out
45 | > the [`examples`](./examples) directory.
46 |
47 | ### Handling SAML Logins
48 |
49 | To accept a SAML login, you would usually write something like:
50 |
51 | ```go
52 | http.HandleFunc("/acs", func(w http.ResponseWriter, r *http.Request) {
53 | samlResponse, err := saml.Verify(
54 | r.FormValue(saml.ParamSAMLResponse),
55 | "https://customer-idp.example.com",
56 | customerCertificate,
57 | "https://your-service.example.com",
58 | time.Now(),
59 | )
60 |
61 | if err != nil {
62 | // Give back a 400 response or something, up to you ...
63 | }
64 |
65 | // See "Integrating with SAML" section below for suggested approaches to
66 | // handling a valid SAML login.
67 | //
68 | // ...
69 | }
70 | ```
71 |
72 | ### Initiating a SAML Login
73 |
74 | Kicking off a SAML login is so easy this package doesn't even give you a method
75 | for it. Just do:
76 |
77 | ```go
78 | http.HandleFunc("/initiate", func(w http.ResponseWriter, r *http.Request) {
79 | http.Redirect(w, r, "https://customer-idp.example.com/init", http.StatusFound)
80 | }
81 | ```
82 |
83 | ### Getting Identity Provider Configuration
84 |
85 | In the examples above, you need three pieces of information from your customer:
86 |
87 | 1. The customer's Identity Provider "ID". That was
88 | "https://customer-idp.example.com" in the example above.
89 | 2. The customer's Identity Provider certificate. That was `customerCertificate`
90 | above.
91 | 3. The customer's Identity Provider redirect URL. That was
92 | "https://customer-idp.example.com/init" above.
93 |
94 | You can have your customers input those into your application on their own, but
95 | a more streamlined process is for them to upload their Identity Provider
96 | "metadata" to your application. This package gives you a way to parse and
97 | extract information from Identity Provider metadata. Just do:
98 |
99 | ```go
100 | http.HandleFunc("/setup", func(w http.ResponseWriter, r *http.Request) {
101 | var metadata saml.EntityDescriptor
102 | if err := xml.NewDecoder(r.Body).Decode(&metadata); err != nil {
103 | // Give back a 400 response or something, up to you ...
104 | }
105 |
106 | idpID, customerCertificate, redirectURL, err := metadata.GetEntityIDCertificateAndRedirectURL()
107 | if err != nil {
108 | // Give back a 400 response or something, up to you ...
109 | }
110 |
111 | // Now you can store the Identity Provider ID, certificate, and redirect
112 | // URL someplace where you can retreive it later.
113 | //
114 | // ...
115 | }
116 | ```
117 |
118 | ## Integrating with SAML
119 |
120 | > This section gives guidance on how you should consider integrating SAML logins
121 | > into your existing application. It assumes you are building something like a
122 | > B2B SaaS product, and want to add Single Sign-On functionality using SAML.
123 | >
124 | > If you want to see a working example application built on top of this package,
125 | > check out [`examples/saml-todo-app`](./examples/saml-todo-app). Its design
126 | > follows the recommendations in this section.
127 |
128 | ### Typical Customer Expectations
129 |
130 | To have success with SAML, you must first understand what it gives you: **a SAML
131 | connection is essentially a customer-controlled factory of users**. Customers
132 | want SAML because they want to control what employees can access your app, and
133 | they want to be able to grant or revoke that access at will. Your integration
134 | with SAML should be designed with that in mind.
135 |
136 | Companies love Single Sign-On (and thus SAML) because it moves the problem of
137 | "can Jane get access to FooApp?" into a single place: their corporate identity
138 | provider (aka their "IdP", e.g. Okta, OneLogin, etc.). They want:
139 |
140 | * IT to be able to let Jane log into your app just by giving her access to the
141 | relevant SAML connection in their IdP.
142 | * Jane to be able to use your app by just clicking on your app's logo in her IdP
143 | account.
144 | * If Jane ever leaves the company, the IT team can just delete Jane's account in
145 | the identity provider, and she can't log into anything anymore.
146 | * If Jane changes roles, IT can remove the SAML connection from her account, and
147 | they know she can't log into your app anymore.
148 |
149 | In particular, what this means for you is:
150 |
151 | 1. **You should support having users that can log in with SAML but not
152 | username/password.** The nice thing about Single Sign-On is that it means
153 | employees only need to remember one password: their corporate identity
154 | provider password.
155 |
156 | If you force your customer's employees to have a password in your
157 | application, then you've lost a lot of the value of Single Sign-On. You'll
158 | end up with your customers' IT team asking you for the ability to
159 | programmatically delete users from your app, because they're worried about
160 | Jane's weak password being leaked, and then someone logging in as Jane and
161 | stealing company data.
162 |
163 | 1. **You should support "just-in-time" provisioning of accounts.** If someone
164 | logs in with SAML and you don't have a user for them already created, you
165 | should auto-create one for them on the spot.
166 |
167 | Single Sign-On is about automating provisioning. If you make your users
168 | create an account *outside* of SAML first, and only then support logging in
169 | via SAML, then not only are you ignoring the first suggestion (supporting
170 | passwordless users), but you're also adding extra steps that SAML is supposed
171 | to automate for customers.
172 |
173 | 1. **You should support disabling password-based logins.** Some smaller
174 | companies still transitioning to the centrally-managed approach of coroporate
175 | identity providers might want to support having both SAML-based and
176 | password-based logins into the same app. But the big companies want to be
177 | able to guarantee that there are no password-based logins into your app.
178 |
179 | No passwords means no password leaks, and no circumventing the
180 | centrally-managed rules in their IdP. That's a big security win for your
181 | customers.
182 |
183 | You should also be aware of this important bit of security context:
184 |
185 | * **An Identity Provider can put *anything* they like in a SAML assertion.**
186 | There is no guarantee that Identity Providers won't try to send you nefarious
187 | SAML assertions. For example, an IdP is allowed to claim they have a user with
188 | the email steve.jobs@apple.com, no verification required. There is no global
189 | SAML police.
190 |
191 | If you don't keep this in mind, you might accidentally introduce
192 | vulnerabilities in your SAML implementation. For instance, don't solely rely
193 | on an `email` attribute in SAML logins to decide what user to log someone in
194 | as. Otherwise an attacker could log in as anyone they like just by adding a
195 | phony user to their IdP with the right email, and then sending you a SAML
196 | login with that email.
197 |
198 | What this means for you is: trust a SAML login *only* within the context of
199 | the account that has established a trust relationship with the associated IdP.
200 | What Company A's Identity Provider says should not have *any* bearing on what
201 | they can do with Company B's resources in your product.
202 |
203 | ### A Playbook for Introducing SAML
204 |
205 | With all of that context, here is a playbook can follow to figure out how you
206 | should introduce SAML into your product. As you follow along, the most important
207 | fork in the road is whether your users exclusively belong to an "account" (or
208 | whatever your "root-level resource" is), or whether users can be in multiple
209 | accounts.
210 |
211 | If your users exclusively belong to your root-level resource, then your
212 | transition to supporting SAML will look something like this:
213 |
214 | > 
215 | >
216 | > A SAML transition plan for products that work roughly like AWS, where users
217 | > belong to accounts.
218 |
219 | If your users can be in multiple instances of your root-level resource, then
220 | your transition to supporting SAML will look something like this:
221 |
222 | > 
223 | >
224 | > A SAML transition plan for products that work roughly like GitHub, where users
225 | > can be in multiple accounts, and don't really "belong to" any particular
226 | > account.
227 |
228 | Don't worry if not everything in these pictures make sense yet. As you go
229 | through this playbook, consider jumping back up to this diagram to help make
230 | things clearer.
231 |
232 | 1. **Identify your root-level resource.** The root-level resource is the thing
233 | that's the "parent" of most other resources in your system -- most other
234 | things belong-to it. Usually, billing information is associated with this
235 | root-level resource. Oftentimes, customers will only have one (or very few,
236 | maybe one per business unit or environment) instances of the root-level
237 | resource. To customers, accounts might be an "invisible" resource, because a
238 | session can never see accounts outside of the one they're issued for.
239 |
240 | For example, in AWS, an AWS account is the root-level resource. In GitHub,
241 | users and organizations are the root-level resources, but for the purposes of
242 | a business-tier account it's the organization resource that matters most. In
243 | Stripe, a Stripe account is the root-level resource, and is a
244 | mostly-invisible resource when you're using their API.
245 |
246 | 1. **Determine the relation between users and your root-level resource.**
247 | Broadly speaking, there are two typical relations users and root-level
248 | resources can have in most SaaS products:
249 |
250 | * In a *belongs-to* relationship, users exist in exactly one root-level
251 | resource, and belong to the root-level resource they're in. If you delete
252 | the root-level resource, you also delete the user.
253 |
254 | AWS and Stripe are examples of this. All AWS IAM users belong to an AWS
255 | account. All Stripe users / tokens belong to a Stripe account.
256 |
257 | Broadly speaking, most B2B SaaS companies work like this. You're doing
258 | business with a company, and everything in your system should be
259 | attributable to a billable corporate customer.
260 |
261 | * In a *has-and-belongs-to-many* relationship, users can exist in multiple
262 | root-level resources, and deleting a root-level resource doesn't delete a
263 | user.
264 |
265 | GitHub.com (not the enterprise edition) is an example of this. Developers
266 | have personal GitHub accounts that may belong to multiple organizations.
267 | Developers can leave or join organizations, and users and organizations
268 | can be deleted independently.
269 |
270 | Broadly speaking, products with a "social network" aspect work like this.
271 | You should avoid this design if you can, because it complicates things
272 | when it comes to SAML. The fact that many employers require employees to
273 | create a new GitHub account when they join the company is emblematic of
274 | the sorts of issues that GitHub's model has with selling to businesses.
275 |
276 | Now that you've identified your root-level resource and the relationship it has
277 | with your users, here's a recommended approach to adding SAML:
278 |
279 | 1. Add a new kind of resource to your root-level resource: a "SAML connection".
280 | See the [`./examples/persistent`](./examples/persistent) demo for an example
281 | of how you can represent a SAML connection in a database.
282 |
283 | If you can, let your root-level resource *have-many* SAML connections; you'll
284 | find that many companies, especially those with multiple business units or
285 | which rely on consultants, will have multiple Identity Providers internally.
286 |
287 | For examples of SAML connections attached to root-level resources, see [the
288 | AWS IAM `CreateSAMLProvider` endpoint][aws-create-saml] or [GitHub's docs on
289 | adding a SAML connection to an organization][github-create-saml]. AWS
290 | supports multiple SAML connections per account, whereas GitHub supports up to
291 | one SAML connection per organization.
292 |
293 | 1. Add the notion of a "SAML user ID" on the resource that ties users to your
294 | root-level resource.
295 |
296 | * If users *belong-to* your root-level resource, then you should add a "SAML
297 | user ID" to your users resource.
298 |
299 | * If users *have-and-belong-to-many* root-level resources, then you should
300 | add a "SAML user ID" to the join table between users and the root-level
301 | resource.
302 |
303 | The "SAML user ID" will be how you can tell if a user was created via an
304 | Identity Provider, rather than via the old-school username/password approach.
305 | The "SAML user ID" will consist of a pair of fields:
306 |
307 | * The SAML connection that the user came from, and
308 | * The ID that the SAML connection had given to that user. Every SAML login
309 | comes with a user ID (called a `NameID`), but you don't get to control
310 | what that user ID will be.
311 |
312 | When someone logs in with SAML, you'll look up if an existing user with the
313 | SAML login's connection and `NameID` already exists in your database. If one
314 | does, you'll log them into that user. If one doesn't, you can create a user
315 | on the spot, if that makes sense for your business. GitHub can't do this,
316 | because GitHub users don't belong to organizations. But AWS can, and does,
317 | create IAM principals on-the-spot when you use an IAM SAML Provider.
318 |
319 | 1. Add the notion of a "SAML connection ID" to your "session" resource, or its
320 | equivalents in your product. A "session" resource might not necessarily exist
321 | in your database -- if you use stateless JWTs as your session tokens, then
322 | add a "SAML connection ID" claim to your JWTs.
323 |
324 | When someone logs in with username and password, don't put a SAML connection
325 | ID on the session you issue. When someone logs in with SAML, add the SAML
326 | connection they used to the session you issued to them.
327 |
328 | By tracking whether a session is associated with a SAML connection, you'll be
329 | able to support both password-based and SAML-based logins relatively
330 | seamlessly. If you choose to give customers the ability require SAML for all
331 | logins into their root-level resource, you can enforce that internally by
332 | considering any session that doesn't have the right SAML connection ID -- or
333 | doesn't have a SAML connection ID at all -- to be unauthorized.
334 |
335 | > One subtlety with "sessions". If you're making a developer-oriented
336 | > product, you might have the concept of a Personal Access Token (PAT). If
337 | > your product has both PATs *and* you have a has-and-belongs-to-many
338 | > relationship between users and root-level resources, then you may need to
339 | > either disable PATs for customers that enable "require SAML", or you'll
340 | > need to copy what GitHub does: adding the notion of "enable SAML" to a PAT.
341 | >
342 | > See [GitHub's docs on authorizating a PAT for SAML][github-pat-saml] for
343 | > what that could look like.
344 | >
345 | > SAML only works in browsers, so it's always going to be a bit awkward to
346 | > integrate SAML into non-browser-based systems, like CLI tools.
347 |
348 | Hopefully this guidance has made the big picture clearer. Although
349 | `github.com/ucarion/saml` does not solve all of these problems for you, it does
350 | give you many of the secure building blocks you need to integrate SAML into your
351 | product.
352 |
353 | [oasis]: http://docs.oasis-open.org/security/saml/v2.0/saml-core-2.0-os.pdf
354 | [aws-create-saml]: https://docs.aws.amazon.com/IAM/latest/APIReference/API_CreateSAMLProvider.html
355 | [github-create-saml]: https://help.github.com/en/github/setting-up-and-managing-organizations-and-teams/connecting-your-identity-provider-to-your-organization
356 | [github-pat-saml]: https://help.github.com/en/github/authenticating-to-github/authorizing-a-personal-access-token-for-use-with-saml-single-sign-on
357 |
--------------------------------------------------------------------------------
/entity_diagram_1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ucarion/saml/d0caa3b6e802fbd5e181a8ff0eb7d12adfe91953/entity_diagram_1.png
--------------------------------------------------------------------------------
/entity_diagram_2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ucarion/saml/d0caa3b6e802fbd5e181a8ff0eb7d12adfe91953/entity_diagram_2.png
--------------------------------------------------------------------------------
/examples/hello-world/README.md:
--------------------------------------------------------------------------------
1 | # Example Persistence Layer for SAML
2 |
3 | This is an example of how you can use `github.com/ucarion/saml` to go through
4 | the SAML flow. It's meant to be the simplest possible app that knows how to set
5 | up and log in with SAML.
6 |
7 | You can try this example by running, from the directory where this README is in:
8 |
9 | ```bash
10 | go run ./...
11 | ```
12 |
13 | You can now visit localhost:8080 and try out the example app.
14 |
15 | ## Guided Tour
16 |
17 | Here's a suggested approach you can take to trying out this example.
18 |
19 | 1. First, you'll need to sign up for a free account with [Okta][okta] or
20 | [OneLogin][onelogin], which are widely-used Identity Providers that support
21 | free trials.
22 |
23 | 1. Now we need to set up the connection from the Identity Provider end.
24 |
25 | * If you're using OneLogin, go to your OneLogin administrator page. Go to
26 | "Applications", and then click "Add App". Choose "SAML Test Connector
27 | (Advanced)" -- you can use the search to help you find this. Click "Save".
28 |
29 | You've now created a new OneLogin application. We can now paste the
30 | information from TodoApp into OneLogin. Go to "Configuration", and then put
31 | `http://localhost:8080/acs` into the OneLogin inputs labeled "Recipient",
32 | "ACS (Consumer) URL Validator", and "ACS (Consumer) URL".
33 |
34 | Then hit save.
35 |
36 | * If you're using Okta, go to your Okta admin dashboard. Go to
37 | "Applications", and then click "Add Application". Choose "Create New App"
38 | at the top right, and then the "SAML 2.0" option. Then click "Create".
39 |
40 | Give the application any name you like, and then click Next.
41 |
42 | Put `http://localhost:8080/acs` into the fields in Okta called "Single sign
43 | on URL" and "Audience URI (SP Entity ID)". Click "Next".
44 |
45 | Do whatever you like on the "Feedback" page. It doesn't matter. Click
46 | "Finish".
47 |
48 | 1. Next we need to set up SAML from the Service Provider end. In this case, the
49 | demo app is the service provider.
50 |
51 | * If you're using OneLogin, click on the "More Actions" dropdown at the top
52 | right of the edit page for the OneLogin app you just created. Click on the
53 | "SAML Metadata" option with a download icon.
54 |
55 | * If you're using Okta, click on the "Sign On" tab of your application. A
56 | yellow-bordered box contains a link labeled "Identity Provider metadata".
57 | Click on that link. Save the page. Your browser may ask what format you
58 | want to save it in -- choose "XML", not "Web page".
59 |
60 | You'll also need to assign the app to yourself at this time. Go to
61 | "Assignments", click "Assign" and then "Assign People". Find your Okta user
62 | in the list of users, click "Assign", and then "Save and Go Back", then
63 | "Done".
64 |
65 | 1. Go to the directory where you downloaded that metadata file. Assuming you
66 | saved it into a file called `metadata.xml`, now run:
67 |
68 | ```bash
69 | curl http://localhost:8080/setup -d @metadata.xml
70 | ```
71 |
72 | 1. You can now try logging in with SAML. Visit:
73 |
74 | ```text
75 | http://localhost:8080/initiate
76 | ```
77 |
78 | With your *browser*, not cURL. You should be redirected to your identity
79 | provider, and then sent back to a page with the URL like:
80 |
81 | ```text
82 | http://localhost:8080/acs
83 | ```
84 |
85 | The page's content should also show you some details about yourself from your
86 | identity provider. That's information that the demo app extracted from the
87 | SAML login flow.
88 |
89 | There are some additional follow-up tests you could try. For instance, try
90 | visiting:
91 |
92 | ```text
93 | http://localhost:8080/initiate?relay_state=foobar
94 | ```
95 |
96 | You'll find that you get directed back to the `.../acs` URL, but with the
97 | `relay_state` parameter populated. A "Relay State" is how you typically do
98 | deeplinking with SAML.
99 |
100 | As a last little test, you can try logging into the demo app directly from your
101 | Identity Provider, instead of relying on the `.../initiate` URL. Find the app
102 | you created in your identity provider, and click on it. You may need to exit the
103 | "Admin" view of your Identity Provider, so that it logs you in instead of trying
104 | to edit the connection details.
105 |
--------------------------------------------------------------------------------
/examples/hello-world/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "crypto/x509"
5 | "encoding/json"
6 | "encoding/xml"
7 | "fmt"
8 | "net/http"
9 | "net/url"
10 | "time"
11 |
12 | "github.com/ucarion/saml"
13 | )
14 |
15 | func main() {
16 | var issuer string
17 | var cert *x509.Certificate
18 | var redirectURL *url.URL
19 |
20 | http.HandleFunc("/setup", func(w http.ResponseWriter, r *http.Request) {
21 | var metadata saml.EntityDescriptor
22 | if err := xml.NewDecoder(r.Body).Decode(&metadata); err != nil {
23 | w.WriteHeader(http.StatusBadRequest)
24 | fmt.Fprintf(w, "%s", err)
25 | return
26 | }
27 |
28 | inputIssuer, inputCert, inputRedirectURL, err := metadata.GetEntityIDCertificateAndRedirectURL()
29 | if err != nil {
30 | w.WriteHeader(http.StatusBadRequest)
31 | fmt.Fprintf(w, "%s", err)
32 | return
33 | }
34 |
35 | issuer = inputIssuer
36 | cert = inputCert
37 | redirectURL = inputRedirectURL
38 | })
39 |
40 | http.HandleFunc("/acs", func(w http.ResponseWriter, r *http.Request) {
41 | if err := r.ParseForm(); err != nil {
42 | w.WriteHeader(http.StatusBadRequest)
43 | fmt.Fprintf(w, "%s", err)
44 | return
45 | }
46 |
47 | samlResponse, err := saml.Verify(
48 | r.FormValue("SAMLResponse"),
49 | issuer,
50 | cert,
51 | "http://localhost:8080/acs",
52 | time.Now(),
53 | )
54 |
55 | if err != nil {
56 | w.WriteHeader(http.StatusBadRequest)
57 | fmt.Fprintf(w, "%s", err)
58 | return
59 | }
60 |
61 | response := map[string]interface{}{
62 | "assertion": samlResponse.Assertion,
63 | "relay_state": r.FormValue("RelayState"),
64 | }
65 |
66 | w.Header().Set("content-type", "application/json")
67 | json.NewEncoder(w).Encode(response)
68 | })
69 |
70 | http.HandleFunc("/initiate", func(w http.ResponseWriter, r *http.Request) {
71 | redirectURL := *redirectURL
72 | redirectURL.Query().Add("RelayState", r.URL.Query().Get("relay_state"))
73 | http.Redirect(w, r, redirectURL.String(), http.StatusFound)
74 | })
75 |
76 | http.ListenAndServe("localhost:8080", nil)
77 | }
78 |
--------------------------------------------------------------------------------
/examples/persistent/README.md:
--------------------------------------------------------------------------------
1 | # Example Persistence Layer for SAML
2 |
3 | This is an example of how you can use `github.com/ucarion/saml` with a database,
4 | in order to save SAML connections and then be able to accept logins with them at
5 | a later time. It's meant to be small step up from the `hello-world` example in
6 | this repo.
7 |
8 | You can try this example by running, from the directory where this README is in:
9 |
10 | ```bash
11 | docker-compose up -d
12 | ```
13 |
14 | And then running:
15 |
16 | ```bash
17 | go run ./...
18 | ```
19 |
20 | You can now visit localhost:8080 and try out the example app.
21 |
22 | ## Guided Tour
23 |
24 | Here's a suggested approach you can take to trying out this example.
25 |
26 | 1. First, you'll need to sign up for a free account with [Okta][okta] or
27 | [OneLogin][onelogin], which are widely-used Identity Providers that support
28 | free trials.
29 |
30 | 1. Next, let's create a new empty SAML connection. Do so by running:
31 |
32 | ```bash
33 | curl -X POST localhost:8080/connections
34 | ```
35 |
36 | You'll get back something like:
37 |
38 | ```json
39 | {"id":"378f16ff-d57a-4fbd-a274-07da573c2bcf","issuer":"","x509":null,"redirect_url":""}
40 | ```
41 |
42 | Take note of the `id` here, we'll use it in the next steps.
43 |
44 | 1. Now we need to set up the connection from the Identity Provider end.
45 |
46 | * If you're using OneLogin, go to your OneLogin administrator page. Go to
47 | "Applications", and then click "Add App". Choose "SAML Test Connector
48 | (Advanced)" -- you can use the search to help you find this. Click "Save".
49 |
50 | You've now created a new OneLogin application. We can now paste the
51 | information from TodoApp into OneLogin. Go to "Configuration", and then put
52 | `http://localhost:8080/connections/$id` into the OneLogin input labeled
53 | "Recipient", with `$id` replaced with the `id` you got in the previous
54 | step. For example:
55 |
56 | ```text
57 | http://localhost:8080/connections/378f16ff-d57a-4fbd-a274-07da573c2bcf
58 | ```
59 |
60 | Put `http://localhost:8080/connections/$id/acs` into both "ACS (Consumer)
61 | URL Validator" and "ACS (Consumer) URL", again using the previous `id` as
62 | `$id`. For example:
63 |
64 | ```text
65 | http://localhost:8080/connections/378f16ff-d57a-4fbd-a274-07da573c2bcf/acs
66 | ```
67 |
68 | Then hit save.
69 |
70 | * If you're using Okta, go to your Okta admin dashboard. Go to
71 | "Applications", and then click "Add Application". Choose "Create New App"
72 | at the top right, and then the "SAML 2.0" option. Then click "Create".
73 |
74 | Give the application any name you like, and then click Next.
75 |
76 | Put `http://localhost:8080/connections/$id` into the field in Okta called
77 | "Single sign on URL", with `$id` replaced with the `id` you got in the previous
78 | step. For example:
79 |
80 | ```text
81 | http://localhost:8080/connections/378f16ff-d57a-4fbd-a274-07da573c2bcf
82 | ```
83 |
84 | Untick the box labeled "Use this for Recipient URL and Destination URL".
85 | Put `http://localhost:8080/connections/$id/acs` into "Audience URI (SP
86 | Entity ID)" and "Recipient URL" in Okta, again using the previous `id` as
87 | `$id`. For example:
88 |
89 | ```text
90 | http://localhost:8080/connections/378f16ff-d57a-4fbd-a274-07da573c2bcf/acs
91 | ```
92 |
93 | The "Destination URL" should be the same thing as the "Single sign on URL"
94 | in Okta. Click "Next".
95 |
96 | Do whatever you like on the "Feedback" page. It doesn't matter. Click
97 | "Finish".
98 |
99 | 1. Next we need to set up SAML from the Service Provider end. In this case, the
100 | demo app is the service provider.
101 |
102 | * If you're using OneLogin, click on the "More Actions" dropdown at the top
103 | right of the edit page for the OneLogin app you just created. Click on the
104 | "SAML Metadata" option with a download icon.
105 |
106 | * If you're using Okta, click on the "Sign On" tab of your application. A
107 | yellow-bordered box contains a link labeled "Identity Provider metadata".
108 | Click on that link. Save the page. Your browser may ask what format you
109 | want to save it in -- choose "XML", not "Web page".
110 |
111 | You'll also need to assign the app to yourself at this time. Go to
112 | "Assignments", click "Assign" and then "Assign People". Find your Okta user
113 | in the list of users, click "Assign", and then "Save and Go Back", then
114 | "Done".
115 |
116 | 1. Go to the directory where you downloaded that metadata file. Assuming you
117 | saved it into a file called `metadata.xml`, now run:
118 |
119 | ```bash
120 | curl -X PATCH http://localhost:8080/connections/$id/metadata -d @metadata.xml
121 | ```
122 |
123 | With `$id` again replaced with the `id` you've been using from step (2).
124 |
125 | You should get back a pretty big JSON message. That's the relevant data that
126 | the demo app extracted from the identity metadata, and saved to the database.
127 |
128 | 1. You can now try logging in with SAML. Visit, again replacing `$id`:
129 |
130 | ```text
131 | http://localhost:8080/connections/$id/initiate
132 | ```
133 |
134 | With your *browser*, not cURL. You should be redirected to your identity
135 | provider, and then sent back to a page with the URL like:
136 |
137 | ```text
138 | http://localhost:8080/connections/$id/acs
139 | ```
140 |
141 | The page's content should also show you some details about yourself from your
142 | identity provider. That's information that the demo app extracted from the
143 | SAML login flow.
144 |
145 | There are some additional follow-up tests you could try. For instance, try
146 | visiting:
147 |
148 | ```text
149 | http://localhost:8080/connections/$id/initiate?relay_state=foobar
150 | ```
151 |
152 | You'll find that you get directed back to the `.../acs` URL, but with the
153 | `relay_state` parameter populated. A "Relay State" is how you typically do
154 | deeplinking with SAML.
155 |
156 | As a last little test, you can try logging into the demo app directly from your
157 | Identity Provider, instead of relying on the `.../initiate` URL. Find the app
158 | you created in your identity provider, and click on it. You may need to exit the
159 | "Admin" view of your Identity Provider, so that it logs you in instead of trying
160 | to edit the connection details.
161 |
--------------------------------------------------------------------------------
/examples/persistent/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: "2.4"
2 | services:
3 | postgres:
4 | image: postgres:12.3
5 | environment:
6 | POSTGRES_PASSWORD: "password"
7 | volumes:
8 | - ./init.sql:/docker-entrypoint-initdb.d/init.sql
9 | ports:
10 | - 5432:5432
11 |
--------------------------------------------------------------------------------
/examples/persistent/init.sql:
--------------------------------------------------------------------------------
1 | create table connections (
2 | id uuid not null primary key,
3 | issuer varchar,
4 | x509 bytea,
5 | redirect_url varchar
6 | );
7 |
--------------------------------------------------------------------------------
/examples/persistent/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "context"
5 | "crypto/x509"
6 | "encoding/json"
7 | "encoding/xml"
8 | "fmt"
9 | "net/http"
10 | "net/url"
11 | "time"
12 |
13 | "github.com/google/uuid"
14 | "github.com/jmoiron/sqlx"
15 | "github.com/julienschmidt/httprouter"
16 | _ "github.com/lib/pq"
17 | "github.com/ucarion/saml"
18 | )
19 |
20 | type connection struct {
21 | ID uuid.UUID `json:"id" db:"id"`
22 | Issuer string `json:"issuer" db:"issuer"`
23 | X509 []byte `json:"x509" db:"x509"`
24 | RedirectURL string `json:"redirect_url" db:"redirect_url"`
25 | }
26 |
27 | type store struct {
28 | DB *sqlx.DB
29 | }
30 |
31 | func (s *store) getConnection(ctx context.Context, id uuid.UUID) (connection, error) {
32 | var c connection
33 | err := s.DB.GetContext(ctx, &c, `select * from connections where id = $1`, id)
34 | return c, err
35 | }
36 |
37 | func (s *store) createConnection(ctx context.Context, c connection) error {
38 | _, err := s.DB.ExecContext(ctx, `insert into connections (id) values ($1)`, c.ID)
39 | return err
40 | }
41 |
42 | func (s *store) updateConnection(ctx context.Context, c connection) error {
43 | _, err := s.DB.ExecContext(ctx, `
44 | update
45 | connections
46 | set
47 | issuer = $1, x509 = $2, redirect_url = $3
48 | where
49 | id = $4
50 | `, c.Issuer, c.X509, c.RedirectURL, c.ID)
51 |
52 | return err
53 | }
54 |
55 | func main() {
56 | db, err := sqlx.Open("postgres", "postgres://postgres:password@localhost?sslmode=disable")
57 | if err != nil {
58 | panic(err)
59 | }
60 |
61 | store := store{DB: db}
62 | router := httprouter.New()
63 |
64 | router.GET("/connections/:id", func(w http.ResponseWriter, r *http.Request, p httprouter.Params) {
65 | id, err := uuid.Parse(p.ByName("id"))
66 | if err != nil {
67 | fmt.Fprintf(w, err.Error())
68 | return
69 | }
70 |
71 | c, err := store.getConnection(r.Context(), id)
72 | if err != nil {
73 | fmt.Fprintf(w, err.Error())
74 | return
75 | }
76 |
77 | json.NewEncoder(w).Encode(c)
78 | })
79 |
80 | router.POST("/connections", func(w http.ResponseWriter, r *http.Request, p httprouter.Params) {
81 | c := connection{ID: uuid.New()}
82 | err := store.createConnection(r.Context(), c)
83 | if err != nil {
84 | fmt.Fprintf(w, err.Error())
85 | return
86 | }
87 |
88 | json.NewEncoder(w).Encode(c)
89 | })
90 |
91 | router.PATCH("/connections/:id/metadata", func(w http.ResponseWriter, r *http.Request, p httprouter.Params) {
92 | defer r.Body.Close()
93 |
94 | id, err := uuid.Parse(p.ByName("id"))
95 | if err != nil {
96 | fmt.Fprintf(w, err.Error())
97 | return
98 | }
99 |
100 | var metadata saml.EntityDescriptor
101 | if err := xml.NewDecoder(r.Body).Decode(&metadata); err != nil {
102 | fmt.Fprintf(w, err.Error())
103 | return
104 | }
105 |
106 | issuer, cert, redirectURL, err := metadata.GetEntityIDCertificateAndRedirectURL()
107 | if err != nil {
108 | fmt.Fprintf(w, err.Error())
109 | return
110 | }
111 |
112 | c := connection{ID: id, Issuer: issuer, X509: cert.Raw, RedirectURL: redirectURL.String()}
113 | err = store.updateConnection(r.Context(), c)
114 | if err != nil {
115 | fmt.Fprintf(w, err.Error())
116 | return
117 | }
118 |
119 | json.NewEncoder(w).Encode(c)
120 | })
121 |
122 | router.GET("/connections/:id/initiate", func(w http.ResponseWriter, r *http.Request, p httprouter.Params) {
123 | id, err := uuid.Parse(p.ByName("id"))
124 | if err != nil {
125 | fmt.Fprintf(w, err.Error())
126 | return
127 | }
128 |
129 | c, err := store.getConnection(r.Context(), id)
130 | if err != nil {
131 | fmt.Fprintf(w, err.Error())
132 | return
133 | }
134 |
135 | redirectURL, err := url.Parse(c.RedirectURL)
136 | if err != nil {
137 | fmt.Fprintf(w, err.Error())
138 | return
139 | }
140 |
141 | relayState := r.URL.Query().Get("relay_state")
142 | query := redirectURL.Query()
143 | query.Set(saml.ParamRelayState, relayState)
144 | redirectURL.RawQuery = query.Encode()
145 |
146 | http.Redirect(w, r, redirectURL.String(), http.StatusFound)
147 | })
148 |
149 | router.POST("/connections/:id/acs", func(w http.ResponseWriter, r *http.Request, p httprouter.Params) {
150 | id, err := uuid.Parse(p.ByName("id"))
151 | if err != nil {
152 | fmt.Fprintf(w, err.Error())
153 | return
154 | }
155 |
156 | c, err := store.getConnection(r.Context(), id)
157 | if err != nil {
158 | fmt.Fprintf(w, err.Error())
159 | return
160 | }
161 |
162 | if err := r.ParseForm(); err != nil {
163 | fmt.Fprintf(w, err.Error())
164 | return
165 | }
166 |
167 | cert, err := x509.ParseCertificate(c.X509)
168 | if err != nil {
169 | fmt.Fprintf(w, err.Error())
170 | return
171 | }
172 |
173 | rawSAMLResponse := r.FormValue(saml.ParamSAMLResponse)
174 | samlResponse, err := saml.Verify(rawSAMLResponse, c.Issuer, cert, fmt.Sprintf("http://localhost:8080/connections/%s", id), time.Now())
175 | if err != nil {
176 | fmt.Fprintf(w, err.Error())
177 | return
178 | }
179 |
180 | relayState := r.FormValue(saml.ParamRelayState)
181 |
182 | // In real life, at this point you would need to integrate samlResponse into
183 | // your existing authentication / authorization system. There's no
184 | // one-size-fits-all way of doing that, you'll need to do some critical
185 | // thinking.
186 | //
187 | // See the README of github.com/ucarion/saml for some discussion around what
188 | // SAML does and does not guarantee you, and for some suggestions around how
189 | // to integrate SAML into your existing model.
190 | //
191 | // For now, just as a demo, let's write the saml response back out as JSON.
192 | json.NewEncoder(w).Encode(map[string]interface{}{
193 | "saml_response": samlResponse,
194 | "relay_state": relayState,
195 | })
196 | })
197 |
198 | if err := http.ListenAndServe("localhost:8080", router); err != nil {
199 | panic(err)
200 | }
201 | }
202 |
--------------------------------------------------------------------------------
/examples/saml-todo-app/README.md:
--------------------------------------------------------------------------------
1 | # Example TodoApp with SAML
2 |
3 | > Yes, this demo app is very ugly. It's not meant to be an example of modern
4 | > UI/UX best practices. The hope is that by paring down the application to its
5 | > absolute bare essentials, the result is something that's easy to digest.
6 |
7 | This is an example of how you can use `github.com/ucarion/saml` to implement
8 | SAML in a real-world application. You can try this example by running, from the
9 | directory where this README is in:
10 |
11 | ```bash
12 | docker-compose up -d
13 | ```
14 |
15 | And then running:
16 |
17 | ```bash
18 | go run ./...
19 | ```
20 |
21 | You can now visit localhost:8080 and try out the example app.
22 |
23 | ## Guided Tour
24 |
25 | Here's a suggested approach you can take to trying out this example. In this
26 | guided tour, you'll see an example of an application supporting SAML, and
27 | experience what setting up and logging in with SAML is like as an end user.
28 |
29 | > If you're in a hurry, you can just do steps (1) and (2) and then jump to step
30 | > (9) to jump straight to the SAML stuff. We include these other steps to
31 | > emphasize that it's possible to have username/password and SAML coexist
32 | > without any problems.
33 |
34 | 1. First, you'll need to sign up for a free account with [Okta][okta] or
35 | [OneLogin][onelogin], which are widely-used Identity Providers that support
36 | free trials.
37 |
38 | 1. Next, create an account in the demo app. Visit localhost:8080, and put in
39 | "test" and "password" in the Sign Up form.
40 |
41 | 1. You should now be logged in and see information about the SAML Configuration,
42 | Users, and Todos in your newly-created account. Try creating a todo, by
43 | typing something into the input at the bottom of the page and clicking
44 | "Create a todo".
45 |
46 | 1. You should now see your todo on the page. It should also have the word
47 | "test:" next to it -- that's just showing you that the user named "test"
48 | wrote that todo.
49 |
50 | 1. Before jumping into the SAML stuff, let's invite another user into our
51 | account. In the "Users" section, so far there's only one user, named "test".
52 | Let's create another. Put "bob" in the Display Name section and "password" in
53 | the password section, and click "Create a user".
54 |
55 | 1. Bob should now be in the list of users. Let's log in as them now. Copy-paste
56 | the long UUID next to their name -- it should look something like:
57 |
58 | ```text
59 | c9d87570-e17b-4621-8af1-4053b6320c5c
60 | ```
61 |
62 | 1. Go to localhost:8080 again, and paste the ID you just copied into the User ID
63 | under "Log in", and put "password" into the password field. Click "Log into
64 | an existing user".
65 |
66 | 1. At the top of the page, you should now see that it says that you're logged in
67 | as bob.
68 |
69 | 1. Now, let's try setting up SAML. First, we need to set up the connection from
70 | the Identity Provider end.
71 |
72 | Under the SAML Configuration section, you should see a table labeled "Data
73 | you need to put into your Identity Provider". Let's now go to our Identity
74 | Provider, and create a new connection with this information.
75 |
76 | * If you're using OneLogin, go to your OneLogin administrator page. Go to
77 | "Applications", and then click "Add App". Choose "SAML Test Connector
78 | (Advanced)" -- you can use the search to help you find this. Click "Save".
79 |
80 | You've now created a new OneLogin application. We can now paste the
81 | information from TodoApp into OneLogin. Go to "Configuration", and then put
82 | the TodoApp "SAML Recipient ID" into the OneLogin input labeled
83 | "Recipient". Put the TodoApp "SAML Assertion Consumer Service ("ACS") URL"
84 | into both "ACS (Consumer) URL Validator" and "ACS (Consumer) URL".
85 |
86 | Then hit save.
87 |
88 | * If you're using Okta, go to your Okta admin dashboard. Go to
89 | "Applications", and then click "Add Application". Choose "Create New App"
90 | at the top right, and then the "SAML 2.0" option. Then click "Create".
91 |
92 | Give the application any name you like, and then click Next.
93 |
94 | Copy the "SAML Assertion Consumer Service ("ACS") URL" from TodoApp into
95 | the field in Okta called "Single sign on URL". Untick the box labeled "Use
96 | this for Recipient URL and Destination URL". Copy "SAML Recipient ID" from
97 | TodoApp into "Audience URI (SP Entity ID)" and "Recipient URL" in Okta. The
98 | "Destination URL" should be the same thing as the "Single sign on URL" in
99 | Okta. Click "Next".
100 |
101 | Do whatever you like on the "Feedback" page. It doesn't matter. Click
102 | "Finish".
103 |
104 | 1. Next we need to set up SAML from the Service Provider end. In this case,
105 | TodoApp is the service provider.
106 |
107 | * If you're using OneLogin, click on the "More Actions" dropdown at the top
108 | right of the edit page for the OneLogin app you just created. Click on the
109 | "SAML Metadata" option with a download icon.
110 |
111 | Now, go back to localhost:8080, and click on the button to the left of the
112 | "Upload Identity Provider SAML Metadata" button. A file chooser will appear
113 | -- choose the XML file you just downloaded from OneLogin, and then click
114 | "Upload Identity Provider SAML Metadata".
115 |
116 | * If you're using Okta, click on the "Sign On" tab of your application. A
117 | yellow-bordered box contains a link labeled "Identity Provider metadata".
118 | Click on that link. Save the page. Your browser may ask what format you
119 | want to save it in -- choose "XML", not "Web page".
120 |
121 | Now, go back to localhost:8080, and click on the button to the left of the
122 | "Upload Identity Provider SAML Metadata" button. A file chooser will appear
123 | -- choose the XML file you just downloaded/copied from Okta, and then click
124 | "Upload Identity Provider SAML Metadata".
125 |
126 | You'll also need to assign the app to yourself at this time. Go to
127 | "Assignments", click "Assign" and then "Assign People". Find your Okta user
128 | in the list of users, click "Assign", and then "Save and Go Back", then
129 | "Done".
130 |
131 | 1. The table called "Data from your Identity Provider you need to give us"
132 | should now be populated with a bunch of data. These are pieces of information
133 | that TodoApp will need in order to verify the authenticity of the SAML logins
134 | it receives.
135 |
136 | 1. Try clicking on "Initiate SAML Login Flow". You should find yourself briefly
137 | being redirected to your identity provider (this might happen so fast you
138 | can't see it -- check your browser's network logs for proof it's really
139 | happening), and then being redirected back.
140 |
141 | 1. At the top of the page, it should now say you're logged in as
142 | "xxx@yourcompany.com". That's because when TodoApp got the SAML login request
143 | from your Identity Provider, it auto-created an account for you and then
144 | logged you in as them.
145 |
146 | You can see that "xxx@yourcompany.com" email appears under the "Users"
147 | section.
148 |
149 | 1. As a last little test, you can try logging into TodoApp directly from your
150 | Identity Provider, instead of relying on the "Initiate SAML Login Flow" link.
151 | Find the app you created in step (9), and clicking on it. You may need to
152 | exit the "Admin" view of your Identity Provider, so that it logs you in
153 | instead of trying to edit the connection details.
154 |
155 | When you log in from your Identity Provider like this, it's called
156 | "Identity-Provider (IdP) Initiated Login". When we logged in by clicking on
157 | the link in TodoApp, that was "Service-Provider (SP) Initiated Login".
158 |
159 | [okta]: https://www.okta.com/free-trial/
160 | [onelogin]: https://www.onelogin.com/free-trial
161 |
--------------------------------------------------------------------------------
/examples/saml-todo-app/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: "2.4"
2 | services:
3 | postgres:
4 | image: postgres:12.3
5 | environment:
6 | POSTGRES_PASSWORD: "password"
7 | volumes:
8 | - ./init.sql:/docker-entrypoint-initdb.d/init.sql
9 | ports:
10 | - 5432:5432
11 |
--------------------------------------------------------------------------------
/examples/saml-todo-app/init.sql:
--------------------------------------------------------------------------------
1 | create table accounts (
2 | id uuid not null primary key,
3 | saml_issuer varchar,
4 | saml_x509 bytea,
5 | saml_redirect_url varchar
6 | );
7 |
8 | create table users (
9 | id uuid not null primary key,
10 | account_id uuid not null references accounts (id),
11 | saml_id varchar,
12 | display_name varchar not null,
13 | password_hash varchar not null,
14 |
15 | unique (account_id, saml_id)
16 | );
17 |
18 | create table sessions (
19 | id uuid not null primary key,
20 | user_id uuid not null references users (id),
21 | expires_at timestamptz not null
22 | );
23 |
24 | create table todos (
25 | id uuid not null primary key,
26 | author_id uuid not null references users (id),
27 | body varchar not null
28 | );
29 |
--------------------------------------------------------------------------------
/examples/saml-todo-app/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "context"
5 | "crypto/x509"
6 | "database/sql"
7 | "encoding/pem"
8 | "encoding/xml"
9 | "errors"
10 | "fmt"
11 | "html/template"
12 | "net/http"
13 | "time"
14 |
15 | "github.com/google/uuid"
16 | "github.com/jmoiron/sqlx"
17 | "github.com/julienschmidt/httprouter"
18 | _ "github.com/lib/pq"
19 | "github.com/ucarion/saml"
20 | "golang.org/x/crypto/bcrypt"
21 | )
22 |
23 | type account struct {
24 | ID uuid.UUID `db:"id"`
25 | SAMLIssuer *string `db:"saml_issuer"`
26 | SAMLX509 []byte `db:"saml_x509"`
27 | SAMLRedirectURL *string `db:"saml_redirect_url"`
28 | }
29 |
30 | type user struct {
31 | ID uuid.UUID `db:"id"`
32 | AccountID uuid.UUID `db:"account_id"`
33 | SAMLID *string `db:"saml_id"`
34 | DisplayName string `db:"display_name"`
35 | PasswordHash []byte `db:"password_hash"`
36 | }
37 |
38 | type session struct {
39 | ID uuid.UUID `db:"id"`
40 | UserID uuid.UUID `db:"user_id"`
41 | ExpiresAt time.Time `db:"expires_at"`
42 | }
43 |
44 | type todo struct {
45 | ID uuid.UUID `db:"id"`
46 | AuthorID uuid.UUID `db:"author_id"`
47 | Body string `db:"body"`
48 | }
49 |
50 | type store struct {
51 | DB *sqlx.DB
52 | }
53 |
54 | func (s *store) getAccount(ctx context.Context, id uuid.UUID) (account, error) {
55 | var a account
56 | err := s.DB.GetContext(ctx, &a, `
57 | select
58 | id, saml_issuer, saml_x509, saml_redirect_url
59 | from
60 | accounts
61 | where
62 | id = $1
63 | `, id)
64 | return a, err
65 | }
66 |
67 | func (s *store) createAccount(ctx context.Context, a account) error {
68 | _, err := s.DB.ExecContext(ctx, `insert into accounts (id) values ($1)`, a.ID)
69 | return err
70 | }
71 |
72 | func (s *store) updateAccount(ctx context.Context, a account) error {
73 | _, err := s.DB.ExecContext(ctx, `
74 | update
75 | accounts
76 | set
77 | saml_issuer = $1,
78 | saml_x509 = $2,
79 | saml_redirect_url = $3
80 | where
81 | id = $4
82 | `, a.SAMLIssuer, a.SAMLX509, a.SAMLRedirectURL, a.ID)
83 | return err
84 | }
85 |
86 | func (s *store) listUsers(ctx context.Context, accountID uuid.UUID) ([]user, error) {
87 | var users []user
88 | err := s.DB.SelectContext(ctx, &users, `
89 | select
90 | id, account_id, display_name, password_hash
91 | from
92 | users
93 | where
94 | account_id = $1
95 | `, accountID)
96 | return users, err
97 | }
98 |
99 | func (s *store) getUser(ctx context.Context, id uuid.UUID) (user, error) {
100 | var u user
101 | err := s.DB.GetContext(ctx, &u, `
102 | select
103 | id, account_id, saml_id, display_name, password_hash
104 | from
105 | users
106 | where
107 | id = $1
108 | `, id)
109 | return u, err
110 | }
111 |
112 | func (s *store) getUserBySAMLID(ctx context.Context, accountID uuid.UUID, samlID string) (user, error) {
113 | var u user
114 | err := s.DB.GetContext(ctx, &u, `
115 | select
116 | id, account_id, saml_id, display_name, password_hash
117 | from
118 | users
119 | where
120 | account_id = $1 and saml_id = $2
121 | `, accountID, samlID)
122 | return u, err
123 | }
124 |
125 | func (s *store) createUser(ctx context.Context, u user) error {
126 | _, err := s.DB.ExecContext(ctx, `
127 | insert into users
128 | (id, account_id, saml_id, display_name, password_hash)
129 | values
130 | ($1, $2, $3, $4, $5)
131 | `, u.ID, u.AccountID, u.SAMLID, u.DisplayName, u.PasswordHash)
132 | return err
133 | }
134 |
135 | func (s *store) createSession(ctx context.Context, sess session) error {
136 | _, err := s.DB.ExecContext(ctx, `
137 | insert into sessions
138 | (id, user_id, expires_at)
139 | values
140 | ($1, $2, $3)
141 | `, sess.ID, sess.UserID, sess.ExpiresAt)
142 | return err
143 | }
144 |
145 | func (s *store) getSession(ctx context.Context, id uuid.UUID) (session, error) {
146 | var sess session
147 | err := s.DB.GetContext(ctx, &sess, `
148 | select
149 | id, user_id, expires_at
150 | from
151 | sessions
152 | where
153 | sessions.id = $1
154 | `, id)
155 | return sess, err
156 | }
157 |
158 | func (s *store) listTodos(ctx context.Context, accountID uuid.UUID) ([]todo, error) {
159 | var todos []todo
160 | err := s.DB.SelectContext(ctx, &todos, `
161 | select
162 | todos.id, todos.author_id, todos.body
163 | from
164 | todos
165 | join
166 | users on todos.author_id = users.id
167 | where
168 | users.account_id = $1
169 | `, accountID)
170 | return todos, err
171 | }
172 |
173 | func (s *store) createTodo(ctx context.Context, t todo) error {
174 | _, err := s.DB.ExecContext(ctx, `
175 | insert into todos
176 | (id, author_id, body)
177 | values
178 | ($1, $2, $3)
179 | `, t.ID, t.AuthorID, t.Body)
180 | return err
181 | }
182 |
183 | var indexTemplate = template.Must(template.New("index").Parse(`
184 |
185 | SAML TodoApp
186 |
187 |
188 | Sign up
189 |
190 |
205 |
206 | Log in
207 |
208 |
223 | `))
224 |
225 | type getAccountData struct {
226 | ID string
227 | CurrentUser user
228 | SAMLACS string
229 | SAMLRecipientID string
230 | SAMLIssuerID string
231 | SAMLIssuerX509 string
232 | SAMLRedirectURL string
233 | Users []user
234 | Todos []todoWithAuthor
235 | }
236 |
237 | type todoWithAuthor struct {
238 | Todo todo
239 | User user
240 | }
241 |
242 | var getAccountTemplate = template.Must(template.New("get_account").Parse(`
243 |
244 |
245 |
248 |
249 |
250 | SAML TodoApp
251 |
252 | You are logged in as: {{ .CurrentUser.DisplayName }} (id = {{ .CurrentUser.ID }})
253 |
254 | SAML Configuration
255 |
256 | Initiate SAML Login Flow
257 |
258 |
259 |
260 | Data you need to put into your Identity Provider
261 |
262 |
263 |
264 | SAML Assertion Consumer Service ("ACS") URL |
265 | {{ .SAMLACS }} |
266 |
267 |
268 | SAML Recipient ID |
269 | {{ .SAMLRecipientID }} |
270 |
271 |
272 |
273 |
274 |
275 | Data from your Identity Provider you need to give us
276 |
277 |
278 |
279 | SAML Issuer Entity ID |
280 | {{ .SAMLIssuerID }} |
281 |
282 |
283 |
284 | SAML Issuer x509 Certificate |
285 | {{ .SAMLIssuerX509 }}
|
286 |
287 |
288 |
289 | SAML Redirect URL (aka "HTTP-Redirect Binding URL") |
290 | {{ .SAMLRedirectURL }}
|
291 |
292 |
293 |
294 |
298 |
299 | Users
300 |
301 | There are {{ len .Users }} users:
302 |
303 |
304 | {{ range .Users }}
305 | -
306 | {{ .DisplayName }} (id = {{ .ID }})
307 |
308 | {{ end }}
309 |
310 |
311 |
324 |
325 | Todos
326 |
327 | There are {{ len .Todos }} todos:
328 |
329 |
330 | {{ range .Todos }}
331 | -
332 | {{ .User.DisplayName }}: {{ .Todo.Body }} (id = {{ .Todo.ID }})
333 |
334 | {{ end }}
335 |
336 |
337 |
345 |
346 | `))
347 |
348 | func with500(f func(w http.ResponseWriter, r *http.Request, p httprouter.Params) error) httprouter.Handle {
349 | return func(w http.ResponseWriter, r *http.Request, p httprouter.Params) {
350 | if err := f(w, r, p); err != nil {
351 | w.WriteHeader(http.StatusInternalServerError)
352 | fmt.Fprintf(w, "internal server error: %s", err.Error())
353 | }
354 | }
355 | }
356 |
357 | func issueSession(ctx context.Context, s *store, w http.ResponseWriter, u user) error {
358 | sess := session{ID: uuid.New(), UserID: u.ID, ExpiresAt: time.Now().Add(time.Hour * 24)}
359 | if err := s.createSession(ctx, sess); err != nil {
360 | return err
361 | }
362 |
363 | http.SetCookie(w, &http.Cookie{
364 | Name: "session_token",
365 | Path: "/",
366 | Expires: sess.ExpiresAt,
367 | Value: sess.ID.String(),
368 | })
369 |
370 | return nil
371 | }
372 |
373 | var errUnauthorized = errors.New("unauthorized")
374 |
375 | func authorize(s *store, w http.ResponseWriter, r *http.Request, accountID string) (user, error) {
376 | sessionCookie, err := r.Cookie("session_token")
377 | if err != nil {
378 | w.WriteHeader(http.StatusUnauthorized)
379 | return user{}, errUnauthorized
380 | }
381 |
382 | sessionID, err := uuid.Parse(sessionCookie.Value)
383 | if err != nil {
384 | return user{}, err
385 | }
386 |
387 | sess, err := s.getSession(r.Context(), sessionID)
388 | if err != nil {
389 | return user{}, err
390 | }
391 |
392 | u, err := s.getUser(r.Context(), sess.UserID)
393 | if err != nil {
394 | return user{}, err
395 | }
396 |
397 | if u.AccountID.String() != accountID {
398 | w.WriteHeader(http.StatusForbidden)
399 | return user{}, errUnauthorized
400 | }
401 |
402 | return u, nil
403 | }
404 |
405 | func main() {
406 | db, err := sqlx.Open("postgres", "postgres://postgres:password@localhost?sslmode=disable")
407 | if err != nil {
408 | panic(err)
409 | }
410 |
411 | store := store{DB: db}
412 | router := httprouter.New()
413 |
414 | router.GET("/", func(w http.ResponseWriter, r *http.Request, p httprouter.Params) {
415 | w.Header().Add("content-type", "text/html")
416 | indexTemplate.Execute(w, nil)
417 | })
418 |
419 | router.POST("/accounts", with500(func(w http.ResponseWriter, r *http.Request, p httprouter.Params) error {
420 | displayName := r.FormValue("root_display_name")
421 | password := r.FormValue("root_password")
422 |
423 | a := account{ID: uuid.New()}
424 | if err := store.createAccount(r.Context(), a); err != nil {
425 | return err
426 | }
427 |
428 | passwordHash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
429 | if err != nil {
430 | return err
431 | }
432 |
433 | u := user{ID: uuid.New(), AccountID: a.ID, DisplayName: displayName, PasswordHash: passwordHash}
434 | if err := store.createUser(r.Context(), u); err != nil {
435 | return err
436 | }
437 |
438 | if err := issueSession(r.Context(), &store, w, u); err != nil {
439 | return err
440 | }
441 |
442 | http.Redirect(w, r, fmt.Sprintf("/accounts/%s", a.ID.String()), http.StatusFound)
443 | return nil
444 | }))
445 |
446 | router.POST("/login", with500(func(w http.ResponseWriter, r *http.Request, p httprouter.Params) error {
447 | userID := r.FormValue("id")
448 | password := r.FormValue("password")
449 |
450 | userUUID, err := uuid.Parse(userID)
451 | if err != nil {
452 | return err
453 | }
454 |
455 | user, err := store.getUser(r.Context(), userUUID)
456 | if err != nil {
457 | return err
458 | }
459 |
460 | if err := bcrypt.CompareHashAndPassword(user.PasswordHash, []byte(password)); err != nil {
461 | return err
462 | }
463 |
464 | if err := issueSession(r.Context(), &store, w, user); err != nil {
465 | return err
466 | }
467 |
468 | http.Redirect(w, r, fmt.Sprintf("/accounts/%s", user.AccountID.String()), http.StatusFound)
469 | return nil
470 | }))
471 |
472 | router.GET("/accounts/:account_id", with500(func(w http.ResponseWriter, r *http.Request, p httprouter.Params) error {
473 | accountID := p.ByName("account_id")
474 | currentUser, err := authorize(&store, w, r, accountID)
475 | if err != nil {
476 | return nil
477 | }
478 |
479 | accountUUID, err := uuid.Parse(accountID)
480 | if err != nil {
481 | return err
482 | }
483 |
484 | account, err := store.getAccount(r.Context(), accountUUID)
485 | if err != nil {
486 | return err
487 | }
488 |
489 | issuer := ""
490 | if account.SAMLIssuer != nil {
491 | issuer = *account.SAMLIssuer
492 | }
493 |
494 | issuerX509 := ""
495 | if len(account.SAMLX509) != 0 {
496 | issuerX509 = string(pem.EncodeToMemory(&pem.Block{
497 | Type: "CERTIFICATE",
498 | Bytes: account.SAMLX509,
499 | }))
500 | }
501 |
502 | redirectURL := ""
503 | if account.SAMLRedirectURL != nil {
504 | redirectURL = *account.SAMLRedirectURL
505 | }
506 |
507 | todos, err := store.listTodos(r.Context(), accountUUID)
508 | if err != nil {
509 | return err
510 | }
511 |
512 | users, err := store.listUsers(r.Context(), accountUUID)
513 | if err != nil {
514 | return err
515 | }
516 |
517 | todosWithAuthors := []todoWithAuthor{}
518 | for _, todo := range todos {
519 | for _, user := range users {
520 | if user.ID == todo.AuthorID {
521 | todosWithAuthors = append(todosWithAuthors, todoWithAuthor{Todo: todo, User: user})
522 | }
523 | }
524 | }
525 |
526 | w.Header().Add("content-type", "text/html")
527 | getAccountTemplate.Execute(w, getAccountData{
528 | ID: accountID,
529 | CurrentUser: currentUser,
530 | SAMLACS: fmt.Sprintf("http://localhost:8080/accounts/%s/saml/acs", accountID),
531 | SAMLRecipientID: fmt.Sprintf("http://localhost:8080/accounts/%s/saml", accountID),
532 | SAMLIssuerID: issuer,
533 | SAMLIssuerX509: issuerX509,
534 | SAMLRedirectURL: redirectURL,
535 | Users: users,
536 | Todos: todosWithAuthors,
537 | })
538 |
539 | return nil
540 | }))
541 |
542 | router.POST("/accounts/:account_id/metadata", with500(func(w http.ResponseWriter, r *http.Request, p httprouter.Params) error {
543 | accountID := p.ByName("account_id")
544 | if _, err := authorize(&store, w, r, accountID); err != nil {
545 | return nil
546 | }
547 |
548 | accountUUID, err := uuid.Parse(accountID)
549 | if err != nil {
550 | return err
551 | }
552 |
553 | file, _, err := r.FormFile("metadata")
554 | if err != nil {
555 | return err
556 | }
557 |
558 | defer file.Close()
559 | var metadata saml.EntityDescriptor
560 | if err := xml.NewDecoder(file).Decode(&metadata); err != nil {
561 | return err
562 | }
563 |
564 | entityID, cert, redirectURL, err := metadata.GetEntityIDCertificateAndRedirectURL()
565 | if err != nil {
566 | return err
567 | }
568 |
569 | samlRedirectURL := redirectURL.String()
570 | store.updateAccount(r.Context(), account{
571 | ID: accountUUID,
572 | SAMLIssuer: &entityID,
573 | SAMLX509: cert.Raw,
574 | SAMLRedirectURL: &samlRedirectURL,
575 | })
576 |
577 | http.Redirect(w, r, fmt.Sprintf("/accounts/%s", accountID), http.StatusFound)
578 | return nil
579 | }))
580 |
581 | router.GET("/accounts/:account_id/saml/initiate", with500(func(w http.ResponseWriter, r *http.Request, p httprouter.Params) error {
582 | // This endpoint is intentionally not checking for authentication /
583 | // authorization. Think of this endpoint as a customizable login page, where
584 | // we redirect the user to a SAML identity provider of the account's
585 | // choosing.
586 | accountID := p.ByName("account_id")
587 |
588 | accountUUID, err := uuid.Parse(accountID)
589 | if err != nil {
590 | return err
591 | }
592 |
593 | account, err := store.getAccount(r.Context(), accountUUID)
594 | if err != nil {
595 | return err
596 | }
597 |
598 | http.Redirect(w, r, *account.SAMLRedirectURL, http.StatusFound)
599 | return nil
600 | }))
601 |
602 | router.POST("/accounts/:account_id/saml/acs", with500(func(w http.ResponseWriter, r *http.Request, p httprouter.Params) error {
603 | // This is the endpoint that users get redirected to from /saml/initiate
604 | // above, or when log into our app directly from their Identity Provider.
605 | accountID := p.ByName("account_id")
606 |
607 | accountUUID, err := uuid.Parse(accountID)
608 | if err != nil {
609 | return err
610 | }
611 |
612 | account, err := store.getAccount(r.Context(), accountUUID)
613 | if err != nil {
614 | return err
615 | }
616 |
617 | cert, err := x509.ParseCertificate(account.SAMLX509)
618 | if err != nil {
619 | return err
620 | }
621 |
622 | // This is the destination ID we expect to see in the SAML assertion. We
623 | // verify this to make sure that this SAML assertion is meant for us, and
624 | // not some other SAML application in the identity provider.
625 | expectedDestinationID := fmt.Sprintf("http://localhost:8080/accounts/%s/saml", accountID)
626 |
627 | // Get the raw SAML response, and verify it.
628 | rawSAMLResponse := r.FormValue(saml.ParamSAMLResponse)
629 | samlResponse, err := saml.Verify(rawSAMLResponse, *account.SAMLIssuer, cert, expectedDestinationID, time.Now())
630 | if err != nil {
631 | return err
632 | }
633 |
634 | // samlUserID will contain the user ID from the identity provider.
635 | //
636 | // If a user with that saml_id already exists in our database, we'll log the
637 | // user in as them. If no such user already exists, we'll create one first.
638 | samlUserID := samlResponse.Assertion.Subject.NameID.Value
639 | existingUser, err := store.getUserBySAMLID(r.Context(), accountUUID, samlUserID)
640 |
641 | // loginUser will contain the user we should create a session for.
642 | var loginUser user
643 | if err == nil {
644 | // A user with the given saml_id in this account already exists. Log into
645 | // that user.
646 | loginUser = existingUser
647 | } else if err == sql.ErrNoRows {
648 | // No such user already exists. Create one now.
649 | //
650 | // This practice of creating a user like this is often called
651 | // "just-in-time" provisioning.
652 | provisionedUser := user{
653 | AccountID: accountUUID,
654 | ID: uuid.New(),
655 | SAMLID: &samlUserID,
656 | DisplayName: samlUserID,
657 | }
658 |
659 | if err := store.createUser(r.Context(), provisionedUser); err != nil {
660 | return err
661 | }
662 |
663 | loginUser = provisionedUser
664 | } else {
665 | return err
666 | }
667 |
668 | if err := issueSession(r.Context(), &store, w, loginUser); err != nil {
669 | return err
670 | }
671 |
672 | http.Redirect(w, r, fmt.Sprintf("/accounts/%s", accountID), http.StatusFound)
673 | return nil
674 | }))
675 |
676 | router.POST("/accounts/:account_id/users", with500(func(w http.ResponseWriter, r *http.Request, p httprouter.Params) error {
677 | accountID := p.ByName("account_id")
678 | if _, err := authorize(&store, w, r, accountID); err != nil {
679 | return err
680 | }
681 |
682 | accountUUID, err := uuid.Parse(accountID)
683 | if err != nil {
684 | return err
685 | }
686 |
687 | displayName := r.FormValue("display_name")
688 | password := r.FormValue("password")
689 |
690 | passwordHash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
691 | if err != nil {
692 | return err
693 | }
694 |
695 | if err := store.createUser(r.Context(), user{
696 | AccountID: accountUUID,
697 | ID: uuid.New(),
698 | DisplayName: displayName,
699 | PasswordHash: passwordHash,
700 | }); err != nil {
701 | return err
702 | }
703 |
704 | http.Redirect(w, r, fmt.Sprintf("/accounts/%s", accountID), http.StatusFound)
705 | return nil
706 | }))
707 |
708 | router.POST("/accounts/:account_id/todos", with500(func(w http.ResponseWriter, r *http.Request, p httprouter.Params) error {
709 | accountID := p.ByName("account_id")
710 | author, err := authorize(&store, w, r, accountID)
711 | if err != nil {
712 | return err
713 | }
714 |
715 | body := r.FormValue("body")
716 |
717 | if err := store.createTodo(r.Context(), todo{
718 | ID: uuid.New(),
719 | AuthorID: author.ID,
720 | Body: body,
721 | }); err != nil {
722 | return err
723 | }
724 |
725 | http.Redirect(w, r, fmt.Sprintf("/accounts/%s", accountID), http.StatusFound)
726 | return nil
727 | }))
728 |
729 | if err := http.ListenAndServe("localhost:8080", router); err != nil {
730 | panic(err)
731 | }
732 | }
733 |
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/ucarion/saml
2 |
3 | go 1.14
4 |
5 | require (
6 | github.com/google/uuid v1.1.1
7 | github.com/jmoiron/sqlx v1.2.0
8 | github.com/julienschmidt/httprouter v1.3.0
9 | github.com/lib/pq v1.0.0
10 | github.com/stretchr/testify v1.5.1
11 | github.com/ucarion/dsig v0.1.0
12 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2
13 | google.golang.org/appengine v1.6.6 // indirect
14 | )
15 |
--------------------------------------------------------------------------------
/go.sum:
--------------------------------------------------------------------------------
1 | github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
2 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
3 | github.com/go-sql-driver/mysql v1.4.0 h1:7LxgVwFb2hIQtMm87NdgAVfXjnt4OePseqT1tKx+opk=
4 | github.com/go-sql-driver/mysql v1.4.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
5 | github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
6 | github.com/google/uuid v1.1.1 h1:Gkbcsh/GbpXz7lPftLA3P6TYMwjCLYm83jiFQZF/3gY=
7 | github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
8 | github.com/jmoiron/sqlx v1.2.0 h1:41Ip0zITnmWNR/vHV+S4m+VoUivnWY5E4OJfLZjCJMA=
9 | github.com/jmoiron/sqlx v1.2.0/go.mod h1:1FEQNm3xlJgrMD+FBdI9+xvCksHtbpVBBw5dYhBSsks=
10 | github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4dN7jwJOQ1U=
11 | github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM=
12 | github.com/lib/pq v1.0.0 h1:X5PMW56eZitiTeO7tKzZxFCSpbFZJtkMMooicw2us9A=
13 | github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
14 | github.com/mattn/go-sqlite3 v1.9.0 h1:pDRiWfl+++eC2FEFRy6jXmQlvp4Yh3z1MJKg4UeYM/4=
15 | github.com/mattn/go-sqlite3 v1.9.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
16 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
17 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
18 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
19 | github.com/stretchr/testify v1.5.1 h1:nOGnQDM7FYENwehXlg/kFVnos3rEvtKTjRvOWSzb6H4=
20 | github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
21 | github.com/ucarion/c14n v0.1.0 h1:pwYb8DCl4MFu+cpzqvxiQ0oiIePUD1vDIiMmIFmHu9k=
22 | github.com/ucarion/c14n v0.1.0/go.mod h1:Nqoq+dRn2UgVIejUGiBioSecH/PbJpTghkw4jM50zoM=
23 | github.com/ucarion/dsig v0.1.0 h1:roKAJptyGbVd7+Ds1p4B+0NxGc3c+XyDq9HPZ/e56UM=
24 | github.com/ucarion/dsig v0.1.0/go.mod h1:MBUQjaqnJg0ED2E33Q3t/J6t8ebfL/A0lUmWiJMlHso=
25 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2 h1:VklqNMn3ovrHsnt90PveolxSbWFaJdECFbxSq0Mqo2M=
26 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
27 | golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
28 | golang.org/x/net v0.0.0-20200506145744-7e3656a0809f h1:QBjCr1Fz5kw158VqdE9JfI9cJnl/ymnJWAdMuinqL7Y=
29 | golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
30 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
31 | golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
32 | golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg=
33 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
34 | golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs=
35 | golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
36 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
37 | google.golang.org/appengine v1.6.6 h1:lMO5rYAqUxkmaj76jAkRUvt5JZgFymx/+Q5Mzfivuhc=
38 | google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
39 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
40 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
41 | gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw=
42 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
43 |
--------------------------------------------------------------------------------
/saml.go:
--------------------------------------------------------------------------------
1 | package saml
2 |
3 | import (
4 | "bytes"
5 | "crypto/x509"
6 | "encoding/base64"
7 | "encoding/xml"
8 | "errors"
9 | "net/url"
10 | "time"
11 |
12 | "github.com/ucarion/dsig"
13 | )
14 |
15 | // ParamSAMLResponse is the name of the HTTP POST parameter where SAML puts
16 | // responses.
17 | //
18 | // Usually, you want to pass ParamSAMLResponse to r.FormValue when writing HTTP
19 | // handlers that are responding to SAML logins.
20 | const ParamSAMLResponse = "SAMLResponse"
21 |
22 | // ParamRelayState is the name of the HTTP POST parameter where SAML puts relay
23 | // states. It's also the name of the URL query parameter you should put your
24 | // relay state in when initiating the SAML flow.
25 | //
26 | // Usually, you want to pass ParamRelayState to r.FormValue when writing HTTP
27 | // handlers that are responding to SAML logins.
28 | //
29 | // Usually, you want to use ParamRelayState as the URL parameter name when
30 | // writing HTTP handlers that are initiating SAML flows. The URL parameter's
31 | // value should be the state you want to relay through SAML back to yourself.
32 | const ParamRelayState = "RelayState"
33 |
34 | // ErrResponseNotSigned indicates that the SAML response was not signed.
35 | //
36 | // Verify does not support handling unsigned SAML responses. Note that some
37 | // Identity Providers support signing either the full SAML response, or only the
38 | // SAML assertion: Verify only supports having the full SAML response signed,
39 | // and will ignore any additional interior signatures.
40 | var ErrResponseNotSigned = errors.New("saml: response not signed")
41 |
42 | // ErrAssertionExpired indicates that the SAML response is expired, or not yet
43 | // valid.
44 | var ErrAssertionExpired = errors.New("saml: assertion expired")
45 |
46 | // ErrInvalidIssuer indicates that the SAML response did not have the expected
47 | // issuer.
48 | //
49 | // This error may indicate that an attacker is attempting to replay a SAML
50 | // assertion issed by their own identity provider instead of the authorized
51 | // identity provider.
52 | var ErrInvalidIssuer = errors.New("saml: invalid issuer")
53 |
54 | // ErrInvalidRecipient indicates that the SAML response did not have the
55 | // expected recipient.
56 | //
57 | // This error may indicates that an attacker is attempting to replay a SAML
58 | // assertion meant for a different service provider.
59 | var ErrInvalidRecipient = errors.New("saml: invalid recipient")
60 |
61 | // Verify parses and verifies a SAML response.
62 | //
63 | // samlResponse should be the HTTP POST body parameter of a SAML response. For
64 | // valid SAML logins, it will contain base64-encoded XML. Consider using
65 | // ParamSAMLResponse to fetch samlResponse from an HTTP request.
66 | //
67 | // issuer is the expected issuer of the SAML assertion. If samlResponse was
68 | // issued by a different entity, Verify returns ErrInvalidIssuer.
69 | //
70 | // cert is the x509 certificate that the issuer is expected to have signed
71 | // samlResponse with. If samlResponse was not signed at all, Verify returns
72 | // ErrResponseNotSigned. If samlResponse was incorrectly signed, Verify will
73 | // return an error from Verify in github.com/ucarion/dsig.
74 | //
75 | // recipient is the expected recipient of the SAML assertion. If samlResponse
76 | // was issued for a different entity, Verify returns ErrInvalidRecipient.
77 | //
78 | // now should be the current time in production systems, although you may want
79 | // to use a hard-coded time in unit tests. It is used to verify whether
80 | // samlResponse is expired. If samlResponse is expired, Verify returns
81 | // ErrAssertionExpired.
82 | //
83 | // Verify does not check if cert is expired.
84 | func Verify(samlResponse, issuer string, cert *x509.Certificate, recipient string, now time.Time) (Response, error) {
85 | data, err := base64.StdEncoding.DecodeString(samlResponse)
86 | if err != nil {
87 | return Response{}, err
88 | }
89 |
90 | var response Response
91 | if err := xml.Unmarshal(data, &response); err != nil {
92 | return Response{}, err
93 | }
94 |
95 | if response.Signature.SignatureValue == "" {
96 | return Response{}, ErrResponseNotSigned
97 | }
98 |
99 | decoder := xml.NewDecoder(bytes.NewReader(data))
100 | if err := response.Signature.Verify(cert, decoder); err != nil {
101 | return Response{}, err
102 | }
103 |
104 | if response.Assertion.Issuer.Name != issuer {
105 | return Response{}, ErrInvalidIssuer
106 | }
107 |
108 | if response.Assertion.Subject.SubjectConfirmation.SubjectConfirmationData.Recipient != recipient {
109 | return Response{}, ErrInvalidRecipient
110 | }
111 |
112 | if now.Before(response.Assertion.Conditions.NotBefore) {
113 | return Response{}, ErrAssertionExpired
114 | }
115 |
116 | if now.After(response.Assertion.Conditions.NotOnOrAfter) {
117 | return Response{}, ErrAssertionExpired
118 | }
119 |
120 | if now.After(response.Assertion.Subject.SubjectConfirmation.SubjectConfirmationData.NotOnOrAfter) {
121 | return Response{}, ErrAssertionExpired
122 | }
123 |
124 | return response, nil
125 | }
126 |
127 | // Response represents a SAML response.
128 | //
129 | // Verify can construct and verify a Response from an HTTP body parameter.
130 | type Response struct {
131 | XMLName xml.Name `xml:"urn:oasis:names:tc:SAML:2.0:protocol Response"`
132 | Signature dsig.Signature `xml:"Signature"`
133 | Assertion Assertion `xml:"Assertion"`
134 | }
135 |
136 | // Assertion represents a SAML assertion.
137 | //
138 | // An assertion is a set of facts that one entity (usually an Identity Provider)
139 | // passes to another entity (usually a Service Provider). These facts are
140 | // usually information about a particular user, called a subject.
141 | type Assertion struct {
142 | XMLName xml.Name `xml:"urn:oasis:names:tc:SAML:2.0:assertion Assertion"`
143 | Issuer Issuer `xml:"Issuer"`
144 | Subject Subject `xml:"Subject"`
145 | Conditions Conditions `xml:"Conditions"`
146 | AttributeStatement AttributeStatement `xml:"AttributeStatement"`
147 | }
148 |
149 | // Issuer indicates the entity that issued a SAML assertion.
150 | type Issuer struct {
151 | XMLName xml.Name `xml:"urn:oasis:names:tc:SAML:2.0:assertion Issuer"`
152 | Name string `xml:",chardata"`
153 | }
154 |
155 | // Subject indicates the user the SAML assertion is about.
156 | type Subject struct {
157 | XMLName xml.Name `xml:"urn:oasis:names:tc:SAML:2.0:assertion Subject"`
158 | NameID NameID `xml:"NameID"`
159 | SubjectConfirmation SubjectConfirmation `xml:"SubjectConfirmation"`
160 | }
161 |
162 | // NameID describes the primary identifier of the user.
163 | type NameID struct {
164 | XMLName xml.Name `xml:"urn:oasis:names:tc:SAML:2.0:assertion NameID"`
165 | Format string `xml:"Format,attr"`
166 | Value string `xml:",chardata"`
167 | }
168 |
169 | // SubjectConfirmation is a set of information that indicates how, and under
170 | // what conditions, the user's identity was confirmed.
171 | type SubjectConfirmation struct {
172 | XMLName xml.Name `xml:"urn:oasis:names:tc:SAML:2.0:assertion SubjectConfirmation"`
173 | SubjectConfirmationData SubjectConfirmationData `xml:"SubjectConfirmationData"`
174 | }
175 |
176 | // SubjectConfirmationData is a set of constraints about what entities should
177 | // accept this subject, and when the assertion should no longer be considered
178 | // valid.
179 | type SubjectConfirmationData struct {
180 | XMLName xml.Name `xml:"urn:oasis:names:tc:SAML:2.0:assertion SubjectConfirmationData"`
181 | NotOnOrAfter time.Time `xml:"NotOnOrAfter,attr"`
182 | Recipient string `xml:"Recipient,attr"`
183 | }
184 |
185 | // Conditions is a set of constraints that limit under what conditions an
186 | // assertion is valid.
187 | type Conditions struct {
188 | XMLName xml.Name `xml:"urn:oasis:names:tc:SAML:2.0:assertion Conditions"`
189 | NotBefore time.Time `xml:"NotBefore,attr"`
190 | NotOnOrAfter time.Time `xml:"NotOnOrAfter,attr"`
191 | }
192 |
193 | // AttributeStatement is a set of user attributes.
194 | type AttributeStatement struct {
195 | XMLName xml.Name `xml:"urn:oasis:names:tc:SAML:2.0:assertion AttributeStatement"`
196 | Attributes []Attribute `xml:"Attribute"`
197 | }
198 |
199 | // Attribute is a particular key-value attribute of the user in an assertion.
200 | type Attribute struct {
201 | XMLName xml.Name `xml:"urn:oasis:names:tc:SAML:2.0:assertion Attribute"`
202 | Name string `xml:"Name,attr"`
203 | NameFormat string `xml:"NameFormat,attr"`
204 | Value string `xml:"AttributeValue"`
205 | }
206 |
207 | // EntityDescriptor describes a SAML entity. This is often referred to as
208 | // "metadata".
209 | //
210 | // This struct is meant to store "Identity Provider metadata"; it's meant to
211 | // store the description of a SAML Identity Provider.
212 | type EntityDescriptor struct {
213 | XMLName xml.Name `xml:"urn:oasis:names:tc:SAML:2.0:metadata EntityDescriptor"`
214 | EntityID string `xml:"entityID,attr"`
215 | IDPSSODescriptor IDPSSODescriptor `xml:"IDPSSODescriptor"`
216 | }
217 |
218 | // ErrNoRedirectBinding indicates that an EntityDescriptor did not declare an
219 | // HTTP-Redirect binding.
220 | var ErrNoRedirectBinding = errors.New("saml: no HTTP redirect binding in IdP metadata")
221 |
222 | // SingleSignOnServiceBindingHTTPRedirect is the URI for a SAML HTTP-Redirect
223 | // Binding.
224 | const SingleSignOnServiceBindingHTTPRedirect = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect"
225 |
226 | // GetEntityIDCertificateAndRedirectURL extracts an issuer entity ID, a x509
227 | // certificate, and a redirect URL from a set of Identity Provider metadata.
228 | //
229 | // Returns an error if the x509 certificate or redirect URL are malformed. If
230 | // there is no redirect URL at all, returns ErrNoRedirectBinding.
231 | func (d *EntityDescriptor) GetEntityIDCertificateAndRedirectURL() (string, *x509.Certificate, *url.URL, error) {
232 | asn1Data, err := base64.StdEncoding.DecodeString(d.IDPSSODescriptor.KeyDescriptor.KeyInfo.X509Data.X509Certificate.Value)
233 | if err != nil {
234 | return "", nil, nil, err
235 | }
236 |
237 | cert, err := x509.ParseCertificate(asn1Data)
238 | if err != nil {
239 | return "", nil, nil, err
240 | }
241 |
242 | for _, s := range d.IDPSSODescriptor.SingleSignOnServices {
243 | if s.Binding == SingleSignOnServiceBindingHTTPRedirect {
244 | location, err := url.Parse(s.Location)
245 | return d.EntityID, cert, location, err
246 | }
247 | }
248 |
249 | return "", nil, nil, ErrNoRedirectBinding
250 | }
251 |
252 | // IDPSSODescriptor describes the single-sign-on offerings of an identity
253 | // provider.
254 | type IDPSSODescriptor struct {
255 | XMLName xml.Name `xml:"urn:oasis:names:tc:SAML:2.0:metadata IDPSSODescriptor"`
256 | KeyDescriptor KeyDescriptor `xml:"KeyDescriptor"`
257 | SingleSignOnServices []SingleSignOnService `xml:"SingleSignOnService"`
258 | }
259 |
260 | // KeyDescriptor describes the key an identity provider uses to sign data.
261 | type KeyDescriptor struct {
262 | XMLName xml.Name `xml:"urn:oasis:names:tc:SAML:2.0:metadata KeyDescriptor"`
263 | KeyInfo KeyInfo `xml:"KeyInfo"`
264 | }
265 |
266 | // KeyInfo is a XML-DSig description of a x509 key.
267 | type KeyInfo struct {
268 | XMLName xml.Name `xml:"http://www.w3.org/2000/09/xmldsig# KeyInfo"`
269 | X509Data X509Data `xml:"X509Data"`
270 | }
271 |
272 | // X509Data contains an x509 certificate.
273 | type X509Data struct {
274 | XMLName xml.Name `xml:"http://www.w3.org/2000/09/xmldsig# X509Data"`
275 | X509Certificate X509Certificate `xml:"X509Certificate"`
276 | }
277 |
278 | // X509Certificate contains the base64-encoded ASN.1 data of a x509 certificate.
279 | type X509Certificate struct {
280 | XMLName xml.Name `xml:"http://www.w3.org/2000/09/xmldsig# X509Certificate"`
281 | Value string `xml:",chardata"`
282 | }
283 |
284 | // SingleSignOnService describes a single binding of an identity provider, and
285 | // the URL where it can be reached.
286 | type SingleSignOnService struct {
287 | XMLName xml.Name `xml:"urn:oasis:names:tc:SAML:2.0:metadata SingleSignOnService"`
288 | Binding string `xml:"Binding,attr"`
289 | Location string `xml:"Location,attr"`
290 | }
291 |
--------------------------------------------------------------------------------
/saml_test.go:
--------------------------------------------------------------------------------
1 | package saml_test
2 |
3 | import (
4 | "crypto/rsa"
5 | "crypto/x509"
6 | "encoding/asn1"
7 | "encoding/base64"
8 | "encoding/pem"
9 | "encoding/xml"
10 | "fmt"
11 | "io/ioutil"
12 | "testing"
13 | "time"
14 |
15 | "github.com/stretchr/testify/assert"
16 | "github.com/ucarion/dsig"
17 | "github.com/ucarion/saml"
18 | )
19 |
20 | // The cert and key used in these tests were generated by running:
21 | //
22 | // openssl req -x509 -newkey rsa:4096 -keyout key.pem -out cert.pem -days 365
23 | // -nodes -subj "/C=US/ST=Oregon/L=Portland/O=Company
24 | // Name/OU=Org/CN=www.example.com"
25 | //
26 | // Which generated the certificate used in TestVerify. It also generated this
27 | // corresponding RSA private key:
28 | //
29 | // -----BEGIN PRIVATE KEY-----
30 | // MIIJQgIBADANBgkqhkiG9w0BAQEFAASCCSwwggkoAgEAAoICAQC4yrnAdbSwMQz8
31 | // CPL0ir2TbhNadYCjukgxQxxjGeVVuRxHFzKyLlaM17ZG6x67F7DqvxXwO8Bvfnur
32 | // eFDG8uq13eIni3Tvl38G4pFQOLHgY/hvKS9E4L8bBPDaXag6jD463arduvOo0ZzB
33 | // movgoqw4ryze53I2z8D+eIPa3WmRX93gZ69UemcAo7MJv/WzpByimefah/+TSTFd
34 | // r5q1kVOxaJclcoAbTcDRxZEVGc6l/BRZQHrz5VG3JEO8M1KuzAq0nMyFkyP1ynGv
35 | // P66syV+DYcqjSJ3cB+fgeIC1VT2ZgwzdxBWJeRIgCNT8YOmI9FpiYrurEcU9iLnf
36 | // HQ0O3422QLZrJVxyY47c7q9kBPUNUZ+ewWgPeSeRDSl3L0wfknNebemmZr7IRYUV
37 | // h6bTIqB/MRORp70E/Vnp5QBrofdkaF8Z6zslnz50FAj3pZVsmEaQjh/yBqxghV4H
38 | // qivNmf+6p8/38PjhlU9x/M1dmmjr1NKau4ILrS9meEL2+rweqV4TZq++T7d57Bor
39 | // oz1zytKXp/QwvGQcpD+TRtuhHZkpLHROyGSdCR9XnYBI5FeQOVi53vhT+BDrbrXE
40 | // +CkfmALylLFsflBeF/9IhmyHAcEpkj/0FZYjAXoEZEEeAphawd/qVspt30J7X35O
41 | // cqTIPDilNVL+WWMi0lHsWO5h9FzPMwIDAQABAoICAH3d353ezp8AGgcFlW7JnYzw
42 | // +g+wX1mmBYxAWPKLbfDwr/kgLPC+rUcrmsU9WuY2odOTKj9Cg7WtolDOF78bMJGF
43 | // u4gR7ilPuD8ZTb8ljsr3bP1SQRcaOjEOMXubNX4DjlOMLtjugQ6pD6uzN7lfNA08
44 | // DEUbwmjhI2Rw8+a8zy4s7TTvirXw1X3TAp0Oei3NB5AdYpYv8f4BabWVabxoa2g4
45 | // hFMGZYmzcTWw6zxDIsVeKQIN8HF17i3fbp+fGZ9j7ZrN/mSxL1o4dSzYJIMeeodD
46 | // scF8McHwRJlZmtloYRfR8o6PA9hqddUKDwCEhi05uuKuu4MvDHj4SxpUcFOEI8Iq
47 | // cxtM4m8kbLPpWnHYC0xUUigP5lY0P8HoQqO6ktLJNv2s2pEKxXFCA7kTiA2xyiEC
48 | // iisuoyGGlXQ8HB8L1ShYDrwjPlbJ7CCTl3yZ8kruq6kp5kF/Cps39cmM0WsvJXK0
49 | // OjYBNFAO2RnoRaoJQrh60rWPjK/JoqpUGrRPG7+k1VuGdGBaOw4YvC339koALXpK
50 | // sEDvwIztPsv2AwE0WMw5NmhnRtmNUXb3LhlrjPOZ5e2HyRhdrLpBu5y79tcF8VN9
51 | // mksfIQqhNxKpidBkju4u6nyYOvQL013vKJSJRfXxYXRKQgGO0gBhasjjwBRjeOvY
52 | // uLKVpnJ1Ncq4nr1yFLdZAoIBAQDfl0kxOJeFBYSVQRKxiXIL+xXm9mSQ3fu2Ir4T
53 | // JoMkkz0pBkXCI5aC+JnR8CmmTVXD+T5BDYcXGngGAZQZDPvFhXs9J0dpJJRVajBT
54 | // Cj7OPRu51o95gjmYVpOHtKwRQal8LerPajd60YsRNNuMsExkJkmirUly3bkaevEh
55 | // Gqt0RK6/qTGDy5M0u5KYhMy/mgg+mSWLfjLmdR7FaszVmIEa6WZr5dTMmmidPq7b
56 | // GHWvXwLZg6VIeGzRhAb33XLDBrB/S/IOY98dz4hasVZ/f+EskgFqkEXe/X1i1YSN
57 | // PT1VNSOWA6Rj/h+rmA7NOdLZMrzi12sId2Y6sjvbPERNPhftAoIBAQDTk7xn6usz
58 | // /MIJk4HhYwlVJadPnfsFP7Z0AGXGY83OclpsiDpJPC8nWGvc40oqtmqz4cOcdy+H
59 | // KFtu1JGyDn9W36y9+NViQp+RJ1NKosdV4/N7L9nXXi1y7uNe9QvdTjtS44xXFEk5
60 | // FLDoKzGDXkPp77eA6BPfqMMFymf8mgq+MpWioKLiR43w+Zc+/Ncz6zMSsr/nPH6e
61 | // 1Gjh0Nva4/M5aelJU+i5P1bJlcrRs6//N3RQjPgCBF5NDj2SEseAH8cQkxhfsXB+
62 | // xfWyY7ocGPNlO+sGarLqaftqSSD1J7wZ8dbgHysnTJkdhhWJmRbYxSoZBwSG1CSI
63 | // kvDugRZ8N1+fAoIBACCb2crZ7A80bM+vu+A0oXNp3RngGW6fUVSQ4JO+bCXra2IO
64 | // TiIwOoVDaHubwRdF9BouwYuPQ4J1E8gcdtLod9eozf5vOhT1hsSmRgH2Xo6Jjv+d
65 | // cTNRcMDs73s9OFMT9nnr4HD7lrfM07FguhxcoeeBRf/5sdqUx6g7AevIDfVZBvtg
66 | // 253TFNb9/DVOOOZAuq8WeslLUHUX47L7DoCgS0P3gj5+OHjWlCdKuwmtGYzIGIxM
67 | // jNBy77vmu3Vu0Ivs79TA6L58hk+8srA3aNwTdG2hpZ87B1WsNpsxdLF8mvNQWq5I
68 | // PbNvnoLSHGaF5mBS7AVRUYTclQY+dEhXE8cIJUkCggEBAMU9bN7TugD1GU8kHGip
69 | // kwG14IvwkxsJkmYCGN8iG7LiGDolpXCwkqTzYVrC6Vl4RXD8fwdWdRBjJxnjQQ/l
70 | // RAEQ9FEFsKexxF/lcVia94mywEGPEl4chfInkf/sIetmCxfy2do0Jy73gxRtb/Mv
71 | // 5dAokcGymRRgl67GSrrKQEmfjq/VYQPiAQktJTqrK1RTZ4F+8jf3xXL8QeqCcvNU
72 | // nmJfwgOCHerUiWvUIQfto50hbWXKhUocGG1tYSjUKPfgqAtjlc1f9ae5lJuBLPcU
73 | // q5MskKWiwriVpLQpCHiDWnA1bEPzyp8QYY2MeneUKCBdbil2yVmIW6aWldVCsluK
74 | // o7ECggEAbp+MZOPzKYTEGVWLNQh0CVairBVrOexlOFrup7sOW0NFQXu8ExHsRsgC
75 | // HMEvBj24jJM6FeaJ4Fkc1WAfJqY0KnpWeEPFzLY9W7ZEHbkyiHJ0DzvReXPYGWSC
76 | // Qj0dgv0jfDODdsfTqI6zW/WXHEQ8399JiAEVGVphMUo2oY+rhDAiZCFFlt7heyq2
77 | // fLf4MAmc3vK6slbyaDb9kYm+fsiCBVqvwVIKvIZ1/IOOU5q6KQIYjJXryLIBORuw
78 | // 3jlAmnFMZFC0dBPJAHeon8m47S/1Te2EkyH1D1GvcDnE07PjhFUl3LpbD4qrw0Wv
79 | // tRNOxnQnlHJKcCgbfcUOD3hpFKtY9g==
80 | // -----END PRIVATE KEY-----
81 |
82 | func TestVerify(t *testing.T) {
83 | block, _ := pem.Decode([]byte(`-----BEGIN CERTIFICATE-----
84 | MIIFXDCCA0QCCQCl4WZtbTlavDANBgkqhkiG9w0BAQsFADBwMQswCQYDVQQGEwJV
85 | UzEPMA0GA1UECAwGT3JlZ29uMREwDwYDVQQHDAhQb3J0bGFuZDEVMBMGA1UECgwM
86 | Q29tcGFueSBOYW1lMQwwCgYDVQQLDANPcmcxGDAWBgNVBAMMD3d3dy5leGFtcGxl
87 | LmNvbTAeFw0yMDA1MjAxNzI0MzFaFw0yMTA1MjAxNzI0MzFaMHAxCzAJBgNVBAYT
88 | AlVTMQ8wDQYDVQQIDAZPcmVnb24xETAPBgNVBAcMCFBvcnRsYW5kMRUwEwYDVQQK
89 | DAxDb21wYW55IE5hbWUxDDAKBgNVBAsMA09yZzEYMBYGA1UEAwwPd3d3LmV4YW1w
90 | bGUuY29tMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAuMq5wHW0sDEM
91 | /Ajy9Iq9k24TWnWAo7pIMUMcYxnlVbkcRxcysi5WjNe2Ruseuxew6r8V8DvAb357
92 | q3hQxvLqtd3iJ4t075d/BuKRUDix4GP4bykvROC/GwTw2l2oOow+Ot2q3brzqNGc
93 | wZqL4KKsOK8s3udyNs/A/niD2t1pkV/d4GevVHpnAKOzCb/1s6Qcopnn2of/k0kx
94 | Xa+atZFTsWiXJXKAG03A0cWRFRnOpfwUWUB68+VRtyRDvDNSrswKtJzMhZMj9cpx
95 | rz+urMlfg2HKo0id3Afn4HiAtVU9mYMM3cQViXkSIAjU/GDpiPRaYmK7qxHFPYi5
96 | 3x0NDt+NtkC2ayVccmOO3O6vZAT1DVGfnsFoD3knkQ0pdy9MH5JzXm3ppma+yEWF
97 | FYem0yKgfzETkae9BP1Z6eUAa6H3ZGhfGes7JZ8+dBQI96WVbJhGkI4f8gasYIVe
98 | B6orzZn/uqfP9/D44ZVPcfzNXZpo69TSmruCC60vZnhC9vq8HqleE2avvk+3eewa
99 | K6M9c8rSl6f0MLxkHKQ/k0bboR2ZKSx0TshknQkfV52ASORXkDlYud74U/gQ6261
100 | xPgpH5gC8pSxbH5QXhf/SIZshwHBKZI/9BWWIwF6BGRBHgKYWsHf6lbKbd9Ce19+
101 | TnKkyDw4pTVS/lljItJR7FjuYfRczzMCAwEAATANBgkqhkiG9w0BAQsFAAOCAgEA
102 | r6UAa9n4FkiA4ZqugCJEoC5Ehc1X/qdNFkY4EIHc33sqscqVZhHC0MbfNmKuiirk
103 | XKTR+M3U62IvD8HXpkBMTYMpnvsH4jFuP3SpTFfUuqarueqsawiPAejhjF9829fg
104 | K1+s1rD/fI3H3UuHWChTXKA4KpnCYr5B1om4ZoCcTVVdZjhO256iM7p/DHze08Eo
105 | Rdhaj+rgs6NC5vLHWX9bezACeqA3YwJYHRH0zuoCQfRKXkikIjj18wpWNARFhDoQ
106 | FEhJXIAO/skpuK6Q9Ml1wWuFaqgXtKN1iVzuGi7P8O3bCLexwmqnmsnEZPPpzjoQ
107 | T8zVIjCH6jBX533f1B745IrGNzMSr6YC/9RT3DrPoNT9pCAozSoZxldqIegxLgWG
108 | zBT6jj/fR92E5kJh8Hy3koeXGkyAkcHB0PH8yyFtYIlP0stENkG/fDCLuMUqf6GZ
109 | P/oSyJH1Ro/qV6kwc1XYDB+6NGC8Xd1JQKZD49c/GZYpo77ZYKQtCoTrMuPKSG5/
110 | jP7OTrdylTj+V4r7jYLLpvWCUe0ON0QPKClo+15tXATWep6PFk0U5W+efvavG70e
111 | Fu9GKMOkTgv5F/ngzDgXKo7T6poRDZAgolUAq2kwDUp42AVx/7UqmOdp0yUTNmJG
112 | A70UwPLAvWk5vX1IMpaEFjBd3LqWLeSmbKZ03zr1jnA=
113 | -----END CERTIFICATE-----`))
114 |
115 | cert, err := x509.ParseCertificate(block.Bytes)
116 | assert.NoError(t, err)
117 |
118 | now, err := time.Parse(time.RFC3339, "2020-05-23T01:46:00Z")
119 | assert.NoError(t, err)
120 |
121 | type testCase struct {
122 | Name string
123 | Response saml.Response
124 | Error error
125 | }
126 |
127 | testCases := []testCase{
128 | testCase{
129 | Name: "unsigned",
130 | Error: saml.ErrResponseNotSigned,
131 | },
132 | testCase{
133 | Name: "invalid_signature",
134 | Error: rsa.ErrVerification,
135 | },
136 | testCase{
137 | Name: "wrong_issuer",
138 | Error: saml.ErrInvalidIssuer,
139 | },
140 | testCase{
141 | Name: "wrong_recipient",
142 | Error: saml.ErrInvalidRecipient,
143 | },
144 | testCase{
145 | Name: "before_conditions_not_before",
146 | Error: saml.ErrAssertionExpired,
147 | },
148 | testCase{
149 | Name: "after_conditions_not_on_or_after",
150 | Error: saml.ErrAssertionExpired,
151 | },
152 | testCase{
153 | Name: "after_subject_confirmation_data_not_on_or_after",
154 | Error: saml.ErrAssertionExpired,
155 | },
156 | testCase{
157 | Name: "valid",
158 | Response: saml.Response{
159 | XMLName: xml.Name{Space: "urn:oasis:names:tc:SAML:2.0:protocol", Local: "Response"},
160 | Assertion: saml.Assertion{
161 | XMLName: xml.Name{Space: "urn:oasis:names:tc:SAML:2.0:assertion", Local: "Assertion"},
162 | Issuer: saml.Issuer{
163 | XMLName: xml.Name{Space: "urn:oasis:names:tc:SAML:2.0:assertion", Local: "Issuer"},
164 | Name: "alice",
165 | },
166 | Subject: saml.Subject{
167 | XMLName: xml.Name{Space: "urn:oasis:names:tc:SAML:2.0:assertion", Local: "Subject"},
168 | NameID: saml.NameID{
169 | XMLName: xml.Name{Space: "urn:oasis:names:tc:SAML:2.0:assertion", Local: "NameID"},
170 | Format: "format",
171 | Value: "jdoe@example.com",
172 | },
173 | SubjectConfirmation: saml.SubjectConfirmation{
174 | XMLName: xml.Name{Space: "urn:oasis:names:tc:SAML:2.0:assertion", Local: "SubjectConfirmation"},
175 | SubjectConfirmationData: saml.SubjectConfirmationData{
176 | XMLName: xml.Name{Space: "urn:oasis:names:tc:SAML:2.0:assertion", Local: "SubjectConfirmationData"},
177 | NotOnOrAfter: now.Add(time.Minute),
178 | Recipient: "bob",
179 | },
180 | },
181 | },
182 | Conditions: saml.Conditions{
183 | XMLName: xml.Name{Space: "urn:oasis:names:tc:SAML:2.0:assertion", Local: "Conditions"},
184 | NotBefore: now.Add(-time.Minute),
185 | NotOnOrAfter: now.Add(time.Minute),
186 | },
187 | AttributeStatement: saml.AttributeStatement{
188 | XMLName: xml.Name{Space: "urn:oasis:names:tc:SAML:2.0:assertion", Local: "AttributeStatement"},
189 | Attributes: []saml.Attribute{
190 | saml.Attribute{
191 | XMLName: xml.Name{Space: "urn:oasis:names:tc:SAML:2.0:assertion", Local: "Attribute"},
192 | Name: "attr1",
193 | NameFormat: "fmt1",
194 | Value: "value1",
195 | },
196 | saml.Attribute{
197 | XMLName: xml.Name{Space: "urn:oasis:names:tc:SAML:2.0:assertion", Local: "Attribute"},
198 | Name: "attr2",
199 | NameFormat: "fmt2",
200 | Value: "value2",
201 | },
202 | },
203 | },
204 | },
205 | },
206 | },
207 | }
208 |
209 | for _, tt := range testCases {
210 | t.Run(tt.Name, func(t *testing.T) {
211 | b, err := ioutil.ReadFile(fmt.Sprintf("tests/%s.xml", tt.Name))
212 | assert.NoError(t, err)
213 |
214 | res, err := saml.Verify(base64.StdEncoding.EncodeToString(b), "alice", cert, "bob", now)
215 | res.Signature = dsig.Signature{} // clear this out to make test cases a bit less verbose
216 |
217 | assert.Equal(t, tt.Response, res)
218 | assert.Equal(t, tt.Error, err)
219 | })
220 | }
221 | }
222 |
223 | func TestVerify_InvalidBase64(t *testing.T) {
224 | _, err := saml.Verify("NOT BASE64", "", nil, "", time.Now())
225 | assert.Equal(t, base64.CorruptInputError(3), err)
226 | }
227 |
228 | func TestVerify_InvalidXML(t *testing.T) {
229 | _, err := saml.Verify(base64.StdEncoding.EncodeToString([]byte("
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 | Mc5G8zUSwYPLc0DPOCkjU5DiJIE=
14 |
15 |
16 | pJAVtvCQxyF5CMwEVMmmtElNsEOBwoFc4NRpLhC+y8HX4+6Gm8IIHq8Erw/z/uIk
17 | ejKGQS6FU593F+QBiOAWoXazvUT5SRGU3HfhF6FXom3S5TcnZrJRzGoSRpGG8d1I
18 | y/jlF9jeKSqs+4PKMIFwe+I8uLw2o5FRuVEk0Np48UxxyqqjUhvN8teabbFwYNAs
19 | zwqOMgd9knKjo3NOgwYwcs0eo79PkK4s3+dTf/MhEx0gMdZl8rgkZ1aQ213jroRc
20 | 7g/EwVNGlL/wwXGuv3A+ECbqG9mKwL5he5p7c3n3/5kdt0Y2Dvs/dShCcc1Gc5rK
21 | XBC88lhfgsa0Z/HW8qVcR/UTiZvretetakB1tRNq65UUoAwUjw4Oj58IbtL9nQn8
22 | TY7/qKymDe1z/ud+aOVM+EBCvvRmrLJsB4IqY5VR8D++z2PGOdcuizttzYiGe7Dy
23 | agAuF5UaIdMRmU5bbBK8xQV6N3/o+nzh1ukXRg/cgjNyjdvL7rnkwr/eN1EjtZC5
24 | gZRi2BmXU6UqQFA24nLRiOyZSQ3tumYHKON7zsRgsHpE+gY7ufHuddgASuvsmX9k
25 | VosisNR81H7b+aFtAvZmagSTFKrLuIoICVBJzIzDki4roTmTqY530TOLohPwPRkq
26 | RTXi699ltQTz1H4SopZqyRByYN4Lwr+p0Q7eCZRGIoc=
27 |
28 |
29 | MIIFXDCCA0QCCQCl4WZtbTlavDANBgkqhkiG9w0BAQsFADBwMQswCQYDVQQGEwJV
30 | UzEPMA0GA1UECAwGT3JlZ29uMREwDwYDVQQHDAhQb3J0bGFuZDEVMBMGA1UECgwM
31 | Q29tcGFueSBOYW1lMQwwCgYDVQQLDANPcmcxGDAWBgNVBAMMD3d3dy5leGFtcGxl
32 | LmNvbTAeFw0yMDA1MjAxNzI0MzFaFw0yMTA1MjAxNzI0MzFaMHAxCzAJBgNVBAYT
33 | AlVTMQ8wDQYDVQQIDAZPcmVnb24xETAPBgNVBAcMCFBvcnRsYW5kMRUwEwYDVQQK
34 | DAxDb21wYW55IE5hbWUxDDAKBgNVBAsMA09yZzEYMBYGA1UEAwwPd3d3LmV4YW1w
35 | bGUuY29tMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAuMq5wHW0sDEM
36 | /Ajy9Iq9k24TWnWAo7pIMUMcYxnlVbkcRxcysi5WjNe2Ruseuxew6r8V8DvAb357
37 | q3hQxvLqtd3iJ4t075d/BuKRUDix4GP4bykvROC/GwTw2l2oOow+Ot2q3brzqNGc
38 | wZqL4KKsOK8s3udyNs/A/niD2t1pkV/d4GevVHpnAKOzCb/1s6Qcopnn2of/k0kx
39 | Xa+atZFTsWiXJXKAG03A0cWRFRnOpfwUWUB68+VRtyRDvDNSrswKtJzMhZMj9cpx
40 | rz+urMlfg2HKo0id3Afn4HiAtVU9mYMM3cQViXkSIAjU/GDpiPRaYmK7qxHFPYi5
41 | 3x0NDt+NtkC2ayVccmOO3O6vZAT1DVGfnsFoD3knkQ0pdy9MH5JzXm3ppma+yEWF
42 | FYem0yKgfzETkae9BP1Z6eUAa6H3ZGhfGes7JZ8+dBQI96WVbJhGkI4f8gasYIVe
43 | B6orzZn/uqfP9/D44ZVPcfzNXZpo69TSmruCC60vZnhC9vq8HqleE2avvk+3eewa
44 | K6M9c8rSl6f0MLxkHKQ/k0bboR2ZKSx0TshknQkfV52ASORXkDlYud74U/gQ6261
45 | xPgpH5gC8pSxbH5QXhf/SIZshwHBKZI/9BWWIwF6BGRBHgKYWsHf6lbKbd9Ce19+
46 | TnKkyDw4pTVS/lljItJR7FjuYfRczzMCAwEAATANBgkqhkiG9w0BAQsFAAOCAgEA
47 | r6UAa9n4FkiA4ZqugCJEoC5Ehc1X/qdNFkY4EIHc33sqscqVZhHC0MbfNmKuiirk
48 | XKTR+M3U62IvD8HXpkBMTYMpnvsH4jFuP3SpTFfUuqarueqsawiPAejhjF9829fg
49 | K1+s1rD/fI3H3UuHWChTXKA4KpnCYr5B1om4ZoCcTVVdZjhO256iM7p/DHze08Eo
50 | Rdhaj+rgs6NC5vLHWX9bezACeqA3YwJYHRH0zuoCQfRKXkikIjj18wpWNARFhDoQ
51 | FEhJXIAO/skpuK6Q9Ml1wWuFaqgXtKN1iVzuGi7P8O3bCLexwmqnmsnEZPPpzjoQ
52 | T8zVIjCH6jBX533f1B745IrGNzMSr6YC/9RT3DrPoNT9pCAozSoZxldqIegxLgWG
53 | zBT6jj/fR92E5kJh8Hy3koeXGkyAkcHB0PH8yyFtYIlP0stENkG/fDCLuMUqf6GZ
54 | P/oSyJH1Ro/qV6kwc1XYDB+6NGC8Xd1JQKZD49c/GZYpo77ZYKQtCoTrMuPKSG5/
55 | jP7OTrdylTj+V4r7jYLLpvWCUe0ON0QPKClo+15tXATWep6PFk0U5W+efvavG70e
56 | Fu9GKMOkTgv5F/ngzDgXKo7T6poRDZAgolUAq2kwDUp42AVx/7UqmOdp0yUTNmJG
57 | A70UwPLAvWk5vX1IMpaEFjBd3LqWLeSmbKZ03zr1jnA=
58 |
59 |
60 |
61 |
62 |
63 | alice
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
--------------------------------------------------------------------------------
/tests/after_subject_confirmation_data_not_on_or_after.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 | BVnTESg7ysdjf91OYHqJmkT9+U8=
14 |
15 |
16 | LJMxxC5C5id38c0ScTXpS2SK8FUTc6iEf8ughctjD1t0tN7hmJhQsRA6/KTB7Pxn
17 | 1Rie9GOQYzp/sWwrWiqN1ZbvD03OqqXU6Zf3RpbfxehPetlzGDLZW0jqzp2GTWmD
18 | p2GwSZLnOvzIq7+ukvYb2WgwYK/GhBWWFr0kPO8euPYEPMY9PKaUSUVeHDcGn927
19 | 8T9A4JoXx0GILEq21sJwwnZNb2pc1+odSlH9E5hlKwkf+MAJpw/NT5L4jtNy+o4W
20 | p1BK+r8CuJtQDMurYjJ/8+OjU/6+YuX2INSqYOdsGE1lG5OsmV/y8Tnn18ELRRze
21 | a2Ffkn/t4wJjEKW5SWELG/yAPbGRZbRzI5mR9gh7j7fb1oLmO/qnkNijvL67yNNi
22 | 2zxl0daF66iAczk8GhH7Y+16oOkBbMUPIVGA135BfuYihtG04LFFWi7A8GyaAOTL
23 | jWqB4Zvls7z/HdS1yvyyHj3s8EZ1UMPS3P8RP0H5EiKCqSs6f+0AbeT9Cx/3DuxJ
24 | pQA/0FcO97l2aUjKg63FJU9KRcK1nm3aCp0SL7S9h6dl3J66GKPbjzEv1f/7Vxqu
25 | V8yf4THUGEVWa0OzIGKYalctISv/5pjQXAQjSHxkZWuYuKZXdAS1Lm26s8wqIWgu
26 | n0WHWXQVFt7mIPCccdTIUK6ah2QIdr21P6afyxw9E3g=
27 |
28 |
29 | MIIFXDCCA0QCCQCl4WZtbTlavDANBgkqhkiG9w0BAQsFADBwMQswCQYDVQQGEwJV
30 | UzEPMA0GA1UECAwGT3JlZ29uMREwDwYDVQQHDAhQb3J0bGFuZDEVMBMGA1UECgwM
31 | Q29tcGFueSBOYW1lMQwwCgYDVQQLDANPcmcxGDAWBgNVBAMMD3d3dy5leGFtcGxl
32 | LmNvbTAeFw0yMDA1MjAxNzI0MzFaFw0yMTA1MjAxNzI0MzFaMHAxCzAJBgNVBAYT
33 | AlVTMQ8wDQYDVQQIDAZPcmVnb24xETAPBgNVBAcMCFBvcnRsYW5kMRUwEwYDVQQK
34 | DAxDb21wYW55IE5hbWUxDDAKBgNVBAsMA09yZzEYMBYGA1UEAwwPd3d3LmV4YW1w
35 | bGUuY29tMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAuMq5wHW0sDEM
36 | /Ajy9Iq9k24TWnWAo7pIMUMcYxnlVbkcRxcysi5WjNe2Ruseuxew6r8V8DvAb357
37 | q3hQxvLqtd3iJ4t075d/BuKRUDix4GP4bykvROC/GwTw2l2oOow+Ot2q3brzqNGc
38 | wZqL4KKsOK8s3udyNs/A/niD2t1pkV/d4GevVHpnAKOzCb/1s6Qcopnn2of/k0kx
39 | Xa+atZFTsWiXJXKAG03A0cWRFRnOpfwUWUB68+VRtyRDvDNSrswKtJzMhZMj9cpx
40 | rz+urMlfg2HKo0id3Afn4HiAtVU9mYMM3cQViXkSIAjU/GDpiPRaYmK7qxHFPYi5
41 | 3x0NDt+NtkC2ayVccmOO3O6vZAT1DVGfnsFoD3knkQ0pdy9MH5JzXm3ppma+yEWF
42 | FYem0yKgfzETkae9BP1Z6eUAa6H3ZGhfGes7JZ8+dBQI96WVbJhGkI4f8gasYIVe
43 | B6orzZn/uqfP9/D44ZVPcfzNXZpo69TSmruCC60vZnhC9vq8HqleE2avvk+3eewa
44 | K6M9c8rSl6f0MLxkHKQ/k0bboR2ZKSx0TshknQkfV52ASORXkDlYud74U/gQ6261
45 | xPgpH5gC8pSxbH5QXhf/SIZshwHBKZI/9BWWIwF6BGRBHgKYWsHf6lbKbd9Ce19+
46 | TnKkyDw4pTVS/lljItJR7FjuYfRczzMCAwEAATANBgkqhkiG9w0BAQsFAAOCAgEA
47 | r6UAa9n4FkiA4ZqugCJEoC5Ehc1X/qdNFkY4EIHc33sqscqVZhHC0MbfNmKuiirk
48 | XKTR+M3U62IvD8HXpkBMTYMpnvsH4jFuP3SpTFfUuqarueqsawiPAejhjF9829fg
49 | K1+s1rD/fI3H3UuHWChTXKA4KpnCYr5B1om4ZoCcTVVdZjhO256iM7p/DHze08Eo
50 | Rdhaj+rgs6NC5vLHWX9bezACeqA3YwJYHRH0zuoCQfRKXkikIjj18wpWNARFhDoQ
51 | FEhJXIAO/skpuK6Q9Ml1wWuFaqgXtKN1iVzuGi7P8O3bCLexwmqnmsnEZPPpzjoQ
52 | T8zVIjCH6jBX533f1B745IrGNzMSr6YC/9RT3DrPoNT9pCAozSoZxldqIegxLgWG
53 | zBT6jj/fR92E5kJh8Hy3koeXGkyAkcHB0PH8yyFtYIlP0stENkG/fDCLuMUqf6GZ
54 | P/oSyJH1Ro/qV6kwc1XYDB+6NGC8Xd1JQKZD49c/GZYpo77ZYKQtCoTrMuPKSG5/
55 | jP7OTrdylTj+V4r7jYLLpvWCUe0ON0QPKClo+15tXATWep6PFk0U5W+efvavG70e
56 | Fu9GKMOkTgv5F/ngzDgXKo7T6poRDZAgolUAq2kwDUp42AVx/7UqmOdp0yUTNmJG
57 | A70UwPLAvWk5vX1IMpaEFjBd3LqWLeSmbKZ03zr1jnA=
58 |
59 |
60 |
61 |
62 |
63 | alice
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
--------------------------------------------------------------------------------
/tests/before_conditions_not_before.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 | xrJnbS0r0ldKuXpn+6dtbOeyBFQ=
14 |
15 |
16 | GZz/XQSLmhJWTaY1KUwzuZROMx9tEJZJqJ39eSMPpreyMUno2DH7Yoyb8LTS3HBc
17 | uKq/68AI4R/MMM8F0p4/1T6iHFJ3kE4mgkjnMIV+pyjDkgfAOaz08/FBRiQA3tYr
18 | Pen+W0Olh/ZrH4Jh8YTK/b8SwK5mgLeBQu6qLsOKlWx32/7EF5jd33fqwLHdUFxw
19 | 7PsaSKAsWsggSIVNIUN+7h4wrvKxfZ6RezPd6pACpdkO1wuQy/1Qr+SXNzzxtzYD
20 | LrQBaU3QkkocJw+JIFi0AnlirD/Dh7Mf57hzhBGeZjznwHBQc2hxU656w1EWKr4Y
21 | 7YbGiD0V7OAKv5YPwg616GbeZ+UDMuJ33uVpqNVoDLzS8qhjvoncdoF794Bx18LN
22 | sVRFbHDnm/y6xUNfQW10OIbA7iDz29EPGDbDw+q8w+jy+m32KCiYr66wKbp+ET3M
23 | 9wIuYtHJCHB27e7y6MlK60tmPsbItDAL9QsoW4KKfEIyA4eg2c4swPrz/vjlcB4j
24 | jQ5wCk+LdanRlQtNbwnXfJvXKPMJsXXtDXJ061FKKZxSYukJsUm+U6hKiecqtUrq
25 | JpB3zOiFbZxyxo+X8mdDSr0QZBEEgmaYUDPKSt17wOE017ckwe7M0xVtDsNbiRQX
26 | tEzQkzP9zGSV5hvExtkwtLsPWhSOVmhZz4Ge1nS0b9I=
27 |
28 |
29 | MIIFXDCCA0QCCQCl4WZtbTlavDANBgkqhkiG9w0BAQsFADBwMQswCQYDVQQGEwJV
30 | UzEPMA0GA1UECAwGT3JlZ29uMREwDwYDVQQHDAhQb3J0bGFuZDEVMBMGA1UECgwM
31 | Q29tcGFueSBOYW1lMQwwCgYDVQQLDANPcmcxGDAWBgNVBAMMD3d3dy5leGFtcGxl
32 | LmNvbTAeFw0yMDA1MjAxNzI0MzFaFw0yMTA1MjAxNzI0MzFaMHAxCzAJBgNVBAYT
33 | AlVTMQ8wDQYDVQQIDAZPcmVnb24xETAPBgNVBAcMCFBvcnRsYW5kMRUwEwYDVQQK
34 | DAxDb21wYW55IE5hbWUxDDAKBgNVBAsMA09yZzEYMBYGA1UEAwwPd3d3LmV4YW1w
35 | bGUuY29tMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAuMq5wHW0sDEM
36 | /Ajy9Iq9k24TWnWAo7pIMUMcYxnlVbkcRxcysi5WjNe2Ruseuxew6r8V8DvAb357
37 | q3hQxvLqtd3iJ4t075d/BuKRUDix4GP4bykvROC/GwTw2l2oOow+Ot2q3brzqNGc
38 | wZqL4KKsOK8s3udyNs/A/niD2t1pkV/d4GevVHpnAKOzCb/1s6Qcopnn2of/k0kx
39 | Xa+atZFTsWiXJXKAG03A0cWRFRnOpfwUWUB68+VRtyRDvDNSrswKtJzMhZMj9cpx
40 | rz+urMlfg2HKo0id3Afn4HiAtVU9mYMM3cQViXkSIAjU/GDpiPRaYmK7qxHFPYi5
41 | 3x0NDt+NtkC2ayVccmOO3O6vZAT1DVGfnsFoD3knkQ0pdy9MH5JzXm3ppma+yEWF
42 | FYem0yKgfzETkae9BP1Z6eUAa6H3ZGhfGes7JZ8+dBQI96WVbJhGkI4f8gasYIVe
43 | B6orzZn/uqfP9/D44ZVPcfzNXZpo69TSmruCC60vZnhC9vq8HqleE2avvk+3eewa
44 | K6M9c8rSl6f0MLxkHKQ/k0bboR2ZKSx0TshknQkfV52ASORXkDlYud74U/gQ6261
45 | xPgpH5gC8pSxbH5QXhf/SIZshwHBKZI/9BWWIwF6BGRBHgKYWsHf6lbKbd9Ce19+
46 | TnKkyDw4pTVS/lljItJR7FjuYfRczzMCAwEAATANBgkqhkiG9w0BAQsFAAOCAgEA
47 | r6UAa9n4FkiA4ZqugCJEoC5Ehc1X/qdNFkY4EIHc33sqscqVZhHC0MbfNmKuiirk
48 | XKTR+M3U62IvD8HXpkBMTYMpnvsH4jFuP3SpTFfUuqarueqsawiPAejhjF9829fg
49 | K1+s1rD/fI3H3UuHWChTXKA4KpnCYr5B1om4ZoCcTVVdZjhO256iM7p/DHze08Eo
50 | Rdhaj+rgs6NC5vLHWX9bezACeqA3YwJYHRH0zuoCQfRKXkikIjj18wpWNARFhDoQ
51 | FEhJXIAO/skpuK6Q9Ml1wWuFaqgXtKN1iVzuGi7P8O3bCLexwmqnmsnEZPPpzjoQ
52 | T8zVIjCH6jBX533f1B745IrGNzMSr6YC/9RT3DrPoNT9pCAozSoZxldqIegxLgWG
53 | zBT6jj/fR92E5kJh8Hy3koeXGkyAkcHB0PH8yyFtYIlP0stENkG/fDCLuMUqf6GZ
54 | P/oSyJH1Ro/qV6kwc1XYDB+6NGC8Xd1JQKZD49c/GZYpo77ZYKQtCoTrMuPKSG5/
55 | jP7OTrdylTj+V4r7jYLLpvWCUe0ON0QPKClo+15tXATWep6PFk0U5W+efvavG70e
56 | Fu9GKMOkTgv5F/ngzDgXKo7T6poRDZAgolUAq2kwDUp42AVx/7UqmOdp0yUTNmJG
57 | A70UwPLAvWk5vX1IMpaEFjBd3LqWLeSmbKZ03zr1jnA=
58 |
59 |
60 |
61 |
62 |
63 | alice
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
--------------------------------------------------------------------------------
/tests/invalid_signature.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 | AYaC+ZevTSDiTN2P78V+G2dpDHo=
14 |
15 |
16 | deadbeefJZlHcLGGutn4EDZGMxJQmj2ps5q7uCj7wYOoKVTu1h3OQ8HAC+TD1jAK3i3jgsmZ
17 | 6yn0P0CQUfrKASVr6eJ44XGIegDcW5oFaAZ/nqgxyD9YcP4j8hyRRxMGqHQwQMrH
18 | NejOl1K9B97gyvP08Js01Vn4z4IFfiRNU7AZs7pnmlZQ55d6ImKVh1oMUMzzLtlS
19 | Oty64VlFkj5LZ/yD1dudSzjnOJC5i/QDD0vD8Tldi8soH9B5lQb9ySkXCVEsYYNA
20 | OOv6GhAsg0Su89+0N/Y1fjzs/ezeKO24sjCdoaFZ0acdMzj0g4fontwAvfAv0F06
21 | xyyAmTi6qqZsj8baUjKy7SkSKHyQSLjsescCKO7P5YL8PBE60G9LwrABQY8XdgO7
22 | F2j9Z2M7++hUE4Ues85LeMCfOO53Z6eqvbCauFyQ0LogqTtTZ7+ThNXjeRS6jzVu
23 | EEaMtvHWBFlctGYcAAzoJDiSD0u7um17oz8ycP1uwQvu/WO+8JNxEK3gcIMIMHBo
24 | xZ5PYqUcZqCdBIFaD3vXvfJDfIQkybqnrwjUAPB52amXTOOSdhYZ2Uv31YAct4Gq
25 | G/mlUqjKY/3Bf+BBRv4J3sSrxNyy6nNH+7m67G9qXpYSXlV64Xue+RuDPrlQmnv8
26 | 2swBH8cE0RevA2aTTnkVNimLg242N3IbuVJZLw2H7EA=
27 |
28 |
29 | MIIFXDCCA0QCCQCl4WZtbTlavDANBgkqhkiG9w0BAQsFADBwMQswCQYDVQQGEwJV
30 | UzEPMA0GA1UECAwGT3JlZ29uMREwDwYDVQQHDAhQb3J0bGFuZDEVMBMGA1UECgwM
31 | Q29tcGFueSBOYW1lMQwwCgYDVQQLDANPcmcxGDAWBgNVBAMMD3d3dy5leGFtcGxl
32 | LmNvbTAeFw0yMDA1MjAxNzI0MzFaFw0yMTA1MjAxNzI0MzFaMHAxCzAJBgNVBAYT
33 | AlVTMQ8wDQYDVQQIDAZPcmVnb24xETAPBgNVBAcMCFBvcnRsYW5kMRUwEwYDVQQK
34 | DAxDb21wYW55IE5hbWUxDDAKBgNVBAsMA09yZzEYMBYGA1UEAwwPd3d3LmV4YW1w
35 | bGUuY29tMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAuMq5wHW0sDEM
36 | /Ajy9Iq9k24TWnWAo7pIMUMcYxnlVbkcRxcysi5WjNe2Ruseuxew6r8V8DvAb357
37 | q3hQxvLqtd3iJ4t075d/BuKRUDix4GP4bykvROC/GwTw2l2oOow+Ot2q3brzqNGc
38 | wZqL4KKsOK8s3udyNs/A/niD2t1pkV/d4GevVHpnAKOzCb/1s6Qcopnn2of/k0kx
39 | Xa+atZFTsWiXJXKAG03A0cWRFRnOpfwUWUB68+VRtyRDvDNSrswKtJzMhZMj9cpx
40 | rz+urMlfg2HKo0id3Afn4HiAtVU9mYMM3cQViXkSIAjU/GDpiPRaYmK7qxHFPYi5
41 | 3x0NDt+NtkC2ayVccmOO3O6vZAT1DVGfnsFoD3knkQ0pdy9MH5JzXm3ppma+yEWF
42 | FYem0yKgfzETkae9BP1Z6eUAa6H3ZGhfGes7JZ8+dBQI96WVbJhGkI4f8gasYIVe
43 | B6orzZn/uqfP9/D44ZVPcfzNXZpo69TSmruCC60vZnhC9vq8HqleE2avvk+3eewa
44 | K6M9c8rSl6f0MLxkHKQ/k0bboR2ZKSx0TshknQkfV52ASORXkDlYud74U/gQ6261
45 | xPgpH5gC8pSxbH5QXhf/SIZshwHBKZI/9BWWIwF6BGRBHgKYWsHf6lbKbd9Ce19+
46 | TnKkyDw4pTVS/lljItJR7FjuYfRczzMCAwEAATANBgkqhkiG9w0BAQsFAAOCAgEA
47 | r6UAa9n4FkiA4ZqugCJEoC5Ehc1X/qdNFkY4EIHc33sqscqVZhHC0MbfNmKuiirk
48 | XKTR+M3U62IvD8HXpkBMTYMpnvsH4jFuP3SpTFfUuqarueqsawiPAejhjF9829fg
49 | K1+s1rD/fI3H3UuHWChTXKA4KpnCYr5B1om4ZoCcTVVdZjhO256iM7p/DHze08Eo
50 | Rdhaj+rgs6NC5vLHWX9bezACeqA3YwJYHRH0zuoCQfRKXkikIjj18wpWNARFhDoQ
51 | FEhJXIAO/skpuK6Q9Ml1wWuFaqgXtKN1iVzuGi7P8O3bCLexwmqnmsnEZPPpzjoQ
52 | T8zVIjCH6jBX533f1B745IrGNzMSr6YC/9RT3DrPoNT9pCAozSoZxldqIegxLgWG
53 | zBT6jj/fR92E5kJh8Hy3koeXGkyAkcHB0PH8yyFtYIlP0stENkG/fDCLuMUqf6GZ
54 | P/oSyJH1Ro/qV6kwc1XYDB+6NGC8Xd1JQKZD49c/GZYpo77ZYKQtCoTrMuPKSG5/
55 | jP7OTrdylTj+V4r7jYLLpvWCUe0ON0QPKClo+15tXATWep6PFk0U5W+efvavG70e
56 | Fu9GKMOkTgv5F/ngzDgXKo7T6poRDZAgolUAq2kwDUp42AVx/7UqmOdp0yUTNmJG
57 | A70UwPLAvWk5vX1IMpaEFjBd3LqWLeSmbKZ03zr1jnA=
58 |
59 |
60 |
61 |
62 |
63 | alice
64 |
65 | jdoe@example.com
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 | value1
74 |
75 |
76 | value2
77 |
78 |
79 |
80 |
81 |
--------------------------------------------------------------------------------
/tests/unsigned.xml:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/tests/valid.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 | AYaC+ZevTSDiTN2P78V+G2dpDHo=
14 |
15 |
16 | JZlHcLGGutn4EDZGMxJQmj2ps5q7uCj7wYOoKVTu1h3OQ8HAC+TD1jAK3i3jgsmZ
17 | 6yn0P0CQUfrKASVr6eJ44XGIegDcW5oFaAZ/nqgxyD9YcP4j8hyRRxMGqHQwQMrH
18 | NejOl1K9B97gyvP08Js01Vn4z4IFfiRNU7AZs7pnmlZQ55d6ImKVh1oMUMzzLtlS
19 | Oty64VlFkj5LZ/yD1dudSzjnOJC5i/QDD0vD8Tldi8soH9B5lQb9ySkXCVEsYYNA
20 | OOv6GhAsg0Su89+0N/Y1fjzs/ezeKO24sjCdoaFZ0acdMzj0g4fontwAvfAv0F06
21 | xyyAmTi6qqZsj8baUjKy7SkSKHyQSLjsescCKO7P5YL8PBE60G9LwrABQY8XdgO7
22 | F2j9Z2M7++hUE4Ues85LeMCfOO53Z6eqvbCauFyQ0LogqTtTZ7+ThNXjeRS6jzVu
23 | EEaMtvHWBFlctGYcAAzoJDiSD0u7um17oz8ycP1uwQvu/WO+8JNxEK3gcIMIMHBo
24 | xZ5PYqUcZqCdBIFaD3vXvfJDfIQkybqnrwjUAPB52amXTOOSdhYZ2Uv31YAct4Gq
25 | G/mlUqjKY/3Bf+BBRv4J3sSrxNyy6nNH+7m67G9qXpYSXlV64Xue+RuDPrlQmnv8
26 | 2swBH8cE0RevA2aTTnkVNimLg242N3IbuVJZLw2H7EA=
27 |
28 |
29 | MIIFXDCCA0QCCQCl4WZtbTlavDANBgkqhkiG9w0BAQsFADBwMQswCQYDVQQGEwJV
30 | UzEPMA0GA1UECAwGT3JlZ29uMREwDwYDVQQHDAhQb3J0bGFuZDEVMBMGA1UECgwM
31 | Q29tcGFueSBOYW1lMQwwCgYDVQQLDANPcmcxGDAWBgNVBAMMD3d3dy5leGFtcGxl
32 | LmNvbTAeFw0yMDA1MjAxNzI0MzFaFw0yMTA1MjAxNzI0MzFaMHAxCzAJBgNVBAYT
33 | AlVTMQ8wDQYDVQQIDAZPcmVnb24xETAPBgNVBAcMCFBvcnRsYW5kMRUwEwYDVQQK
34 | DAxDb21wYW55IE5hbWUxDDAKBgNVBAsMA09yZzEYMBYGA1UEAwwPd3d3LmV4YW1w
35 | bGUuY29tMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAuMq5wHW0sDEM
36 | /Ajy9Iq9k24TWnWAo7pIMUMcYxnlVbkcRxcysi5WjNe2Ruseuxew6r8V8DvAb357
37 | q3hQxvLqtd3iJ4t075d/BuKRUDix4GP4bykvROC/GwTw2l2oOow+Ot2q3brzqNGc
38 | wZqL4KKsOK8s3udyNs/A/niD2t1pkV/d4GevVHpnAKOzCb/1s6Qcopnn2of/k0kx
39 | Xa+atZFTsWiXJXKAG03A0cWRFRnOpfwUWUB68+VRtyRDvDNSrswKtJzMhZMj9cpx
40 | rz+urMlfg2HKo0id3Afn4HiAtVU9mYMM3cQViXkSIAjU/GDpiPRaYmK7qxHFPYi5
41 | 3x0NDt+NtkC2ayVccmOO3O6vZAT1DVGfnsFoD3knkQ0pdy9MH5JzXm3ppma+yEWF
42 | FYem0yKgfzETkae9BP1Z6eUAa6H3ZGhfGes7JZ8+dBQI96WVbJhGkI4f8gasYIVe
43 | B6orzZn/uqfP9/D44ZVPcfzNXZpo69TSmruCC60vZnhC9vq8HqleE2avvk+3eewa
44 | K6M9c8rSl6f0MLxkHKQ/k0bboR2ZKSx0TshknQkfV52ASORXkDlYud74U/gQ6261
45 | xPgpH5gC8pSxbH5QXhf/SIZshwHBKZI/9BWWIwF6BGRBHgKYWsHf6lbKbd9Ce19+
46 | TnKkyDw4pTVS/lljItJR7FjuYfRczzMCAwEAATANBgkqhkiG9w0BAQsFAAOCAgEA
47 | r6UAa9n4FkiA4ZqugCJEoC5Ehc1X/qdNFkY4EIHc33sqscqVZhHC0MbfNmKuiirk
48 | XKTR+M3U62IvD8HXpkBMTYMpnvsH4jFuP3SpTFfUuqarueqsawiPAejhjF9829fg
49 | K1+s1rD/fI3H3UuHWChTXKA4KpnCYr5B1om4ZoCcTVVdZjhO256iM7p/DHze08Eo
50 | Rdhaj+rgs6NC5vLHWX9bezACeqA3YwJYHRH0zuoCQfRKXkikIjj18wpWNARFhDoQ
51 | FEhJXIAO/skpuK6Q9Ml1wWuFaqgXtKN1iVzuGi7P8O3bCLexwmqnmsnEZPPpzjoQ
52 | T8zVIjCH6jBX533f1B745IrGNzMSr6YC/9RT3DrPoNT9pCAozSoZxldqIegxLgWG
53 | zBT6jj/fR92E5kJh8Hy3koeXGkyAkcHB0PH8yyFtYIlP0stENkG/fDCLuMUqf6GZ
54 | P/oSyJH1Ro/qV6kwc1XYDB+6NGC8Xd1JQKZD49c/GZYpo77ZYKQtCoTrMuPKSG5/
55 | jP7OTrdylTj+V4r7jYLLpvWCUe0ON0QPKClo+15tXATWep6PFk0U5W+efvavG70e
56 | Fu9GKMOkTgv5F/ngzDgXKo7T6poRDZAgolUAq2kwDUp42AVx/7UqmOdp0yUTNmJG
57 | A70UwPLAvWk5vX1IMpaEFjBd3LqWLeSmbKZ03zr1jnA=
58 |
59 |
60 |
61 |
62 |
63 | alice
64 |
65 | jdoe@example.com
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 | value1
74 |
75 |
76 | value2
77 |
78 |
79 |
80 |
81 |
--------------------------------------------------------------------------------
/tests/valid_idp_metadata.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | MIIFXDCCA0QCCQCl4WZtbTlavDANBgkqhkiG9w0BAQsFADBwMQswCQYDVQQGEwJV
8 | UzEPMA0GA1UECAwGT3JlZ29uMREwDwYDVQQHDAhQb3J0bGFuZDEVMBMGA1UECgwM
9 | Q29tcGFueSBOYW1lMQwwCgYDVQQLDANPcmcxGDAWBgNVBAMMD3d3dy5leGFtcGxl
10 | LmNvbTAeFw0yMDA1MjAxNzI0MzFaFw0yMTA1MjAxNzI0MzFaMHAxCzAJBgNVBAYT
11 | AlVTMQ8wDQYDVQQIDAZPcmVnb24xETAPBgNVBAcMCFBvcnRsYW5kMRUwEwYDVQQK
12 | DAxDb21wYW55IE5hbWUxDDAKBgNVBAsMA09yZzEYMBYGA1UEAwwPd3d3LmV4YW1w
13 | bGUuY29tMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAuMq5wHW0sDEM
14 | /Ajy9Iq9k24TWnWAo7pIMUMcYxnlVbkcRxcysi5WjNe2Ruseuxew6r8V8DvAb357
15 | q3hQxvLqtd3iJ4t075d/BuKRUDix4GP4bykvROC/GwTw2l2oOow+Ot2q3brzqNGc
16 | wZqL4KKsOK8s3udyNs/A/niD2t1pkV/d4GevVHpnAKOzCb/1s6Qcopnn2of/k0kx
17 | Xa+atZFTsWiXJXKAG03A0cWRFRnOpfwUWUB68+VRtyRDvDNSrswKtJzMhZMj9cpx
18 | rz+urMlfg2HKo0id3Afn4HiAtVU9mYMM3cQViXkSIAjU/GDpiPRaYmK7qxHFPYi5
19 | 3x0NDt+NtkC2ayVccmOO3O6vZAT1DVGfnsFoD3knkQ0pdy9MH5JzXm3ppma+yEWF
20 | FYem0yKgfzETkae9BP1Z6eUAa6H3ZGhfGes7JZ8+dBQI96WVbJhGkI4f8gasYIVe
21 | B6orzZn/uqfP9/D44ZVPcfzNXZpo69TSmruCC60vZnhC9vq8HqleE2avvk+3eewa
22 | K6M9c8rSl6f0MLxkHKQ/k0bboR2ZKSx0TshknQkfV52ASORXkDlYud74U/gQ6261
23 | xPgpH5gC8pSxbH5QXhf/SIZshwHBKZI/9BWWIwF6BGRBHgKYWsHf6lbKbd9Ce19+
24 | TnKkyDw4pTVS/lljItJR7FjuYfRczzMCAwEAATANBgkqhkiG9w0BAQsFAAOCAgEA
25 | r6UAa9n4FkiA4ZqugCJEoC5Ehc1X/qdNFkY4EIHc33sqscqVZhHC0MbfNmKuiirk
26 | XKTR+M3U62IvD8HXpkBMTYMpnvsH4jFuP3SpTFfUuqarueqsawiPAejhjF9829fg
27 | K1+s1rD/fI3H3UuHWChTXKA4KpnCYr5B1om4ZoCcTVVdZjhO256iM7p/DHze08Eo
28 | Rdhaj+rgs6NC5vLHWX9bezACeqA3YwJYHRH0zuoCQfRKXkikIjj18wpWNARFhDoQ
29 | FEhJXIAO/skpuK6Q9Ml1wWuFaqgXtKN1iVzuGi7P8O3bCLexwmqnmsnEZPPpzjoQ
30 | T8zVIjCH6jBX533f1B745IrGNzMSr6YC/9RT3DrPoNT9pCAozSoZxldqIegxLgWG
31 | zBT6jj/fR92E5kJh8Hy3koeXGkyAkcHB0PH8yyFtYIlP0stENkG/fDCLuMUqf6GZ
32 | P/oSyJH1Ro/qV6kwc1XYDB+6NGC8Xd1JQKZD49c/GZYpo77ZYKQtCoTrMuPKSG5/
33 | jP7OTrdylTj+V4r7jYLLpvWCUe0ON0QPKClo+15tXATWep6PFk0U5W+efvavG70e
34 | Fu9GKMOkTgv5F/ngzDgXKo7T6poRDZAgolUAq2kwDUp42AVx/7UqmOdp0yUTNmJG
35 | A70UwPLAvWk5vX1IMpaEFjBd3LqWLeSmbKZ03zr1jnA=
36 |
37 |
38 |
39 |
40 | urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress
41 |
42 |
43 |
44 |
45 |
46 |
--------------------------------------------------------------------------------
/tests/wrong_issuer.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 | m09Yh+c8bB96eAMF0uH5pZpNcB0=
14 |
15 |
16 | IuYUSy9C2aENMbHbEpf8aMjaGbiXwYk2Ep+iI8SWkhfclLW4uD2MHggcQlWudTSG
17 | EZVnOVhzOCwt8IFZv6uZNnGIFdPuxKBHSQUX7EBYGbhYgDimqePlrTWpsGKrFJnB
18 | e7II6dqMw2PhmpSI/Ew3QNrNRZnoNrjasW6GuhFmATd6Q8E6J6J3r4iD5bdgwtDY
19 | YYK5yESkcQUEBTEs2Yt3DLyee0tsaEPVulKWUxyFoEq4VaUsVI9IsXGPgA4LnHbB
20 | Kn9/BftJysg671Pd7p+R3KNWw439bKrFkZ1PpS9VowKxYMrqVPvULghq+pQfAH1i
21 | VW5cH5Dd/0XHgeeL1bXqHZi14+yzZybjusVHuIeuPFxTWi6v3qgQkrGEq1HNUX8+
22 | cvU7TmKwrqlCi1ZByf/h3oiI7xP1HuuuE33j0Zmj6PfgYTBVRDfw1yaC1+j2pOu7
23 | 1GwFqF3S8IGkLr+LklzoLg8R4iyfJsIrNQWsvSVUKj6wtpUK9jqcQFDb76SN0/k+
24 | wIRUqHWKRRk4FWt5mMILJBhRRKozWi09OAwECSt8CmvJsaw5ilTBFnt2h1dgytu2
25 | 8kSHHXMVNUxiqHzGmvYUf1rKxqn0gEHsqQAbj23d4eTy/auz2vO49PXoiLTqg//P
26 | tDp+94U5VZicWD0khmJ9aKNGltRm96i3kFXCqTjwIeQ=
27 |
28 |
29 | MIIFXDCCA0QCCQCl4WZtbTlavDANBgkqhkiG9w0BAQsFADBwMQswCQYDVQQGEwJV
30 | UzEPMA0GA1UECAwGT3JlZ29uMREwDwYDVQQHDAhQb3J0bGFuZDEVMBMGA1UECgwM
31 | Q29tcGFueSBOYW1lMQwwCgYDVQQLDANPcmcxGDAWBgNVBAMMD3d3dy5leGFtcGxl
32 | LmNvbTAeFw0yMDA1MjAxNzI0MzFaFw0yMTA1MjAxNzI0MzFaMHAxCzAJBgNVBAYT
33 | AlVTMQ8wDQYDVQQIDAZPcmVnb24xETAPBgNVBAcMCFBvcnRsYW5kMRUwEwYDVQQK
34 | DAxDb21wYW55IE5hbWUxDDAKBgNVBAsMA09yZzEYMBYGA1UEAwwPd3d3LmV4YW1w
35 | bGUuY29tMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAuMq5wHW0sDEM
36 | /Ajy9Iq9k24TWnWAo7pIMUMcYxnlVbkcRxcysi5WjNe2Ruseuxew6r8V8DvAb357
37 | q3hQxvLqtd3iJ4t075d/BuKRUDix4GP4bykvROC/GwTw2l2oOow+Ot2q3brzqNGc
38 | wZqL4KKsOK8s3udyNs/A/niD2t1pkV/d4GevVHpnAKOzCb/1s6Qcopnn2of/k0kx
39 | Xa+atZFTsWiXJXKAG03A0cWRFRnOpfwUWUB68+VRtyRDvDNSrswKtJzMhZMj9cpx
40 | rz+urMlfg2HKo0id3Afn4HiAtVU9mYMM3cQViXkSIAjU/GDpiPRaYmK7qxHFPYi5
41 | 3x0NDt+NtkC2ayVccmOO3O6vZAT1DVGfnsFoD3knkQ0pdy9MH5JzXm3ppma+yEWF
42 | FYem0yKgfzETkae9BP1Z6eUAa6H3ZGhfGes7JZ8+dBQI96WVbJhGkI4f8gasYIVe
43 | B6orzZn/uqfP9/D44ZVPcfzNXZpo69TSmruCC60vZnhC9vq8HqleE2avvk+3eewa
44 | K6M9c8rSl6f0MLxkHKQ/k0bboR2ZKSx0TshknQkfV52ASORXkDlYud74U/gQ6261
45 | xPgpH5gC8pSxbH5QXhf/SIZshwHBKZI/9BWWIwF6BGRBHgKYWsHf6lbKbd9Ce19+
46 | TnKkyDw4pTVS/lljItJR7FjuYfRczzMCAwEAATANBgkqhkiG9w0BAQsFAAOCAgEA
47 | r6UAa9n4FkiA4ZqugCJEoC5Ehc1X/qdNFkY4EIHc33sqscqVZhHC0MbfNmKuiirk
48 | XKTR+M3U62IvD8HXpkBMTYMpnvsH4jFuP3SpTFfUuqarueqsawiPAejhjF9829fg
49 | K1+s1rD/fI3H3UuHWChTXKA4KpnCYr5B1om4ZoCcTVVdZjhO256iM7p/DHze08Eo
50 | Rdhaj+rgs6NC5vLHWX9bezACeqA3YwJYHRH0zuoCQfRKXkikIjj18wpWNARFhDoQ
51 | FEhJXIAO/skpuK6Q9Ml1wWuFaqgXtKN1iVzuGi7P8O3bCLexwmqnmsnEZPPpzjoQ
52 | T8zVIjCH6jBX533f1B745IrGNzMSr6YC/9RT3DrPoNT9pCAozSoZxldqIegxLgWG
53 | zBT6jj/fR92E5kJh8Hy3koeXGkyAkcHB0PH8yyFtYIlP0stENkG/fDCLuMUqf6GZ
54 | P/oSyJH1Ro/qV6kwc1XYDB+6NGC8Xd1JQKZD49c/GZYpo77ZYKQtCoTrMuPKSG5/
55 | jP7OTrdylTj+V4r7jYLLpvWCUe0ON0QPKClo+15tXATWep6PFk0U5W+efvavG70e
56 | Fu9GKMOkTgv5F/ngzDgXKo7T6poRDZAgolUAq2kwDUp42AVx/7UqmOdp0yUTNmJG
57 | A70UwPLAvWk5vX1IMpaEFjBd3LqWLeSmbKZ03zr1jnA=
58 |
59 |
60 |
61 |
62 |
63 | notalice
64 |
65 |
66 |
--------------------------------------------------------------------------------
/tests/wrong_recipient.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 | xHOyhDJQd3Pg2B4x4plCizvhZfI=
14 |
15 |
16 | C0tm2XYH8yAIRHot9AYumEEGivdCceKP/ZqdXq/qGYqgVkYZhiM6oFSuQKDHx+4j
17 | wGzpZRrC7JuXztmd84+WHH2MLtT5h7aoepZrnYIE/yIVZAuieVWNTNobmhPeMM2D
18 | acjsFt6oTGNKW1j/t46VgxGciAtVbap3gStEZQJTUdVnOHdNji2/cBBaHWNPiNMz
19 | xhuJgR1Dl6axXtxcRKcF5EWQmg8zP7M365BV8lBFAp0af0aagp/zyXU4irYbPwr5
20 | jcauNIDB+dik3kMw55/fgV2MKAg/5J4FTEi9ew4hyHZAiDlUOMJEaUjek/M+/+hf
21 | DHBprKF28tiq1hfJJdT4a34fDt5+CapCWG2fokFj83F1O3O/SPtHSZLUMuy1XA8g
22 | WB80mubhlPebXABBia0BNNyeB1ws21MA0dqVuOq2tCnD0QzI+z5EV/Ff11bS2eyU
23 | hBKWVqCl1MuDn9T0h9+yA4JLita9N+71LjYSlw5NkW1F/inpNXpIr9jUHOT9bo5Z
24 | mfRlPPTFfkQrC2wNXeVOvHLHVyRYF3nEShQKkE1uZl7TDVzq49HsieMz22/VWWev
25 | An2qkcINTTJgPwhgynkEweljYUqgYIrDipo+2kiv0mMr2bEbyFzWW8IMoCPO+FR9
26 | cBmWO0Ur5/EF5b0+gnhRmk8+eF7wl/i1lVBY7WHeJ7Y=
27 |
28 |
29 | MIIFXDCCA0QCCQCl4WZtbTlavDANBgkqhkiG9w0BAQsFADBwMQswCQYDVQQGEwJV
30 | UzEPMA0GA1UECAwGT3JlZ29uMREwDwYDVQQHDAhQb3J0bGFuZDEVMBMGA1UECgwM
31 | Q29tcGFueSBOYW1lMQwwCgYDVQQLDANPcmcxGDAWBgNVBAMMD3d3dy5leGFtcGxl
32 | LmNvbTAeFw0yMDA1MjAxNzI0MzFaFw0yMTA1MjAxNzI0MzFaMHAxCzAJBgNVBAYT
33 | AlVTMQ8wDQYDVQQIDAZPcmVnb24xETAPBgNVBAcMCFBvcnRsYW5kMRUwEwYDVQQK
34 | DAxDb21wYW55IE5hbWUxDDAKBgNVBAsMA09yZzEYMBYGA1UEAwwPd3d3LmV4YW1w
35 | bGUuY29tMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAuMq5wHW0sDEM
36 | /Ajy9Iq9k24TWnWAo7pIMUMcYxnlVbkcRxcysi5WjNe2Ruseuxew6r8V8DvAb357
37 | q3hQxvLqtd3iJ4t075d/BuKRUDix4GP4bykvROC/GwTw2l2oOow+Ot2q3brzqNGc
38 | wZqL4KKsOK8s3udyNs/A/niD2t1pkV/d4GevVHpnAKOzCb/1s6Qcopnn2of/k0kx
39 | Xa+atZFTsWiXJXKAG03A0cWRFRnOpfwUWUB68+VRtyRDvDNSrswKtJzMhZMj9cpx
40 | rz+urMlfg2HKo0id3Afn4HiAtVU9mYMM3cQViXkSIAjU/GDpiPRaYmK7qxHFPYi5
41 | 3x0NDt+NtkC2ayVccmOO3O6vZAT1DVGfnsFoD3knkQ0pdy9MH5JzXm3ppma+yEWF
42 | FYem0yKgfzETkae9BP1Z6eUAa6H3ZGhfGes7JZ8+dBQI96WVbJhGkI4f8gasYIVe
43 | B6orzZn/uqfP9/D44ZVPcfzNXZpo69TSmruCC60vZnhC9vq8HqleE2avvk+3eewa
44 | K6M9c8rSl6f0MLxkHKQ/k0bboR2ZKSx0TshknQkfV52ASORXkDlYud74U/gQ6261
45 | xPgpH5gC8pSxbH5QXhf/SIZshwHBKZI/9BWWIwF6BGRBHgKYWsHf6lbKbd9Ce19+
46 | TnKkyDw4pTVS/lljItJR7FjuYfRczzMCAwEAATANBgkqhkiG9w0BAQsFAAOCAgEA
47 | r6UAa9n4FkiA4ZqugCJEoC5Ehc1X/qdNFkY4EIHc33sqscqVZhHC0MbfNmKuiirk
48 | XKTR+M3U62IvD8HXpkBMTYMpnvsH4jFuP3SpTFfUuqarueqsawiPAejhjF9829fg
49 | K1+s1rD/fI3H3UuHWChTXKA4KpnCYr5B1om4ZoCcTVVdZjhO256iM7p/DHze08Eo
50 | Rdhaj+rgs6NC5vLHWX9bezACeqA3YwJYHRH0zuoCQfRKXkikIjj18wpWNARFhDoQ
51 | FEhJXIAO/skpuK6Q9Ml1wWuFaqgXtKN1iVzuGi7P8O3bCLexwmqnmsnEZPPpzjoQ
52 | T8zVIjCH6jBX533f1B745IrGNzMSr6YC/9RT3DrPoNT9pCAozSoZxldqIegxLgWG
53 | zBT6jj/fR92E5kJh8Hy3koeXGkyAkcHB0PH8yyFtYIlP0stENkG/fDCLuMUqf6GZ
54 | P/oSyJH1Ro/qV6kwc1XYDB+6NGC8Xd1JQKZD49c/GZYpo77ZYKQtCoTrMuPKSG5/
55 | jP7OTrdylTj+V4r7jYLLpvWCUe0ON0QPKClo+15tXATWep6PFk0U5W+efvavG70e
56 | Fu9GKMOkTgv5F/ngzDgXKo7T6poRDZAgolUAq2kwDUp42AVx/7UqmOdp0yUTNmJG
57 | A70UwPLAvWk5vX1IMpaEFjBd3LqWLeSmbKZ03zr1jnA=
58 |
59 |
60 |
61 |
62 |
63 | alice
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
--------------------------------------------------------------------------------