Skip to content

Commit

Permalink
Support anonymous access prior to login request (#63)
Browse files Browse the repository at this point in the history
  • Loading branch information
itzg committed Aug 21, 2023
1 parent 6745b24 commit 87d1db2
Show file tree
Hide file tree
Showing 14 changed files with 321 additions and 73 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v4
with:
go-version: 1.18
go-version: "1.20"

- name: Run tests
run: |
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ jobs:
- name: Setup Go
uses: actions/setup-go@v4
with:
go-version: 1.18
go-version: "1.20"

- name: Run tests
run: |
Expand Down
4 changes: 2 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/*metadata.xml
/*.cert
/*.key
*.cert
*.key

/dist/
/saml-auth-proxy
Expand Down
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
FROM golang:1.18-alpine3.16 AS builder
FROM golang:1.20-alpine3.16 AS builder

WORKDIR /app
COPY . /app
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ Provides a SAML SP authentication proxy for backend web services
Optional path to a CA certificate PEM file for the IdP (env SAML_PROXY_IDP_CA_PATH)
-idp-metadata-url URL
URL of the IdP's metadata XML, can be a local file by specifying the file:// scheme (env SAML_PROXY_IDP_METADATA_URL)
-initiate-session-path path
If set, initiates a SAML authentication flow only when a user visits this path. This will allow anonymous users to access to the backend. (env SAML_PROXY_INITIATE_SESSION_PATH)
-name-id-format string
One of unspecified, transient, email, or persistent to use a standard format or give a full URN of the name ID format (env SAML_PROXY_NAME_ID_FORMAT) (default "transient")
-idp-metadata-url URL
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ github.com/crewjam/httperr v0.2.0 h1:b2BfXR8U3AlIHwNeFFvZ+BV1LFvKLlzMjzaTnZMybNo
github.com/crewjam/httperr v0.2.0/go.mod h1:Jlz+Sg/XqBQhyMjdDiC+GNNRzZTD7x39Gu3pglZ5oH4=
github.com/crewjam/saml v0.4.13 h1:TYHggH/hwP7eArqiXSJUvtOPNzQDyQ7vwmwEqlFWhMc=
github.com/crewjam/saml v0.4.13/go.mod h1:igEejV+fihTIlHXYP8zOec3V5A8y3lws5bQBFsTm4gA=
github.com/crewjam/saml v0.4.13 h1:TYHggH/hwP7eArqiXSJUvtOPNzQDyQ7vwmwEqlFWhMc=
github.com/crewjam/saml v0.4.13/go.mod h1:igEejV+fihTIlHXYP8zOec3V5A8y3lws5bQBFsTm4gA=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
Expand Down
60 changes: 60 additions & 0 deletions server/anonymous.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package server

import (
"errors"
"github.com/crewjam/saml"
"github.com/crewjam/saml/samlsp"
"go.uber.org/zap"
"net/http"
)

type AnonymousSession struct {
}

func IsAnonymousSession(session samlsp.Session) bool {
_, isAnonymous := session.(AnonymousSession)
return isAnonymous
}

// InitAnonymousSessionProvider will initially provide AnonymousSession instances when requested; however,
// once the given initiateSessionPath is intercepted, then remaining session access is delegated to the
// given delegateSessionProvider.
type InitAnonymousSessionProvider struct {
delegateSessionProvider samlsp.SessionProvider
initiateSessionPath string
logger *zap.Logger
}

func NewInitAnonymousSessionProvider(logger *zap.Logger, initiateSessionPath string, delegateSessionProvider samlsp.SessionProvider) *InitAnonymousSessionProvider {
return &InitAnonymousSessionProvider{
delegateSessionProvider: delegateSessionProvider,
initiateSessionPath: initiateSessionPath,
logger: logger.With(zap.String("scope", "InitAnonymousSessionProvider")),
}
}

func (p *InitAnonymousSessionProvider) CreateSession(w http.ResponseWriter, r *http.Request, assertion *saml.Assertion) error {
return p.delegateSessionProvider.CreateSession(w, r, assertion)
}

func (p *InitAnonymousSessionProvider) DeleteSession(w http.ResponseWriter, r *http.Request) error {
return p.delegateSessionProvider.DeleteSession(w, r)
}

func (p *InitAnonymousSessionProvider) GetSession(r *http.Request) (samlsp.Session, error) {
session, err := p.delegateSessionProvider.GetSession(r)
if err != nil {
if errors.Is(err, samlsp.ErrNoSession) {
if r.URL.Path == p.initiateSessionPath {
p.logger.Debug("Intercepted initiate session path", zap.String("path", r.URL.Path))
return nil, samlsp.ErrNoSession
}
p.logger.Debug("Auth has not been initiated, returning anonymous session", zap.String("path", r.URL.Path))
return AnonymousSession{}, nil
} else {
return nil, err
}
} else {
return session, nil
}
}
1 change: 1 addition & 0 deletions server/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,5 @@ type Config struct {
AuthVerifyPath string `default:"/_verify" usage:"Path under BaseUrl that will respond with a 200 when authenticated"`
Debug bool `usage:"Enable debug logs"`
StaticRelayState string `usage:"A fixed RelayState value, such as a short URL. Will be trimmed to 80 characters to conform with SAML. The default generates random bytes that are Base64 encoded."`
InitiateSessionPath string `usage:"If set, initiates a SAML authentication flow only when a user visits this path. This will allow anonymous users to access to the backend."`
}
152 changes: 85 additions & 67 deletions server/proxy.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,18 +28,17 @@ const (
HeaderForwardedFor = "X-Forwarded-For"
HeaderForwardedHost = "X-Forwarded-Host"
HeaderForwardedURI = "X-Forwarded-Uri"
HeaderForwardedMethod = "X-Forwarded-Method"
)

type proxy struct {
type Proxy struct {
config *Config
backendUrl *url.URL
client *http.Client
newTokenCache *cache.Cache
logger *zap.Logger
}

func NewProxy(logger *zap.Logger, cfg *Config) (*proxy, error) {
func NewProxy(logger *zap.Logger, cfg *Config) (*Proxy, error) {
backendUrl, err := url.Parse(cfg.BackendUrl)
if err != nil {
return nil, fmt.Errorf("failed to parse backend URL: %w", err)
Expand All @@ -52,7 +51,7 @@ func NewProxy(logger *zap.Logger, cfg *Config) (*proxy, error) {
},
}

proxy := &proxy{
proxy := &Proxy{
config: cfg,
client: client,
backendUrl: backendUrl,
Expand All @@ -63,7 +62,7 @@ func NewProxy(logger *zap.Logger, cfg *Config) (*proxy, error) {
return proxy, nil
}

func (p *proxy) health(respOutWriter http.ResponseWriter, _ *http.Request) {
func (p *Proxy) health(respOutWriter http.ResponseWriter, _ *http.Request) {
respOutWriter.Header().Set("Content-Type", "text/plain")
respOutWriter.WriteHeader(200)
_, err := respOutWriter.Write([]byte("OK"))
Expand All @@ -74,73 +73,58 @@ func (p *proxy) health(respOutWriter http.ResponseWriter, _ *http.Request) {
}
}

func (p *proxy) handler(respOutWriter http.ResponseWriter, reqIn *http.Request) {
func (p *Proxy) handler(respOutWriter http.ResponseWriter, reqIn *http.Request) {

session := samlsp.SessionFromContext(reqIn.Context())
sessionClaims, ok := session.(samlsp.JWTSessionClaims)
if !ok {
p.logger.Error("session is not expected type")
respOutWriter.WriteHeader(http.StatusInternalServerError)
return
}

authUsing, authorized := p.authorized(&sessionClaims)
if !authorized {
p.logger.Debug("Responding Unauthorized")
respOutWriter.WriteHeader(http.StatusUnauthorized)
return
}

if p.config.AuthVerify && reqIn.URL.Path == p.config.AuthVerifyPath {
p.logger.
With(zap.String("remoteAddr", reqIn.RemoteAddr)).
Debug("Responding with 204 to auth verify request")
p.addHeaders(sessionClaims, respOutWriter.Header())
respOutWriter.WriteHeader(204)
return
}

resolved, err := p.backendUrl.Parse(reqIn.URL.Path)
if err != nil {
p.logger.
With(zap.String("urlPath", reqIn.URL.Path)).
With(zap.Error(err)).
Error("failed to resolve backend URL")
var reqOut *http.Request
if IsAnonymousSession(session) {
reqOut = p.setupRequest(respOutWriter, reqIn)
if reqOut == nil {
return
}

respOutWriter.WriteHeader(500)
_, _ = respOutWriter.Write([]byte(fmt.Sprintf("Failed to resolve backend URL: %s", err.Error())))
return
}
resolved.RawQuery = reqIn.URL.RawQuery
} else {
sessionClaims, ok := session.(samlsp.JWTSessionClaims)
if !ok {
p.logger.Error("session is not expected type")
respOutWriter.WriteHeader(http.StatusInternalServerError)
return
}

reqOut, err := http.NewRequest(reqIn.Method, resolved.String(), reqIn.Body)
if err != nil {
p.logger.
With(zap.String("method", reqIn.Method)).
With(zap.Any("url", reqIn.URL)).
With(zap.Error(err)).
Error("unable to create new request")
respOutWriter.WriteHeader(http.StatusInternalServerError)
return
}
authUsing, authorized := p.authorized(&sessionClaims)
if !authorized {
p.logger.Debug("Responding Unauthorized")
respOutWriter.WriteHeader(http.StatusUnauthorized)
return
}

copyHeaders(reqOut.Header, reqIn.Header)
if p.config.AuthVerify && reqIn.URL.Path == p.config.AuthVerifyPath {
p.logger.
With(zap.String("remoteAddr", reqIn.RemoteAddr)).
Debug("Responding with 204 to auth verify request")
p.addHeaders(sessionClaims, respOutWriter.Header())
respOutWriter.WriteHeader(204)
return
}

reqOut.Header.Del("Cookie")
cookies := reqIn.Cookies()
for _, cookie := range cookies {
if cookie.Name != p.config.CookieName {
reqOut.AddCookie(cookie)
reqOut = p.setupRequest(respOutWriter, reqIn)
if reqOut == nil {
return
}
}

p.checkForNewAuth(&sessionClaims)
p.checkForNewAuth(&sessionClaims)

p.addHeaders(sessionClaims, reqOut.Header)

p.addHeaders(sessionClaims, reqOut.Header)
if p.config.NameIdMapping != "" {
reqOut.Header.Set(p.config.NameIdMapping,
sessionClaims.Subject)
}

if p.config.NameIdMapping != "" {
reqOut.Header.Set(p.config.NameIdMapping,
sessionClaims.Subject)
if authUsing != "" {
reqOut.Header.Set(HeaderAuthorizedUsing, authUsing)
}
}

reqOut.Header.Set(HeaderForwardedHost, reqIn.Host)
Expand All @@ -155,9 +139,6 @@ func (p *proxy) handler(respOutWriter http.ResponseWriter, reqIn *http.Request)
}
protoParts := strings.Split(reqIn.Proto, "/")
reqOut.Header.Set(HeaderForwardedProto, strings.ToLower(protoParts[0]))
if authUsing != "" {
reqOut.Header.Set(HeaderAuthorizedUsing, authUsing)
}

respIn, err := p.client.Do(reqOut)
if err != nil {
Expand All @@ -176,7 +157,44 @@ func (p *proxy) handler(respOutWriter http.ResponseWriter, reqIn *http.Request)
}
}

func (p *proxy) addHeaders(sessionClaims samlsp.JWTSessionClaims, headers http.Header) {
func (p *Proxy) setupRequest(respOutWriter http.ResponseWriter, reqIn *http.Request) *http.Request {
resolved, err := p.backendUrl.Parse(reqIn.URL.Path)
if err != nil {
p.logger.
With(zap.String("urlPath", reqIn.URL.Path)).
With(zap.Error(err)).
Error("failed to resolve backend URL")

respOutWriter.WriteHeader(500)
_, _ = respOutWriter.Write([]byte(fmt.Sprintf("Failed to resolve backend URL: %s", err.Error())))
return nil
}
resolved.RawQuery = reqIn.URL.RawQuery

reqOut, err := http.NewRequest(reqIn.Method, resolved.String(), reqIn.Body)
if err != nil {
p.logger.
With(zap.String("method", reqIn.Method)).
With(zap.Any("url", reqIn.URL)).
With(zap.Error(err)).
Error("unable to create new request")
respOutWriter.WriteHeader(http.StatusInternalServerError)
return nil
}

copyHeaders(reqOut.Header, reqIn.Header)

reqOut.Header.Del("Cookie")
cookies := reqIn.Cookies()
for _, cookie := range cookies {
if cookie.Name != p.config.CookieName {
reqOut.AddCookie(cookie)
}
}
return reqOut
}

func (p *Proxy) addHeaders(sessionClaims samlsp.JWTSessionClaims, headers http.Header) {
if p.config.AttributeHeaderMappings != nil {
for attr, hdr := range p.config.AttributeHeaderMappings {
if values, ok := sessionClaims.GetAttributes()[attr]; ok {
Expand All @@ -196,7 +214,7 @@ func (p *proxy) addHeaders(sessionClaims samlsp.JWTSessionClaims, headers http.H
}
}

func (p *proxy) checkForNewAuth(sessionClaims *samlsp.JWTSessionClaims) {
func (p *Proxy) checkForNewAuth(sessionClaims *samlsp.JWTSessionClaims) {
if p.config.NewAuthWebhookUrl != "" && sessionClaims.IssuedAt >= time.Now().Unix()-1 {
err := p.newTokenCache.Add(sessionClaims.Id, sessionClaims, cache.DefaultExpiration)
if err == nil {
Expand Down Expand Up @@ -226,7 +244,7 @@ func (p *proxy) checkForNewAuth(sessionClaims *samlsp.JWTSessionClaims) {
// authorized returns a boolean indication if the request is authorized.
// The initial string return value is an attribute=value pair that was used to authorize the request.
// If authorization was not configured the returned string is empty.
func (p *proxy) authorized(sessionClaims *samlsp.JWTSessionClaims) (string, bool) {
func (p *Proxy) authorized(sessionClaims *samlsp.JWTSessionClaims) (string, bool) {
if p.config.AuthorizeAttribute != "" {
values, exists := sessionClaims.GetAttributes()[p.config.AuthorizeAttribute]
if !exists {
Expand Down
7 changes: 6 additions & 1 deletion server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,12 @@ func Start(ctx context.Context, logger *zap.Logger, cfg *Config) error {
cookieSessionProvider.Name = cfg.CookieName
cookieSessionProvider.Domain = cookieDomain
cookieSessionProvider.MaxAge = cfg.CookieMaxAge
middleware.Session = cookieSessionProvider

if cfg.InitiateSessionPath != "" {
middleware.Session = NewInitAnonymousSessionProvider(logger, cfg.InitiateSessionPath, cookieSessionProvider)
} else {
middleware.Session = cookieSessionProvider
}

proxy, err := NewProxy(logger, cfg)
if err != nil {
Expand Down
20 changes: 20 additions & 0 deletions test/grafana-public-dashboards/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@

## Setup

Create the proxy's cert and key files [like in the README](../../README.md#trying-it-out)

Bring up the services setting the `BASE_URL` to the publicly resolvable URL of your service:

```shell
BASE_URL=... docker compose up -d --build
```

Export and upload the IDP metadata [like in the README](../../README.md#trying-it-out)

Access Grafana via the proxy at <http://localhost:8080>

Login as Rick via `samltest.idp` since the test configures that user as admin.

Go to the pre-provisioned dashboard at the path `/d/c6f2205a-a683-417f-b177-b916085d5519/public?orgId=1`, [make it public](https://grafana.com/docs/grafana/latest/dashboards/dashboard-public/#make-a-dashboard-public), and copy the public dashboard link.

Open an incognito tab (or equivalent) and confirm access to the public dashboard without login. Go to some other path like `/` and confirm that you are redirected to login via SAML auth.
Loading

0 comments on commit 87d1db2

Please sign in to comment.