Initial QSfera import

This commit is contained in:
Курнат Андрей
2026-06-07 10:20:04 +03:00
commit 2315f25754
16485 changed files with 4826827 additions and 0 deletions
+25
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
}