saml

package module
v0.1.2 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jun 6, 2020 License: MIT Imports: 8 Imported by: 1

README

saml

go.dev reference GitHub Workflow Status

This package is a Golang implementation of Secure Assertion Markup Language v2.0, commonly known as "SAML". This package features:

  1. An extremely simple interface that's easy to integrate with. Many Golang implementations give you tons of functions and types you can use, and it's not clear what you're meant to do. This package only gives you two functions: one for accepting SAML logins, and another for parsing Identity Provider metadata into useful information.

  2. No presumption of how your application works. SAML is a legacy, but important, protocol. You do not want to put SAML everywhere in your application; instead, you should evaluate how to wedge support for SAML into your existing authentication as unintrusively as possible.

    To that end, this package does not attempt to intercept with HTTP handlers or presume whether you're making a single-tenant or multi-tenant system. Instead, this package gives you useful, secure building blocks that you can fit into your systems.

  3. An emphasis on security. There are a lot of ways to introduce security vulnerabilities in SAML implementations, and most existing Golang packages make it too easy to make common mistakes.

    This package only gives you secure ways to handle SAML logins. You cannot skip security checks. In particular, this package will always verify that SAML responses are authentic, not expired, issued by the identity provider you expected, and intended to be consumed by you.

Installation

You can install this package by running:

go get github.com/ucarion/saml

Usage

For working, self-contained demos of how you can use this package, check out the examples directory.

Handling SAML Logins

To accept a SAML login, you would usually write something like:

http.HandleFunc("/acs", func(w http.ResponseWriter, r *http.Request) {
  samlResponse, err := saml.Verify(
    r.FormValue(saml.ParamSAMLResponse),
    "https://customer-idp.example.com",
    customerCertificate,
    "https://your-service.example.com",
    time.Now(),
  )

  if err != nil {
    // Give back a 400 response or something, up to you ...
  }

  // See "Integrating with SAML" section below for suggested approaches to
  // handling a valid SAML login.
  //
  // ...
}
Initiating a SAML Login

Kicking off a SAML login is so easy this package doesn't even give you a method for it. Just do:

http.HandleFunc("/initiate", func(w http.ResponseWriter, r *http.Request) {
  http.Redirect(w, r, "https://customer-idp.example.com/init", http.StatusFound)
}
Getting Identity Provider Configuration

In the examples above, you need three pieces of information from your customer:

  1. The customer's Identity Provider "ID". That was "https://customer-idp.example.com" in the example above.
  2. The customer's Identity Provider certificate. That was customerCertificate above.
  3. The customer's Identity Provider redirect URL. That was "https://customer-idp.example.com/init" above.

You can have your customers input those into your application on their own, but a more streamlined process is for them to upload their Identity Provider "metadata" to your application. This package gives you a way to parse and extract information from Identity Provider metadata. Just do:

http.HandleFunc("/setup", func(w http.ResponseWriter, r *http.Request) {
  var metadata saml.EntityDescriptor
  if err := xml.NewDecoder(r.Body).Decode(&metadata); err != nil {
    // Give back a 400 response or something, up to you ...
  }

  idpID, customerCertificate, redirectURL, err := metadata.GetEntityIDCertificateAndRedirectURL()
  if err != nil {
    // Give back a 400 response or something, up to you ...
  }

  // Now you can store the Identity Provider ID, certificate, and redirect
  // URL someplace where you can retreive it later.
  //
  // ...
}

Integrating with SAML

This section gives guidance on how you should consider integrating SAML logins into your existing application. It assumes you are building something like a B2B SaaS product, and want to add Single Sign-On functionality using SAML.

If you want to see a working example application built on top of this package, check out examples/saml-todo-app. Its design follows the recommendations in this section.

Typical Customer Expectations

To have success with SAML, you must first understand what it gives you: a SAML connection is essentially a customer-controlled factory of users. Customers want SAML because they want to control what employees can access your app, and they want to be able to grant or revoke that access at will. Your integration with SAML should be designed with that in mind.

Companies love Single Sign-On (and thus SAML) because it moves the problem of "can Jane get access to FooApp?" into a single place: their corporate identity provider (aka their "IdP", e.g. Okta, OneLogin, etc.). They want:

  • IT to be able to let Jane log into your app just by giving her access to the relevant SAML connection in their IdP.
  • Jane to be able to use your app by just clicking on your app's logo in her IdP account.
  • If Jane ever leaves the company, the IT team can just delete Jane's account in the identity provider, and she can't log into anything anymore.
  • If Jane changes roles, IT can remove the SAML connection from her account, and they know she can't log into your app anymore.

In particular, what this means for you is:

  1. You should support having users that can log in with SAML but not username/password. The nice thing about Single Sign-On is that it means employees only need to remember one password: their corporate identity provider password.

    If you force your customer's employees to have a password in your application, then you've lost a lot of the value of Single Sign-On. You'll end up with your customers' IT team asking you for the ability to programmatically delete users from your app, because they're worried about Jane's weak password being leaked, and then someone logging in as Jane and stealing company data.

  2. You should support "just-in-time" provisioning of accounts. If someone logs in with SAML and you don't have a user for them already created, you should auto-create one for them on the spot.

    Single Sign-On is about automating provisioning. If you make your users create an account outside of SAML first, and only then support logging in via SAML, then not only are you ignoring the first suggestion (supporting passwordless users), but you're also adding extra steps that SAML is supposed to automate for customers.

  3. You should support disabling password-based logins. Some smaller companies still transitioning to the centrally-managed approach of coroporate identity providers might want to support having both SAML-based and password-based logins into the same app. But the big companies want to be able to guarantee that there are no password-based logins into your app.

    No passwords means no password leaks, and no circumventing the centrally-managed rules in their IdP. That's a big security win for your customers.

You should also be aware of this important bit of security context:

  • An Identity Provider can put anything they like in a SAML assertion. There is no guarantee that Identity Providers won't try to send you nefarious SAML assertions. For example, an IdP is allowed to claim they have a user with the email steve.jobs@apple.com, no verification required. There is no global SAML police.

    If you don't keep this in mind, you might accidentally introduce vulnerabilities in your SAML implementation. For instance, don't solely rely on an email attribute in SAML logins to decide what user to log someone in as. Otherwise an attacker could log in as anyone they like just by adding a phony user to their IdP with the right email, and then sending you a SAML login with that email.

    What this means for you is: trust a SAML login only within the context of the account that has established a trust relationship with the associated IdP. What Company A's Identity Provider says should not have any bearing on what they can do with Company B's resources in your product.

A Playbook for Introducing SAML

With all of that context, here is a playbook can follow to figure out how you should introduce SAML into your product. As you follow along, the most important fork in the road is whether your users exclusively belong to an "account" (or whatever your "root-level resource" is), or whether users can be in multiple accounts.

If your users exclusively belong to your root-level resource, then your transition to supporting SAML will look something like this:

Diagram when users belong-to accounts

A SAML transition plan for products that work roughly like AWS, where users belong to accounts.

If your users can be in multiple instances of your root-level resource, then your transition to supporting SAML will look something like this:

Diagram when users have-and-belong-to-many accounts

A SAML transition plan for products that work roughly like GitHub, where users can be in multiple accounts, and don't really "belong to" any particular account.

Don't worry if not everything in these pictures make sense yet. As you go through this playbook, consider jumping back up to this diagram to help make things clearer.

  1. Identify your root-level resource. The root-level resource is the thing that's the "parent" of most other resources in your system -- most other things belong-to it. Usually, billing information is associated with this root-level resource. Oftentimes, customers will only have one (or very few, maybe one per business unit or environment) instances of the root-level resource. To customers, accounts might be an "invisible" resource, because a session can never see accounts outside of the one they're issued for.

    For example, in AWS, an AWS account is the root-level resource. In GitHub, users and organizations are the root-level resources, but for the purposes of a business-tier account it's the organization resource that matters most. In Stripe, a Stripe account is the root-level resource, and is a mostly-invisible resource when you're using their API.

  2. Determine the relation between users and your root-level resource. Broadly speaking, there are two typical relations users and root-level resources can have in most SaaS products:

    • In a belongs-to relationship, users exist in exactly one root-level resource, and belong to the root-level resource they're in. If you delete the root-level resource, you also delete the user.

      AWS and Stripe are examples of this. All AWS IAM users belong to an AWS account. All Stripe users / tokens belong to a Stripe account.

      Broadly speaking, most B2B SaaS companies work like this. You're doing business with a company, and everything in your system should be attributable to a billable corporate customer.

    • In a has-and-belongs-to-many relationship, users can exist in multiple root-level resources, and deleting a root-level resource doesn't delete a user.

      GitHub.com (not the enterprise edition) is an example of this. Developers have personal GitHub accounts that may belong to multiple organizations. Developers can leave or join organizations, and users and organizations can be deleted independently.

      Broadly speaking, products with a "social network" aspect work like this. You should avoid this design if you can, because it complicates things when it comes to SAML. The fact that many employers require employees to create a new GitHub account when they join the company is emblematic of the sorts of issues that GitHub's model has with selling to businesses.

Now that you've identified your root-level resource and the relationship it has with your users, here's a recommended approach to adding SAML:

  1. Add a new kind of resource to your root-level resource: a "SAML connection". See the ./examples/persistent demo for an example of how you can represent a SAML connection in a database.

    If you can, let your root-level resource have-many SAML connections; you'll find that many companies, especially those with multiple business units or which rely on consultants, will have multiple Identity Providers internally.

    For examples of SAML connections attached to root-level resources, see the AWS IAM CreateSAMLProvider endpoint or GitHub's docs on adding a SAML connection to an organization. AWS supports multiple SAML connections per account, whereas GitHub supports up to one SAML connection per organization.

  2. Add the notion of a "SAML user ID" on the resource that ties users to your root-level resource.

    • If users belong-to your root-level resource, then you should add a "SAML user ID" to your users resource.

    • If users have-and-belong-to-many root-level resources, then you should add a "SAML user ID" to the join table between users and the root-level resource.

    The "SAML user ID" will be how you can tell if a user was created via an Identity Provider, rather than via the old-school username/password approach. The "SAML user ID" will consist of a pair of fields:

    • The SAML connection that the user came from, and
    • The ID that the SAML connection had given to that user. Every SAML login comes with a user ID (called a NameID), but you don't get to control what that user ID will be.

    When someone logs in with SAML, you'll look up if an existing user with the SAML login's connection and NameID already exists in your database. If one does, you'll log them into that user. If one doesn't, you can create a user on the spot, if that makes sense for your business. GitHub can't do this, because GitHub users don't belong to organizations. But AWS can, and does, create IAM principals on-the-spot when you use an IAM SAML Provider.

  3. Add the notion of a "SAML connection ID" to your "session" resource, or its equivalents in your product. A "session" resource might not necessarily exist in your database -- if you use stateless JWTs as your session tokens, then add a "SAML connection ID" claim to your JWTs.

    When someone logs in with username and password, don't put a SAML connection ID on the session you issue. When someone logs in with SAML, add the SAML connection they used to the session you issued to them.

    By tracking whether a session is associated with a SAML connection, you'll be able to support both password-based and SAML-based logins relatively seamlessly. If you choose to give customers the ability require SAML for all logins into their root-level resource, you can enforce that internally by considering any session that doesn't have the right SAML connection ID -- or doesn't have a SAML connection ID at all -- to be unauthorized.

    One subtlety with "sessions". If you're making a developer-oriented product, you might have the concept of a Personal Access Token (PAT). If your product has both PATs and you have a has-and-belongs-to-many relationship between users and root-level resources, then you may need to either disable PATs for customers that enable "require SAML", or you'll need to copy what GitHub does: adding the notion of "enable SAML" to a PAT.

    See GitHub's docs on authorizating a PAT for SAML for what that could look like.

    SAML only works in browsers, so it's always going to be a bit awkward to integrate SAML into non-browser-based systems, like CLI tools.

Hopefully this guidance has made the big picture clearer. Although github.com/ucarion/saml does not solve all of these problems for you, it does give you many of the secure building blocks you need to integrate SAML into your product.

Documentation

Index

Constants

View Source
const ParamRelayState = "RelayState"

ParamRelayState is the name of the HTTP POST parameter where SAML puts relay states. It's also the name of the URL query parameter you should put your relay state in when initiating the SAML flow.

Usually, you want to pass ParamRelayState to r.FormValue when writing HTTP handlers that are responding to SAML logins.

Usually, you want to use ParamRelayState as the URL parameter name when writing HTTP handlers that are initiating SAML flows. The URL parameter's value should be the state you want to relay through SAML back to yourself.

View Source
const ParamSAMLResponse = "SAMLResponse"

ParamSAMLResponse is the name of the HTTP POST parameter where SAML puts responses.

Usually, you want to pass ParamSAMLResponse to r.FormValue when writing HTTP handlers that are responding to SAML logins.

View Source
const SingleSignOnServiceBindingHTTPRedirect = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect"

SingleSignOnServiceBindingHTTPRedirect is the URI for a SAML HTTP-Redirect Binding.

Variables

View Source
var ErrAssertionExpired = errors.New("saml: assertion expired")

ErrAssertionExpired indicates that the SAML response is expired, or not yet valid.

View Source
var ErrInvalidIssuer = errors.New("saml: invalid issuer")

ErrInvalidIssuer indicates that the SAML response did not have the expected issuer.

This error may indicate that an attacker is attempting to replay a SAML assertion issed by their own identity provider instead of the authorized identity provider.

View Source
var ErrInvalidRecipient = errors.New("saml: invalid recipient")

ErrInvalidRecipient indicates that the SAML response did not have the expected recipient.

This error may indicates that an attacker is attempting to replay a SAML assertion meant for a different service provider.

View Source
var ErrNoRedirectBinding = errors.New("saml: no HTTP redirect binding in IdP metadata")

ErrNoRedirectBinding indicates that an EntityDescriptor did not declare an HTTP-Redirect binding.

View Source
var ErrResponseNotSigned = errors.New("saml: response not signed")

ErrResponseNotSigned indicates that the SAML response was not signed.

Verify does not support handling unsigned SAML responses. Note that some Identity Providers support signing either the full SAML response, or only the SAML assertion: Verify only supports having the full SAML response signed, and will ignore any additional interior signatures.

Functions

This section is empty.

Types

type Assertion

type Assertion struct {
	XMLName            xml.Name           `xml:"urn:oasis:names:tc:SAML:2.0:assertion Assertion"`
	Issuer             Issuer             `xml:"Issuer"`
	Subject            Subject            `xml:"Subject"`
	Conditions         Conditions         `xml:"Conditions"`
	AttributeStatement AttributeStatement `xml:"AttributeStatement"`
}

Assertion represents a SAML assertion.

An assertion is a set of facts that one entity (usually an Identity Provider) passes to another entity (usually a Service Provider). These facts are usually information about a particular user, called a subject.

type Attribute

type Attribute struct {
	XMLName    xml.Name `xml:"urn:oasis:names:tc:SAML:2.0:assertion Attribute"`
	Name       string   `xml:"Name,attr"`
	NameFormat string   `xml:"NameFormat,attr"`
	Value      string   `xml:"AttributeValue"`
}

Attribute is a particular key-value attribute of the user in an assertion.

type AttributeStatement

type AttributeStatement struct {
	XMLName    xml.Name    `xml:"urn:oasis:names:tc:SAML:2.0:assertion AttributeStatement"`
	Attributes []Attribute `xml:"Attribute"`
}

AttributeStatement is a set of user attributes.

type Conditions

type Conditions struct {
	XMLName      xml.Name  `xml:"urn:oasis:names:tc:SAML:2.0:assertion Conditions"`
	NotBefore    time.Time `xml:"NotBefore,attr"`
	NotOnOrAfter time.Time `xml:"NotOnOrAfter,attr"`
}

Conditions is a set of constraints that limit under what conditions an assertion is valid.

type EntityDescriptor

type EntityDescriptor struct {
	XMLName          xml.Name         `xml:"urn:oasis:names:tc:SAML:2.0:metadata EntityDescriptor"`
	EntityID         string           `xml:"entityID,attr"`
	IDPSSODescriptor IDPSSODescriptor `xml:"IDPSSODescriptor"`
}

EntityDescriptor describes a SAML entity. This is often referred to as "metadata".

This struct is meant to store "Identity Provider metadata"; it's meant to store the description of a SAML Identity Provider.

func (*EntityDescriptor) GetEntityIDCertificateAndRedirectURL

func (d *EntityDescriptor) GetEntityIDCertificateAndRedirectURL() (string, *x509.Certificate, *url.URL, error)

GetEntityIDCertificateAndRedirectURL extracts an issuer entity ID, a x509 certificate, and a redirect URL from a set of Identity Provider metadata.

Returns an error if the x509 certificate or redirect URL are malformed. If there is no redirect URL at all, returns ErrNoRedirectBinding.

type IDPSSODescriptor

type IDPSSODescriptor struct {
	XMLName              xml.Name              `xml:"urn:oasis:names:tc:SAML:2.0:metadata IDPSSODescriptor"`
	KeyDescriptor        KeyDescriptor         `xml:"KeyDescriptor"`
	SingleSignOnServices []SingleSignOnService `xml:"SingleSignOnService"`
}

IDPSSODescriptor describes the single-sign-on offerings of an identity provider.

type Issuer

type Issuer struct {
	XMLName xml.Name `xml:"urn:oasis:names:tc:SAML:2.0:assertion Issuer"`
	Name    string   `xml:",chardata"`
}

Issuer indicates the entity that issued a SAML assertion.

type KeyDescriptor

type KeyDescriptor struct {
	XMLName xml.Name `xml:"urn:oasis:names:tc:SAML:2.0:metadata KeyDescriptor"`
	KeyInfo KeyInfo  `xml:"KeyInfo"`
}

KeyDescriptor describes the key an identity provider uses to sign data.

type KeyInfo

type KeyInfo struct {
	XMLName  xml.Name `xml:"http://www.w3.org/2000/09/xmldsig# KeyInfo"`
	X509Data X509Data `xml:"X509Data"`
}

KeyInfo is a XML-DSig description of a x509 key.

type NameID

type NameID struct {
	XMLName xml.Name `xml:"urn:oasis:names:tc:SAML:2.0:assertion NameID"`
	Format  string   `xml:"Format,attr"`
	Value   string   `xml:",chardata"`
}

NameID describes the primary identifier of the user.

type Response

type Response struct {
	XMLName   xml.Name       `xml:"urn:oasis:names:tc:SAML:2.0:protocol Response"`
	Signature dsig.Signature `xml:"Signature"`
	Assertion Assertion      `xml:"Assertion"`
}

Response represents a SAML response.

Verify can construct and verify a Response from an HTTP body parameter.

func Verify

func Verify(samlResponse, issuer string, cert *x509.Certificate, recipient string, now time.Time) (Response, error)

Verify parses and verifies a SAML response.

samlResponse should be the HTTP POST body parameter of a SAML response. For valid SAML logins, it will contain base64-encoded XML. Consider using ParamSAMLResponse to fetch samlResponse from an HTTP request.

issuer is the expected issuer of the SAML assertion. If samlResponse was issued by a different entity, Verify returns ErrInvalidIssuer.

cert is the x509 certificate that the issuer is expected to have signed samlResponse with. If samlResponse was not signed at all, Verify returns ErrResponseNotSigned. If samlResponse was incorrectly signed, Verify will return an error from Verify in github.com/ucarion/dsig.

recipient is the expected recipient of the SAML assertion. If samlResponse was issued for a different entity, Verify returns ErrInvalidRecipient.

now should be the current time in production systems, although you may want to use a hard-coded time in unit tests. It is used to verify whether samlResponse is expired. If samlResponse is expired, Verify returns ErrAssertionExpired.

Verify does not check if cert is expired.

type SingleSignOnService

type SingleSignOnService struct {
	XMLName  xml.Name `xml:"urn:oasis:names:tc:SAML:2.0:metadata SingleSignOnService"`
	Binding  string   `xml:"Binding,attr"`
	Location string   `xml:"Location,attr"`
}

SingleSignOnService describes a single binding of an identity provider, and the URL where it can be reached.

type Subject

type Subject struct {
	XMLName             xml.Name            `xml:"urn:oasis:names:tc:SAML:2.0:assertion Subject"`
	NameID              NameID              `xml:"NameID"`
	SubjectConfirmation SubjectConfirmation `xml:"SubjectConfirmation"`
}

Subject indicates the user the SAML assertion is about.

type SubjectConfirmation

type SubjectConfirmation struct {
	XMLName                 xml.Name                `xml:"urn:oasis:names:tc:SAML:2.0:assertion SubjectConfirmation"`
	SubjectConfirmationData SubjectConfirmationData `xml:"SubjectConfirmationData"`
}

SubjectConfirmation is a set of information that indicates how, and under what conditions, the user's identity was confirmed.

type SubjectConfirmationData

type SubjectConfirmationData struct {
	XMLName      xml.Name  `xml:"urn:oasis:names:tc:SAML:2.0:assertion SubjectConfirmationData"`
	NotOnOrAfter time.Time `xml:"NotOnOrAfter,attr"`
	Recipient    string    `xml:"Recipient,attr"`
}

SubjectConfirmationData is a set of constraints about what entities should accept this subject, and when the assertion should no longer be considered valid.

type X509Certificate

type X509Certificate struct {
	XMLName xml.Name `xml:"http://www.w3.org/2000/09/xmldsig# X509Certificate"`
	Value   string   `xml:",chardata"`
}

X509Certificate contains the base64-encoded ASN.1 data of a x509 certificate.

type X509Data

type X509Data struct {
	XMLName         xml.Name        `xml:"http://www.w3.org/2000/09/xmldsig# X509Data"`
	X509Certificate X509Certificate `xml:"X509Certificate"`
}

X509Data contains an x509 certificate.

Directories

Path Synopsis
examples

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL