├── .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 | [![go.dev reference](https://img.shields.io/badge/go.dev-reference-007d9c?logo=go&logoColor=white&style=flat-square)](https://pkg.go.dev/mod/github.com/ucarion/saml?tab=overview) 4 | [![GitHub Workflow Status](https://img.shields.io/github/workflow/status/ucarion/saml/tests?label=tests&logo=github&style=flat-square)](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 | > ![Diagram when users belong-to accounts](entity_diagram_1.png) 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 | > ![Diagram when users have-and-belong-to-many accounts](entity_diagram_2.png) 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 |
191 | 196 | 197 | 202 | 203 | 204 |
205 | 206 |

Log in

207 | 208 |
209 | 214 | 215 | 220 | 221 | 222 |
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 | 262 | 263 | 264 | 265 | 266 | 267 | 268 | 269 | 270 | 271 |
260 | Data you need to put into your Identity Provider 261 |
SAML Assertion Consumer Service ("ACS") URL{{ .SAMLACS }}
SAML Recipient ID{{ .SAMLRecipientID }}
272 | 273 | 274 | 277 | 278 | 279 | 280 | 281 | 282 | 283 | 284 | 285 | 286 | 287 | 288 | 289 | 290 | 291 | 292 |
275 | Data from your Identity Provider you need to give us 276 |
SAML Issuer Entity ID{{ .SAMLIssuerID }}
SAML Issuer x509 Certificate
{{ .SAMLIssuerX509 }}
SAML Redirect URL (aka "HTTP-Redirect Binding URL")
{{ .SAMLRedirectURL }}
293 | 294 |
295 | 296 | 297 |
298 | 299 |

Users

300 | 301 |

There are {{ len .Users }} users:

302 | 303 | 310 | 311 |
312 | 316 | 317 | 321 | 322 | 323 |
324 | 325 |

Todos

326 | 327 | There are {{ len .Todos }} todos: 328 | 329 | 336 | 337 |
338 | 342 | 343 | 344 |
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 | --------------------------------------------------------------------------------