Initial QSfera import
This commit is contained in:
+25
@@ -0,0 +1,25 @@
|
||||
package samlsp
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"github.com/crewjam/saml"
|
||||
)
|
||||
|
||||
// ErrorFunction is a callback that is invoked to return an error to the
|
||||
// web user.
|
||||
type ErrorFunction func(w http.ResponseWriter, r *http.Request, err error)
|
||||
|
||||
// DefaultOnError is the default ErrorFunction implementation. It prints
|
||||
// an message via the standard log package and returns a simple text
|
||||
// "Forbidden" message to the user.
|
||||
func DefaultOnError(w http.ResponseWriter, _ *http.Request, err error) {
|
||||
if parseErr, ok := err.(*saml.InvalidResponseError); ok {
|
||||
log.Printf("WARNING: received invalid saml response: %s (now: %s) %s",
|
||||
parseErr.Response, parseErr.Now, parseErr.PrivateErr)
|
||||
} else {
|
||||
log.Printf("ERROR: %s", err)
|
||||
}
|
||||
http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
|
||||
}
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
package samlsp
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"github.com/crewjam/httperr"
|
||||
xrv "github.com/mattermost/xml-roundtrip-validator"
|
||||
|
||||
"github.com/crewjam/saml/logger"
|
||||
|
||||
"github.com/crewjam/saml"
|
||||
)
|
||||
|
||||
// ParseMetadata parses arbitrary SAML IDP metadata.
|
||||
//
|
||||
// Note: this is needed because IDP metadata is sometimes wrapped in
|
||||
// an <EntitiesDescriptor>, and sometimes the top level element is an
|
||||
// <EntityDescriptor>.
|
||||
func ParseMetadata(data []byte) (*saml.EntityDescriptor, error) {
|
||||
entity := &saml.EntityDescriptor{}
|
||||
|
||||
if err := xrv.Validate(bytes.NewBuffer(data)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err := xml.Unmarshal(data, entity)
|
||||
|
||||
// this comparison is ugly, but it is how the error is generated in encoding/xml
|
||||
if err != nil && err.Error() == "expected element type <EntityDescriptor> but have <EntitiesDescriptor>" {
|
||||
entities := &saml.EntitiesDescriptor{}
|
||||
if err := xml.Unmarshal(data, entities); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for i, e := range entities.EntityDescriptors {
|
||||
if len(e.IDPSSODescriptors) > 0 {
|
||||
return &entities.EntityDescriptors[i], nil
|
||||
}
|
||||
}
|
||||
return nil, errors.New("no entity found with IDPSSODescriptor")
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return entity, nil
|
||||
}
|
||||
|
||||
// FetchMetadata returns metadata from an IDP metadata URL.
|
||||
func FetchMetadata(ctx context.Context, httpClient *http.Client, metadataURL url.URL) (*saml.EntityDescriptor, error) {
|
||||
req, err := http.NewRequest("GET", metadataURL.String(), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req = req.WithContext(ctx)
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() {
|
||||
if err := resp.Body.Close(); err != nil {
|
||||
logger.DefaultLogger.Printf("Error while closing response body during fetch metadata: %v", err)
|
||||
}
|
||||
}()
|
||||
if resp.StatusCode >= 400 {
|
||||
return nil, httperr.Response(*resp)
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return ParseMetadata(data)
|
||||
}
|
||||
+253
@@ -0,0 +1,253 @@
|
||||
package samlsp
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/xml"
|
||||
"net/http"
|
||||
|
||||
"github.com/crewjam/saml"
|
||||
)
|
||||
|
||||
// Middleware implements middleware than allows a web application
|
||||
// to support SAML.
|
||||
//
|
||||
// It implements http.Handler so that it can provide the metadata and ACS endpoints,
|
||||
// typically /saml/metadata and /saml/acs, respectively.
|
||||
//
|
||||
// It also provides middleware RequireAccount which redirects users to
|
||||
// the auth process if they do not have session credentials.
|
||||
//
|
||||
// When redirecting the user through the SAML auth flow, the middleware assigns
|
||||
// a temporary cookie with a random name beginning with "saml_". The value of
|
||||
// the cookie is a signed JSON Web Token containing the original URL requested
|
||||
// and the SAML request ID. The random part of the name corresponds to the
|
||||
// RelayState parameter passed through the SAML flow.
|
||||
//
|
||||
// When validating the SAML response, the RelayState is used to look up the
|
||||
// correct cookie, validate that the SAML request ID, and redirect the user
|
||||
// back to their original URL.
|
||||
//
|
||||
// Sessions are established by issuing a JSON Web Token (JWT) as a session
|
||||
// cookie once the SAML flow has succeeded. The JWT token contains the
|
||||
// authenticated attributes from the SAML assertion.
|
||||
//
|
||||
// When the middleware receives a request with a valid session JWT it extracts
|
||||
// the SAML attributes and modifies the http.Request object adding a Context
|
||||
// object to the request context that contains attributes from the initial
|
||||
// SAML assertion.
|
||||
//
|
||||
// When issuing JSON Web Tokens, a signing key is required. Because the
|
||||
// SAML service provider already has a private key, we borrow that key
|
||||
// to sign the JWTs as well.
|
||||
type Middleware struct {
|
||||
ServiceProvider saml.ServiceProvider
|
||||
OnError func(w http.ResponseWriter, r *http.Request, err error)
|
||||
Binding string // either saml.HTTPPostBinding or saml.HTTPRedirectBinding
|
||||
ResponseBinding string // either saml.HTTPPostBinding or saml.HTTPArtifactBinding
|
||||
RequestTracker RequestTracker
|
||||
Session SessionProvider
|
||||
}
|
||||
|
||||
// ServeHTTP implements http.Handler and serves the SAML-specific HTTP endpoints
|
||||
// on the URIs specified by m.ServiceProvider.MetadataURL and
|
||||
// m.ServiceProvider.AcsURL.
|
||||
func (m *Middleware) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == m.ServiceProvider.MetadataURL.Path {
|
||||
m.ServeMetadata(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if r.URL.Path == m.ServiceProvider.AcsURL.Path {
|
||||
m.ServeACS(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
http.NotFoundHandler().ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
// ServeMetadata handles requests for the SAML metadata endpoint.
|
||||
func (m *Middleware) ServeMetadata(w http.ResponseWriter, _ *http.Request) {
|
||||
buf, _ := xml.MarshalIndent(m.ServiceProvider.Metadata(), "", " ")
|
||||
w.Header().Set("Content-Type", "application/samlmetadata+xml")
|
||||
if _, err := w.Write(buf); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// ServeACS handles requests for the SAML ACS endpoint.
|
||||
func (m *Middleware) ServeACS(w http.ResponseWriter, r *http.Request) {
|
||||
err := r.ParseForm()
|
||||
if err != nil {
|
||||
m.OnError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
possibleRequestIDs := []string{}
|
||||
if m.ServiceProvider.AllowIDPInitiated {
|
||||
possibleRequestIDs = append(possibleRequestIDs, "")
|
||||
}
|
||||
|
||||
trackedRequests := m.RequestTracker.GetTrackedRequests(r)
|
||||
for _, tr := range trackedRequests {
|
||||
possibleRequestIDs = append(possibleRequestIDs, tr.SAMLRequestID)
|
||||
}
|
||||
|
||||
assertion, err := m.ServiceProvider.ParseResponse(r, possibleRequestIDs)
|
||||
if err != nil {
|
||||
m.OnError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
m.CreateSessionFromAssertion(w, r, assertion, m.ServiceProvider.DefaultRedirectURI)
|
||||
}
|
||||
|
||||
// RequireAccount is HTTP middleware that requires that each request be
|
||||
// associated with a valid session. If the request is not associated with a valid
|
||||
// session, then rather than serve the request, the middleware redirects the user
|
||||
// to start the SAML auth flow.
|
||||
func (m *Middleware) RequireAccount(handler http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
session, err := m.Session.GetSession(r)
|
||||
if session != nil {
|
||||
r = r.WithContext(ContextWithSession(r.Context(), session))
|
||||
handler.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
if err == ErrNoSession {
|
||||
m.HandleStartAuthFlow(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
m.OnError(w, r, err)
|
||||
})
|
||||
}
|
||||
|
||||
// HandleStartAuthFlow is called to start the SAML authentication process.
|
||||
func (m *Middleware) HandleStartAuthFlow(w http.ResponseWriter, r *http.Request) {
|
||||
// If we try to redirect when the original request is the ACS URL we'll
|
||||
// end up in a loop. This is a programming error, so we panic here. In
|
||||
// general this means a 500 to the user, which is preferable to a
|
||||
// redirect loop.
|
||||
if r.URL.Path == m.ServiceProvider.AcsURL.Path {
|
||||
panic("don't wrap Middleware with RequireAccount")
|
||||
}
|
||||
|
||||
var binding, bindingLocation string
|
||||
if m.Binding != "" {
|
||||
binding = m.Binding
|
||||
bindingLocation = m.ServiceProvider.GetSSOBindingLocation(binding)
|
||||
} else {
|
||||
binding = saml.HTTPRedirectBinding
|
||||
bindingLocation = m.ServiceProvider.GetSSOBindingLocation(binding)
|
||||
if bindingLocation == "" {
|
||||
binding = saml.HTTPPostBinding
|
||||
bindingLocation = m.ServiceProvider.GetSSOBindingLocation(binding)
|
||||
}
|
||||
}
|
||||
|
||||
authReq, err := m.ServiceProvider.MakeAuthenticationRequest(bindingLocation, binding, m.ResponseBinding)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// relayState is limited to 80 bytes but also must be integrity protected.
|
||||
// this means that we cannot use a JWT because it is way to long. Instead
|
||||
// we set a signed cookie that encodes the original URL which we'll check
|
||||
// against the SAML response when we get it.
|
||||
relayState, err := m.RequestTracker.TrackRequest(w, r, authReq.ID)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if binding == saml.HTTPRedirectBinding {
|
||||
redirectURL, err := authReq.Redirect(relayState, &m.ServiceProvider)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Add("Location", redirectURL.String())
|
||||
w.WriteHeader(http.StatusFound)
|
||||
return
|
||||
}
|
||||
if binding == saml.HTTPPostBinding {
|
||||
w.Header().Add("Content-Security-Policy", ""+
|
||||
"default-src; "+
|
||||
"script-src 'sha256-AjPdJSbZmeWHnEc5ykvJFay8FTWeTeRbs9dutfZ0HqE='; "+
|
||||
"reflected-xss block; referrer no-referrer;")
|
||||
w.Header().Add("Content-type", "text/html")
|
||||
var buf bytes.Buffer
|
||||
buf.WriteString(`<!DOCTYPE html><html><body>`)
|
||||
buf.Write(authReq.Post(relayState))
|
||||
buf.WriteString(`</body></html>`)
|
||||
if _, err := w.Write(buf.Bytes()); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
panic("not reached")
|
||||
}
|
||||
|
||||
// CreateSessionFromAssertion is invoked by ServeHTTP when we have a new, valid SAML assertion.
|
||||
func (m *Middleware) CreateSessionFromAssertion(w http.ResponseWriter, r *http.Request, assertion *saml.Assertion, redirectURI string) {
|
||||
if trackedRequestIndex := r.Form.Get("RelayState"); trackedRequestIndex != "" {
|
||||
trackedRequest, err := m.RequestTracker.GetTrackedRequest(r, trackedRequestIndex)
|
||||
if err != nil {
|
||||
if err == http.ErrNoCookie && m.ServiceProvider.AllowIDPInitiated {
|
||||
if uri := r.Form.Get("RelayState"); uri != "" {
|
||||
redirectURI = uri
|
||||
}
|
||||
} else {
|
||||
m.OnError(w, r, err)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
if err := m.RequestTracker.StopTrackingRequest(w, r, trackedRequestIndex); err != nil {
|
||||
m.OnError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
redirectURI = trackedRequest.URI
|
||||
}
|
||||
}
|
||||
|
||||
if err := m.Session.CreateSession(w, r, assertion); err != nil {
|
||||
m.OnError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, redirectURI, http.StatusFound)
|
||||
}
|
||||
|
||||
// RequireAttribute returns a middleware function that requires that the
|
||||
// SAML attribute `name` be set to `value`. This can be used to require
|
||||
// that a remote user be a member of a group. It relies on the Claims assigned
|
||||
// to to the context in RequireAccount.
|
||||
//
|
||||
// For example:
|
||||
//
|
||||
// goji.Use(m.RequireAccount)
|
||||
// goji.Use(RequireAttributeMiddleware("eduPersonAffiliation", "Staff"))
|
||||
func RequireAttribute(name, value string) func(http.Handler) http.Handler {
|
||||
return func(handler http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if session := SessionFromContext(r.Context()); session != nil {
|
||||
// this will panic if we have the wrong type of Session, and that is OK.
|
||||
sessionWithAttributes := session.(SessionWithAttributes)
|
||||
attributes := sessionWithAttributes.GetAttributes()
|
||||
if values, ok := attributes[name]; ok {
|
||||
for _, v := range values {
|
||||
if v == value {
|
||||
handler.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
|
||||
})
|
||||
}
|
||||
}
|
||||
+154
@@ -0,0 +1,154 @@
|
||||
// Package samlsp provides helpers that can be used to protect web services using SAML.
|
||||
package samlsp
|
||||
|
||||
import (
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
dsig "github.com/russellhaering/goxmldsig"
|
||||
|
||||
"github.com/crewjam/saml"
|
||||
)
|
||||
|
||||
// Options represents the parameters for creating a new middleware
|
||||
type Options struct {
|
||||
EntityID string
|
||||
URL url.URL
|
||||
Key *rsa.PrivateKey
|
||||
Certificate *x509.Certificate
|
||||
Intermediates []*x509.Certificate
|
||||
HTTPClient *http.Client
|
||||
AllowIDPInitiated bool
|
||||
DefaultRedirectURI string
|
||||
IDPMetadata *saml.EntityDescriptor
|
||||
SignRequest bool
|
||||
UseArtifactResponse bool
|
||||
ForceAuthn bool // TODO(ross): this should be *bool
|
||||
RequestedAuthnContext *saml.RequestedAuthnContext
|
||||
CookieSameSite http.SameSite
|
||||
CookieName string
|
||||
RelayStateFunc func(w http.ResponseWriter, r *http.Request) string
|
||||
LogoutBindings []string
|
||||
}
|
||||
|
||||
// DefaultSessionCodec returns the default SessionCodec for the provided options,
|
||||
// a JWTSessionCodec configured to issue signed tokens.
|
||||
func DefaultSessionCodec(opts Options) JWTSessionCodec {
|
||||
return JWTSessionCodec{
|
||||
SigningMethod: defaultJWTSigningMethod,
|
||||
Audience: opts.URL.String(),
|
||||
Issuer: opts.URL.String(),
|
||||
MaxAge: defaultSessionMaxAge,
|
||||
Key: opts.Key,
|
||||
}
|
||||
}
|
||||
|
||||
// DefaultSessionProvider returns the default SessionProvider for the provided options,
|
||||
// a CookieSessionProvider configured to store sessions in a cookie.
|
||||
func DefaultSessionProvider(opts Options) CookieSessionProvider {
|
||||
cookieName := opts.CookieName
|
||||
if cookieName == "" {
|
||||
cookieName = defaultSessionCookieName
|
||||
}
|
||||
return CookieSessionProvider{
|
||||
Name: cookieName,
|
||||
Domain: opts.URL.Host,
|
||||
MaxAge: defaultSessionMaxAge,
|
||||
HTTPOnly: true,
|
||||
Secure: opts.URL.Scheme == "https",
|
||||
SameSite: opts.CookieSameSite,
|
||||
Codec: DefaultSessionCodec(opts),
|
||||
}
|
||||
}
|
||||
|
||||
// DefaultTrackedRequestCodec returns a new TrackedRequestCodec for the provided
|
||||
// options, a JWTTrackedRequestCodec that uses a JWT to encode TrackedRequests.
|
||||
func DefaultTrackedRequestCodec(opts Options) JWTTrackedRequestCodec {
|
||||
return JWTTrackedRequestCodec{
|
||||
SigningMethod: defaultJWTSigningMethod,
|
||||
Audience: opts.URL.String(),
|
||||
Issuer: opts.URL.String(),
|
||||
MaxAge: saml.MaxIssueDelay,
|
||||
Key: opts.Key,
|
||||
}
|
||||
}
|
||||
|
||||
// DefaultRequestTracker returns a new RequestTracker for the provided options,
|
||||
// a CookieRequestTracker which uses cookies to track pending requests.
|
||||
func DefaultRequestTracker(opts Options, serviceProvider *saml.ServiceProvider) CookieRequestTracker {
|
||||
return CookieRequestTracker{
|
||||
ServiceProvider: serviceProvider,
|
||||
NamePrefix: "saml_",
|
||||
Codec: DefaultTrackedRequestCodec(opts),
|
||||
MaxAge: saml.MaxIssueDelay,
|
||||
RelayStateFunc: opts.RelayStateFunc,
|
||||
SameSite: opts.CookieSameSite,
|
||||
}
|
||||
}
|
||||
|
||||
// DefaultServiceProvider returns the default saml.ServiceProvider for the provided
|
||||
// options.
|
||||
func DefaultServiceProvider(opts Options) saml.ServiceProvider {
|
||||
metadataURL := opts.URL.ResolveReference(&url.URL{Path: "saml/metadata"})
|
||||
acsURL := opts.URL.ResolveReference(&url.URL{Path: "saml/acs"})
|
||||
sloURL := opts.URL.ResolveReference(&url.URL{Path: "saml/slo"})
|
||||
|
||||
var forceAuthn *bool
|
||||
if opts.ForceAuthn {
|
||||
forceAuthn = &opts.ForceAuthn
|
||||
}
|
||||
signatureMethod := dsig.RSASHA1SignatureMethod
|
||||
if !opts.SignRequest {
|
||||
signatureMethod = ""
|
||||
}
|
||||
|
||||
if opts.DefaultRedirectURI == "" {
|
||||
opts.DefaultRedirectURI = "/"
|
||||
}
|
||||
|
||||
if len(opts.LogoutBindings) == 0 {
|
||||
opts.LogoutBindings = []string{saml.HTTPPostBinding}
|
||||
}
|
||||
|
||||
return saml.ServiceProvider{
|
||||
EntityID: opts.EntityID,
|
||||
Key: opts.Key,
|
||||
Certificate: opts.Certificate,
|
||||
HTTPClient: opts.HTTPClient,
|
||||
Intermediates: opts.Intermediates,
|
||||
MetadataURL: *metadataURL,
|
||||
AcsURL: *acsURL,
|
||||
SloURL: *sloURL,
|
||||
IDPMetadata: opts.IDPMetadata,
|
||||
ForceAuthn: forceAuthn,
|
||||
RequestedAuthnContext: opts.RequestedAuthnContext,
|
||||
SignatureMethod: signatureMethod,
|
||||
AllowIDPInitiated: opts.AllowIDPInitiated,
|
||||
DefaultRedirectURI: opts.DefaultRedirectURI,
|
||||
LogoutBindings: opts.LogoutBindings,
|
||||
}
|
||||
}
|
||||
|
||||
// New creates a new Middleware with the default providers for the
|
||||
// given options.
|
||||
//
|
||||
// You can customize the behavior of the middleware in more detail by
|
||||
// replacing and/or changing Session, RequestTracker, and ServiceProvider
|
||||
// in the returned Middleware.
|
||||
func New(opts Options) (*Middleware, error) {
|
||||
m := &Middleware{
|
||||
ServiceProvider: DefaultServiceProvider(opts),
|
||||
Binding: "",
|
||||
ResponseBinding: saml.HTTPPostBinding,
|
||||
OnError: DefaultOnError,
|
||||
Session: DefaultSessionProvider(opts),
|
||||
}
|
||||
m.RequestTracker = DefaultRequestTracker(opts, &m.ServiceProvider)
|
||||
if opts.UseArtifactResponse {
|
||||
m.ResponseBinding = saml.HTTPArtifactBinding
|
||||
}
|
||||
|
||||
return m, nil
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
package samlsp
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// RequestTracker tracks pending authentication requests.
|
||||
//
|
||||
// There are two main reasons for this:
|
||||
//
|
||||
// 1. When the middleware initiates an authentication request it must track the original URL
|
||||
// in order to redirect the user to the right place after the authentication completes.
|
||||
//
|
||||
// 2. After the authentication completes, we want to ensure that the user presenting the
|
||||
// assertion is actually the one the request it, to mitigate request forgeries.
|
||||
type RequestTracker interface {
|
||||
// TrackRequest starts tracking the SAML request with the given ID. It returns an
|
||||
// `index` that should be used as the RelayState in the SAMl request flow.
|
||||
TrackRequest(w http.ResponseWriter, r *http.Request, samlRequestID string) (index string, err error)
|
||||
|
||||
// StopTrackingRequest stops tracking the SAML request given by index, which is a string
|
||||
// previously returned from TrackRequest
|
||||
StopTrackingRequest(w http.ResponseWriter, r *http.Request, index string) error
|
||||
|
||||
// GetTrackedRequests returns all the pending tracked requests
|
||||
GetTrackedRequests(r *http.Request) []TrackedRequest
|
||||
|
||||
// GetTrackedRequest returns a pending tracked request.
|
||||
GetTrackedRequest(r *http.Request, index string) (*TrackedRequest, error)
|
||||
}
|
||||
|
||||
// TrackedRequest holds the data we store for each pending request.
|
||||
type TrackedRequest struct {
|
||||
Index string `json:"-"`
|
||||
SAMLRequestID string `json:"id"`
|
||||
URI string `json:"uri"`
|
||||
}
|
||||
|
||||
// TrackedRequestCodec handles encoding and decoding of a TrackedRequest.
|
||||
type TrackedRequestCodec interface {
|
||||
// Encode returns an encoded string representing the TrackedRequest.
|
||||
Encode(value TrackedRequest) (string, error)
|
||||
|
||||
// Decode returns a Tracked request from an encoded string.
|
||||
Decode(signed string) (*TrackedRequest, error)
|
||||
}
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
package samlsp
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/crewjam/saml"
|
||||
)
|
||||
|
||||
var _ RequestTracker = CookieRequestTracker{}
|
||||
|
||||
// CookieRequestTracker tracks requests by setting a uniquely named
|
||||
// cookie for each request.
|
||||
type CookieRequestTracker struct {
|
||||
ServiceProvider *saml.ServiceProvider
|
||||
NamePrefix string
|
||||
Codec TrackedRequestCodec
|
||||
MaxAge time.Duration
|
||||
RelayStateFunc func(w http.ResponseWriter, r *http.Request) string
|
||||
SameSite http.SameSite
|
||||
}
|
||||
|
||||
// TrackRequest starts tracking the SAML request with the given ID. It returns an
|
||||
// `index` that should be used as the RelayState in the SAMl request flow.
|
||||
func (t CookieRequestTracker) TrackRequest(w http.ResponseWriter, r *http.Request, samlRequestID string) (string, error) {
|
||||
trackedRequest := TrackedRequest{
|
||||
Index: base64.RawURLEncoding.EncodeToString(randomBytes(42)),
|
||||
SAMLRequestID: samlRequestID,
|
||||
URI: r.URL.String(),
|
||||
}
|
||||
|
||||
if t.RelayStateFunc != nil {
|
||||
relayState := t.RelayStateFunc(w, r)
|
||||
if relayState != "" {
|
||||
trackedRequest.Index = relayState
|
||||
}
|
||||
}
|
||||
|
||||
signedTrackedRequest, err := t.Codec.Encode(trackedRequest)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: t.NamePrefix + trackedRequest.Index,
|
||||
Value: signedTrackedRequest,
|
||||
MaxAge: int(t.MaxAge.Seconds()),
|
||||
HttpOnly: true,
|
||||
SameSite: t.SameSite,
|
||||
Secure: t.ServiceProvider.AcsURL.Scheme == "https",
|
||||
Path: t.ServiceProvider.AcsURL.Path,
|
||||
})
|
||||
|
||||
return trackedRequest.Index, nil
|
||||
}
|
||||
|
||||
// StopTrackingRequest stops tracking the SAML request given by index, which is a string
|
||||
// previously returned from TrackRequest
|
||||
func (t CookieRequestTracker) StopTrackingRequest(w http.ResponseWriter, r *http.Request, index string) error {
|
||||
cookie, err := r.Cookie(t.NamePrefix + index)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cookie.Value = ""
|
||||
cookie.Domain = t.ServiceProvider.AcsURL.Hostname()
|
||||
cookie.Expires = time.Unix(1, 0) // past time as close to epoch as possible, but not zero time.Time{}
|
||||
http.SetCookie(w, cookie)
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetTrackedRequests returns all the pending tracked requests
|
||||
func (t CookieRequestTracker) GetTrackedRequests(r *http.Request) []TrackedRequest {
|
||||
rv := []TrackedRequest{}
|
||||
for _, cookie := range r.Cookies() {
|
||||
if !strings.HasPrefix(cookie.Name, t.NamePrefix) {
|
||||
continue
|
||||
}
|
||||
|
||||
trackedRequest, err := t.Codec.Decode(cookie.Value)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
index := strings.TrimPrefix(cookie.Name, t.NamePrefix)
|
||||
if index != trackedRequest.Index {
|
||||
continue
|
||||
}
|
||||
|
||||
rv = append(rv, *trackedRequest)
|
||||
}
|
||||
return rv
|
||||
}
|
||||
|
||||
// GetTrackedRequest returns a pending tracked request.
|
||||
func (t CookieRequestTracker) GetTrackedRequest(r *http.Request, index string) (*TrackedRequest, error) {
|
||||
cookie, err := r.Cookie(t.NamePrefix + index)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
trackedRequest, err := t.Codec.Decode(cookie.Value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if trackedRequest.Index != index {
|
||||
return nil, fmt.Errorf("expected index %q, got %q", index, trackedRequest.Index)
|
||||
}
|
||||
return trackedRequest, nil
|
||||
}
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
package samlsp
|
||||
|
||||
import (
|
||||
"crypto/rsa"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v4"
|
||||
|
||||
"github.com/crewjam/saml"
|
||||
)
|
||||
|
||||
var defaultJWTSigningMethod = jwt.SigningMethodRS256
|
||||
|
||||
// JWTTrackedRequestCodec encodes TrackedRequests as signed JWTs
|
||||
type JWTTrackedRequestCodec struct {
|
||||
SigningMethod jwt.SigningMethod
|
||||
Audience string
|
||||
Issuer string
|
||||
MaxAge time.Duration
|
||||
Key *rsa.PrivateKey
|
||||
}
|
||||
|
||||
var _ TrackedRequestCodec = JWTTrackedRequestCodec{}
|
||||
|
||||
// JWTTrackedRequestClaims represents the JWT claims for a tracked request.
|
||||
type JWTTrackedRequestClaims struct {
|
||||
jwt.RegisteredClaims
|
||||
TrackedRequest
|
||||
SAMLAuthnRequest bool `json:"saml-authn-request"`
|
||||
}
|
||||
|
||||
// Encode returns an encoded string representing the TrackedRequest.
|
||||
func (s JWTTrackedRequestCodec) Encode(value TrackedRequest) (string, error) {
|
||||
now := saml.TimeNow()
|
||||
claims := JWTTrackedRequestClaims{
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
Audience: jwt.ClaimStrings{s.Audience},
|
||||
ExpiresAt: jwt.NewNumericDate(now.Add(s.MaxAge)),
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
Issuer: s.Issuer,
|
||||
NotBefore: jwt.NewNumericDate(now), // TODO(ross): correct for clock skew
|
||||
Subject: value.Index,
|
||||
},
|
||||
TrackedRequest: value,
|
||||
SAMLAuthnRequest: true,
|
||||
}
|
||||
token := jwt.NewWithClaims(s.SigningMethod, claims)
|
||||
return token.SignedString(s.Key)
|
||||
}
|
||||
|
||||
// Decode returns a Tracked request from an encoded string.
|
||||
func (s JWTTrackedRequestCodec) Decode(signed string) (*TrackedRequest, error) {
|
||||
parser := jwt.Parser{
|
||||
ValidMethods: []string{s.SigningMethod.Alg()},
|
||||
}
|
||||
claims := JWTTrackedRequestClaims{}
|
||||
_, err := parser.ParseWithClaims(signed, &claims, func(*jwt.Token) (interface{}, error) {
|
||||
return s.Key.Public(), nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !claims.VerifyAudience(s.Audience, true) {
|
||||
return nil, fmt.Errorf("expected audience %q, got %q", s.Audience, claims.Audience)
|
||||
}
|
||||
if !claims.VerifyIssuer(s.Issuer, true) {
|
||||
return nil, fmt.Errorf("expected issuer %q, got %q", s.Issuer, claims.Issuer)
|
||||
}
|
||||
if !claims.SAMLAuthnRequest {
|
||||
return nil, fmt.Errorf("expected saml-authn-request")
|
||||
}
|
||||
claims.TrackedRequest.Index = claims.Subject
|
||||
return &claims.TrackedRequest, nil
|
||||
}
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
package samlsp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/crewjam/saml"
|
||||
)
|
||||
|
||||
// Session is an interface implemented to contain a session.
|
||||
type Session interface{}
|
||||
|
||||
// SessionWithAttributes is a session that can expose the
|
||||
// attributes provided by the SAML identity provider.
|
||||
type SessionWithAttributes interface {
|
||||
Session
|
||||
GetAttributes() Attributes
|
||||
}
|
||||
|
||||
// ErrNoSession is the error returned when the remote user does not have a session
|
||||
var ErrNoSession = errors.New("saml: session not present")
|
||||
|
||||
// SessionProvider is an interface implemented by types that can track
|
||||
// the active session of a user. The default implementation is CookieSessionProvider
|
||||
type SessionProvider interface {
|
||||
// CreateSession is called when we have received a valid SAML assertion and
|
||||
// should create a new session and modify the http response accordingly, e.g. by
|
||||
// setting a cookie.
|
||||
CreateSession(w http.ResponseWriter, r *http.Request, assertion *saml.Assertion) error
|
||||
|
||||
// DeleteSession is called to modify the response such that it removed the current
|
||||
// session, e.g. by deleting a cookie.
|
||||
DeleteSession(w http.ResponseWriter, r *http.Request) error
|
||||
|
||||
// GetSession returns the current Session associated with the request, or
|
||||
// ErrNoSession if there is no valid session.
|
||||
GetSession(r *http.Request) (Session, error)
|
||||
}
|
||||
|
||||
// SessionCodec is an interface to convert SAML assertions to a
|
||||
// Session. The default implementation uses JWTs, JWTSessionCodec.
|
||||
type SessionCodec interface {
|
||||
// New creates a Session from the SAML assertion.
|
||||
New(assertion *saml.Assertion) (Session, error)
|
||||
|
||||
// Encode returns a serialized version of the Session.
|
||||
//
|
||||
// Note: When implementing this function, it is reasonable to expect that
|
||||
// Session is of the exact type returned by New(), and panic if it is not.
|
||||
Encode(s Session) (string, error)
|
||||
|
||||
// Decode parses the serialized session that may have been returned by Encode
|
||||
// and returns a Session.
|
||||
Decode(string) (Session, error)
|
||||
}
|
||||
|
||||
type indexType int
|
||||
|
||||
const sessionIndex indexType = iota
|
||||
|
||||
// SessionFromContext returns the session associated with ctx, or nil
|
||||
// if no session are associated
|
||||
func SessionFromContext(ctx context.Context) Session {
|
||||
v := ctx.Value(sessionIndex)
|
||||
if v == nil {
|
||||
return nil
|
||||
}
|
||||
return v.(Session)
|
||||
}
|
||||
|
||||
// ContextWithSession returns a new context with session associated
|
||||
func ContextWithSession(ctx context.Context, session Session) context.Context {
|
||||
return context.WithValue(ctx, sessionIndex, session)
|
||||
}
|
||||
|
||||
// AttributeFromContext is a convenience method that returns the named attribute
|
||||
// from the session, if available.
|
||||
func AttributeFromContext(ctx context.Context, name string) string {
|
||||
s := SessionFromContext(ctx)
|
||||
if s == nil {
|
||||
return ""
|
||||
}
|
||||
sa, ok := s.(SessionWithAttributes)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return sa.GetAttributes().Get(name)
|
||||
}
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
package samlsp
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/crewjam/saml"
|
||||
)
|
||||
|
||||
const defaultSessionCookieName = "token"
|
||||
|
||||
var _ SessionProvider = CookieSessionProvider{}
|
||||
|
||||
// CookieSessionProvider is an implementation of SessionProvider that stores
|
||||
// session tokens in an HTTP cookie.
|
||||
type CookieSessionProvider struct {
|
||||
Name string
|
||||
Domain string
|
||||
HTTPOnly bool
|
||||
Secure bool
|
||||
SameSite http.SameSite
|
||||
MaxAge time.Duration
|
||||
Codec SessionCodec
|
||||
}
|
||||
|
||||
// CreateSession is called when we have received a valid SAML assertion and
|
||||
// should create a new session and modify the http response accordingly, e.g. by
|
||||
// setting a cookie.
|
||||
func (c CookieSessionProvider) CreateSession(w http.ResponseWriter, r *http.Request, assertion *saml.Assertion) error {
|
||||
// Cookies should not have the port attached to them so strip it off
|
||||
if domain, _, err := net.SplitHostPort(c.Domain); err == nil {
|
||||
c.Domain = domain
|
||||
}
|
||||
|
||||
session, err := c.Codec.New(assertion)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
value, err := c.Codec.Encode(session)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: c.Name,
|
||||
Domain: c.Domain,
|
||||
Value: value,
|
||||
MaxAge: int(c.MaxAge.Seconds()),
|
||||
HttpOnly: c.HTTPOnly,
|
||||
Secure: c.Secure || r.URL.Scheme == "https",
|
||||
SameSite: c.SameSite,
|
||||
Path: "/",
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteSession is called to modify the response such that it removed the current
|
||||
// session, e.g. by deleting a cookie.
|
||||
func (c CookieSessionProvider) DeleteSession(w http.ResponseWriter, r *http.Request) error {
|
||||
// Cookies should not have the port attached to them so strip it off
|
||||
if domain, _, err := net.SplitHostPort(c.Domain); err == nil {
|
||||
c.Domain = domain
|
||||
}
|
||||
|
||||
cookie, err := r.Cookie(c.Name)
|
||||
|
||||
if err == http.ErrNoCookie {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cookie.Value = ""
|
||||
cookie.Expires = time.Unix(1, 0) // past time as close to epoch as possible, but not zero time.Time{}
|
||||
cookie.Path = "/"
|
||||
cookie.Domain = c.Domain
|
||||
http.SetCookie(w, cookie)
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetSession returns the current Session associated with the request, or
|
||||
// ErrNoSession if there is no valid session.
|
||||
func (c CookieSessionProvider) GetSession(r *http.Request) (Session, error) {
|
||||
cookie, err := r.Cookie(c.Name)
|
||||
if err == http.ErrNoCookie {
|
||||
return nil, ErrNoSession
|
||||
} else if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
session, err := c.Codec.Decode(cookie.Value)
|
||||
if err != nil {
|
||||
return nil, ErrNoSession
|
||||
}
|
||||
return session, nil
|
||||
}
|
||||
+143
@@ -0,0 +1,143 @@
|
||||
package samlsp
|
||||
|
||||
import (
|
||||
"crypto/rsa"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v4"
|
||||
|
||||
"github.com/crewjam/saml"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultSessionMaxAge = time.Hour
|
||||
claimNameSessionIndex = "SessionIndex"
|
||||
)
|
||||
|
||||
// JWTSessionCodec implements SessionCoded to encode and decode Sessions from
|
||||
// the corresponding JWT.
|
||||
type JWTSessionCodec struct {
|
||||
SigningMethod jwt.SigningMethod
|
||||
Audience string
|
||||
Issuer string
|
||||
MaxAge time.Duration
|
||||
Key *rsa.PrivateKey
|
||||
}
|
||||
|
||||
var _ SessionCodec = JWTSessionCodec{}
|
||||
|
||||
// New creates a Session from the SAML assertion.
|
||||
//
|
||||
// The returned Session is a JWTSessionClaims.
|
||||
func (c JWTSessionCodec) New(assertion *saml.Assertion) (Session, error) {
|
||||
now := saml.TimeNow()
|
||||
claims := JWTSessionClaims{}
|
||||
claims.SAMLSession = true
|
||||
claims.Audience = c.Audience
|
||||
claims.Issuer = c.Issuer
|
||||
claims.IssuedAt = now.Unix()
|
||||
claims.ExpiresAt = now.Add(c.MaxAge).Unix()
|
||||
claims.NotBefore = now.Unix()
|
||||
|
||||
if sub := assertion.Subject; sub != nil {
|
||||
if nameID := sub.NameID; nameID != nil {
|
||||
claims.Subject = nameID.Value
|
||||
}
|
||||
}
|
||||
|
||||
claims.Attributes = map[string][]string{}
|
||||
|
||||
for _, attributeStatement := range assertion.AttributeStatements {
|
||||
for _, attr := range attributeStatement.Attributes {
|
||||
claimName := attr.FriendlyName
|
||||
if claimName == "" {
|
||||
claimName = attr.Name
|
||||
}
|
||||
for _, value := range attr.Values {
|
||||
claims.Attributes[claimName] = append(claims.Attributes[claimName], value.Value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// add SessionIndex to claims Attributes
|
||||
for _, authnStatement := range assertion.AuthnStatements {
|
||||
claims.Attributes[claimNameSessionIndex] = append(claims.Attributes[claimNameSessionIndex],
|
||||
authnStatement.SessionIndex)
|
||||
}
|
||||
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
// Encode returns a serialized version of the Session.
|
||||
//
|
||||
// The provided session must be a JWTSessionClaims, otherwise this
|
||||
// function will panic.
|
||||
func (c JWTSessionCodec) Encode(s Session) (string, error) {
|
||||
claims := s.(JWTSessionClaims) // this will panic if you pass the wrong kind of session
|
||||
|
||||
token := jwt.NewWithClaims(c.SigningMethod, claims)
|
||||
signedString, err := token.SignedString(c.Key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return signedString, nil
|
||||
}
|
||||
|
||||
// Decode parses the serialized session that may have been returned by Encode
|
||||
// and returns a Session.
|
||||
func (c JWTSessionCodec) Decode(signed string) (Session, error) {
|
||||
parser := jwt.Parser{
|
||||
ValidMethods: []string{c.SigningMethod.Alg()},
|
||||
}
|
||||
claims := JWTSessionClaims{}
|
||||
_, err := parser.ParseWithClaims(signed, &claims, func(*jwt.Token) (interface{}, error) {
|
||||
return c.Key.Public(), nil
|
||||
})
|
||||
// TODO(ross): check for errors due to bad time and return ErrNoSession
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !claims.VerifyAudience(c.Audience, true) {
|
||||
return nil, fmt.Errorf("expected audience %q, got %q", c.Audience, claims.Audience)
|
||||
}
|
||||
if !claims.VerifyIssuer(c.Issuer, true) {
|
||||
return nil, fmt.Errorf("expected issuer %q, got %q", c.Issuer, claims.Issuer)
|
||||
}
|
||||
if !claims.SAMLSession {
|
||||
return nil, errors.New("expected saml-session")
|
||||
}
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
// JWTSessionClaims represents the JWT claims in the encoded session
|
||||
type JWTSessionClaims struct {
|
||||
jwt.StandardClaims
|
||||
Attributes Attributes `json:"attr"`
|
||||
SAMLSession bool `json:"saml-session"`
|
||||
}
|
||||
|
||||
var _ Session = JWTSessionClaims{}
|
||||
|
||||
// GetAttributes implements SessionWithAttributes. It returns the SAMl attributes.
|
||||
func (c JWTSessionClaims) GetAttributes() Attributes {
|
||||
return c.Attributes
|
||||
}
|
||||
|
||||
// Attributes is a map of attributes provided in the SAML assertion
|
||||
type Attributes map[string][]string
|
||||
|
||||
// Get returns the first attribute named `key` or an empty string if
|
||||
// no such attributes is present.
|
||||
func (a Attributes) Get(key string) string {
|
||||
if a == nil {
|
||||
return ""
|
||||
}
|
||||
v := a[key]
|
||||
if len(v) == 0 {
|
||||
return ""
|
||||
}
|
||||
return v[0]
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
package samlsp
|
||||
|
||||
import (
|
||||
"io"
|
||||
|
||||
"github.com/crewjam/saml"
|
||||
)
|
||||
|
||||
func randomBytes(n int) []byte {
|
||||
rv := make([]byte, n)
|
||||
|
||||
if _, err := io.ReadFull(saml.RandReader, rv); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return rv
|
||||
}
|
||||
Reference in New Issue
Block a user