Skip to content

Commit

Permalink
Merge pull request #265 from avenga/saml-oidc-relative-urls
Browse files Browse the repository at this point in the history
SAML and OAuth2/OIDC AC with relative callback URLs
  • Loading branch information
Marcel Ludwig authored Aug 3, 2021
2 parents 52afd66 + c2a1a89 commit 7e9ef89
Show file tree
Hide file tree
Showing 16 changed files with 172 additions and 23 deletions.
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ Unreleased changes are available as `avenga/couper:edge` container.
* Default values for environment variable by means of `environment_variables` within `defaults` block. ([#271](https://github.com/avenga/couper/pull/271))
* `protocol`, `host`, `port`, `origin`, `body`, `json_body` to [`backend_requests`](./docs/REFERENCE.md#backend_requests) ([#278](https://github.com/avenga/couper/pull/278))

* **Changed**
* The `sp_acs_url` in the [SAML Block](./docs/REFERENCE.md#saml-block) may now be relative ([#265](https://github.com/avenga/couper/pull/265))

* **Fixed**
* No GZIP compression for small response bodies ([#186](https://github.com/avenga/couper/issues/186))
* Missing error type for [request](docs/REFERENCE.md#request-block)/[response](docs/REFERENCE.md#response-block) body, json_body or form_body related HCL evaluation errors ([#276](https://github.com/avenga/couper/pull/276))
Expand Down
9 changes: 9 additions & 0 deletions accesscontrol/saml2.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import (

"github.com/avenga/couper/config/request"
"github.com/avenga/couper/errors"
"github.com/avenga/couper/eval"
"github.com/avenga/couper/eval/lib"
)

type Saml2 struct {
Expand Down Expand Up @@ -80,6 +82,13 @@ func (s *Saml2) Validate(req *http.Request) error {
return err
}

origin := eval.NewRawOrigin(req.URL)
absAcsUrl, err := lib.AbsoluteURL(s.sp.AssertionConsumerServiceURL, origin)
if err != nil {
return err
}
s.sp.AssertionConsumerServiceURL = absAcsUrl

encodedResponse := req.FormValue("SAMLResponse")
req.ContentLength = 0

Expand Down
6 changes: 3 additions & 3 deletions docs/REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -371,7 +371,7 @@ Like all [Access Control](#access-control) types, the `beta_oauth2` block is def
| `authorization_endpoint` | string |-| The authorization server endpoint URL used for authorization. |⚠ required|-|
| `token_endpoint` | string |-| The authorization server endpoint URL used for requesting the token. |⚠ required|-|
| `token_endpoint_auth_method` |string|`client_secret_basic`|Defines the method to authenticate the client at the token endpoint.|If set to `client_secret_post`, the client credentials are transported in the request body. If set to `client_secret_basic`, the client credentials are transported via Basic Authentication.|-|
| `redirect_uri` | string |-| The Couper endpoint for receiving the authorization code. |⚠ required|-|
| `redirect_uri` | string |-| The Couper endpoint for receiving the authorization code. |⚠ required. Relative URL references are resolved against the origin of the current request URL.|-|
| `grant_type` |string|-| The grant type. |⚠ required, to be set to: `authorization_code`|`grant_type = "authorization_code"`|
| `client_id`| string|-|The client identifier.|⚠ required|-|
| `client_secret` |string|-|The client password.|⚠ required.|-|
Expand All @@ -397,7 +397,7 @@ Like all [Access Control](#access-control) types, the `beta_oidc` block is defin
| `configuration_url` | string |-| The OpenID configuration URL. |⚠ required|-|
| `ttl` | duration |-| The duration to cache the OpenID configuration located at `configuration_url`. |⚠ required| `ttl = "1d"` |
| `token_endpoint_auth_method` |string|`client_secret_basic`|Defines the method to authenticate the client at the token endpoint.|If set to `client_secret_post`, the client credentials are transported in the request body. If set to `client_secret_basic`, the client credentials are transported via Basic Authentication.|-|
| `redirect_uri` | string |-| The Couper endpoint for receiving the authorization code. |⚠ required|-|
| `redirect_uri` | string |-| The Couper endpoint for receiving the authorization code. |⚠ required. Relative URL references are resolved against the origin of the current request URL.|-|
| `client_id`| string|-|The client identifier.|⚠ required|-|
| `client_secret` |string|-|The client password.|⚠ required.|-|
| `scope` |string|-| A space separated list of requested scopes for the access token.|`openid` is automatically added.| `scope = "profile read"` |
Expand All @@ -421,7 +421,7 @@ required _label_.
| Attribute(s) | Type |Default|Description|Characteristic(s)| Example|
| :------------------------------ | :--------------- | :--------------- | :--------------- | :--------------- | :--------------- |
|`idp_metadata_file`|string|-|File reference to the Identity Provider metadata XML file.|⚠ required|-|
|`sp_acs_url` |string|-|The URL of the Service Provider's ACS endpoint.|⚠ required|-|
|`sp_acs_url` |string|-|The URL of the Service Provider's ACS endpoint.|⚠ required. Relative URL references are resolved against the origin of the current request URL.|-|
| `sp_entity_id` |string|-|The Service Provider's entity ID.|⚠ required|-|
| `array_attributes`|string|-|A list of assertion attributes that may have several values.|-|-|

Expand Down
27 changes: 16 additions & 11 deletions eval/context.go
Original file line number Diff line number Diff line change
Expand Up @@ -107,8 +107,6 @@ func (c *Context) WithClientRequest(req *http.Request) *Context {
saml: c.saml[:],
}

ctx.createOAuth2Functions()

if rc := req.Context(); rc != nil {
ctx.inner = context.WithValue(rc, ContextType, ctx)
}
Expand Down Expand Up @@ -138,12 +136,14 @@ func (c *Context) WithClientRequest(req *http.Request) *Context {
}
port, _ := strconv.ParseInt(p, 10, 64)
body, jsonBody := parseReqBody(req)

origin := NewRawOrigin(req.URL)
ctx.eval.Variables[ClientRequest] = cty.ObjectVal(ctxMap.Merge(ContextMap{
ID: cty.StringVal(id),
Method: cty.StringVal(req.Method),
PathParam: seetie.MapToValue(pathParams),
URL: cty.StringVal(req.URL.String()),
Origin: cty.StringVal(newRawOrigin(req.URL).String()),
Origin: cty.StringVal(origin.String()),
Protocol: cty.StringVal(req.URL.Scheme),
Host: cty.StringVal(req.URL.Hostname()),
Port: cty.NumberIntVal(port),
Expand All @@ -154,6 +154,8 @@ func (c *Context) WithClientRequest(req *http.Request) *Context {
FormBody: seetie.ValuesMapToValue(parseForm(req).PostForm),
}.Merge(newVariable(ctx.inner, req.Cookies(), req.Header))))

ctx.createClientRequestRelatedFunctions(origin)

updateFunctions(ctx)

return ctx
Expand Down Expand Up @@ -196,7 +198,7 @@ func (c *Context) WithBeresps(beresps ...*http.Response) *Context {
bereqs[name] = cty.ObjectVal(ContextMap{
Method: cty.StringVal(bereq.Method),
URL: cty.StringVal(bereq.URL.String()),
Origin: cty.StringVal(newRawOrigin(bereq.URL).String()),
Origin: cty.StringVal(NewRawOrigin(bereq.URL).String()),
Protocol: cty.StringVal(bereq.URL.Scheme),
Host: cty.StringVal(bereq.URL.Hostname()),
Port: cty.NumberIntVal(port),
Expand Down Expand Up @@ -258,29 +260,32 @@ func (c *Context) WithOidcConfig(os map[string]*oidc.OidcConfig) *Context {
return c
}

// WithSAML initially setup the lib.FnSamlSsoUrl function.
// WithSAML initially setup the saml configuration.
func (c *Context) WithSAML(s []*config.SAML) *Context {
c.saml = s
if c.saml == nil {
c.saml = make([]*config.SAML, 0)
}
samlfn := lib.NewSamlSsoUrlFunction(c.saml)
c.eval.Functions[lib.FnSamlSsoUrl] = samlfn
return c
}

func (c *Context) HCLContext() *hcl.EvalContext {
return c.eval
}

// createOAuth2Functions creates the listed OAuth2 functions for the client request context.
func (c *Context) createOAuth2Functions() {
// createClientRequestRelatedFunctions creates the listed functions for the client request context.
func (c *Context) createClientRequestRelatedFunctions(origin *url.URL) {
if c.oauth2 != nil {
oauth2fn := lib.NewOAuthAuthorizationUrlFunction(c.oauth2, c.getCodeVerifier)
oauth2fn := lib.NewOAuthAuthorizationUrlFunction(c.oauth2, c.getCodeVerifier, origin)
c.eval.Functions[lib.FnOAuthAuthorizationUrl] = oauth2fn
}
c.eval.Functions[lib.FnOAuthVerifier] = lib.NewOAuthCodeVerifierFunction(c.getCodeVerifier)
c.eval.Functions[lib.InternalFnOAuthHashedVerifier] = lib.NewOAuthCodeChallengeFunction(c.getCodeVerifier)

if c.saml != nil {
samlfn := lib.NewSamlSsoUrlFunction(c.saml, origin)
c.eval.Functions[lib.FnSamlSsoUrl] = samlfn
}
}

func (c *Context) getCodeVerifier() (*pkce.CodeVerifier, error) {
Expand Down Expand Up @@ -380,7 +385,7 @@ func parseJSONBytes(b []byte) cty.Value {
return val
}

func newRawOrigin(u *url.URL) *url.URL {
func NewRawOrigin(u *url.URL) *url.URL {
rawOrigin := *u
rawOrigin.Path = ""
rawOrigin.RawQuery = ""
Expand Down
8 changes: 6 additions & 2 deletions eval/lib/oauth2.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ const (
CodeVerifier = "code_verifier"
)

func NewOAuthAuthorizationUrlFunction(oauth2Configs []config.OAuth2Authorization, verifier func() (*pkce.CodeVerifier, error)) function.Function {
func NewOAuthAuthorizationUrlFunction(oauth2Configs []config.OAuth2Authorization, verifier func() (*pkce.CodeVerifier, error), origin *url.URL) function.Function {
oauth2s := make(map[string]config.OAuth2Authorization)
for _, o := range oauth2Configs {
oauth2s[o.GetName()] = o
Expand Down Expand Up @@ -47,7 +47,11 @@ func NewOAuthAuthorizationUrlFunction(oauth2Configs []config.OAuth2Authorization
query := oauthAuthorizationUrl.Query()
query.Set("response_type", "code")
query.Set("client_id", oauth2.GetClientID())
query.Set("redirect_uri", oauth2.GetRedirectURI())
absRedirectUri, err := AbsoluteURL(oauth2.GetRedirectURI(), origin)
if err != nil {
return cty.StringVal(""), err
}
query.Set("redirect_uri", absRedirectUri)
if scope := oauth2.GetScope(); scope != "" {
query.Set("scope", scope)
}
Expand Down
10 changes: 8 additions & 2 deletions eval/lib/saml.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package lib
import (
"encoding/xml"
"fmt"
"net/url"

saml2 "github.com/russellhaering/gosaml2"
"github.com/russellhaering/gosaml2/types"
Expand All @@ -17,7 +18,7 @@ const (
NameIdFormatUnspecified = "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified"
)

func NewSamlSsoUrlFunction(configs []*config.SAML) function.Function {
func NewSamlSsoUrlFunction(configs []*config.SAML, origin *url.URL) function.Function {
type entity struct {
config *config.SAML
descriptor *types.EntityDescriptor
Expand Down Expand Up @@ -68,8 +69,13 @@ func NewSamlSsoUrlFunction(configs []*config.SAML) function.Function {

nameIDFormat := getNameIDFormat(metadata.IDPSSODescriptor.NameIDFormats)

absAcsUrl, err := AbsoluteURL(ent.config.SpAcsUrl, origin)
if err != nil {
return cty.StringVal(""), err
}

sp := &saml2.SAMLServiceProvider{
AssertionConsumerServiceURL: ent.config.SpAcsUrl,
AssertionConsumerServiceURL: absAcsUrl,
IdentityProviderSSOURL: ssoUrl,
ServiceProviderIssuer: ent.config.SpEntityId,
SignAuthnRequests: false,
Expand Down
8 changes: 6 additions & 2 deletions eval/lib/saml_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"encoding/base64"
"encoding/xml"
"io"
"net/http"
"net/url"
"strings"
"testing"
Expand Down Expand Up @@ -92,9 +93,12 @@ func Test_SamlSsoUrl(t *testing.T) {
h.Must(err)
}

hclContext := cf.Context.Value(eval.ContextType).(*eval.Context).HCLContext()
evalContext := cf.Context.Value(eval.ContextType).(*eval.Context)
req, err := http.NewRequest(http.MethodGet, "https://www.example.com/foo", nil)
h.Must(err)
evalContext = evalContext.WithClientRequest(req)

ssoUrl, err := hclContext.Functions[lib.FnSamlSsoUrl].Call([]cty.Value{cty.StringVal(tt.samlLabel)})
ssoUrl, err := evalContext.HCLContext().Functions[lib.FnSamlSsoUrl].Call([]cty.Value{cty.StringVal(tt.samlLabel)})
if err == nil && tt.wantErr {
st.Fatal("Error expected")
}
Expand Down
12 changes: 12 additions & 0 deletions eval/lib/url.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,15 @@ func newUrlEncodeFunction() function.Function {
},
})
}

func AbsoluteURL(urlRef string, origin *url.URL) (string, error) {
u, err := url.Parse(urlRef)
if err != nil {
return "", err
}

if !u.IsAbs() {
return origin.ResolveReference(u).String(), nil
}
return urlRef, nil
}
10 changes: 7 additions & 3 deletions oauth2/oauth2.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import (
"github.com/avenga/couper/config/request"
"github.com/avenga/couper/errors"
"github.com/avenga/couper/eval"
"github.com/avenga/couper/eval/lib"
"github.com/avenga/couper/internal/seetie"
)

Expand Down Expand Up @@ -71,9 +72,6 @@ func (c *Client) newTokenRequest(ctx context.Context, requestParams map[string]s
if scope := c.clientConfig.GetScope(); scope != "" && grantType != "authorization_code" {
post.Set("scope", scope)
}
if acClientConfig, ok := c.clientConfig.(config.OAuth2AcClient); ok && grantType == "authorization_code" {
post.Set("redirect_uri", acClientConfig.GetRedirectURI())
}
if requestParams != nil {
for key, value := range requestParams {
post.Set(key, value)
Expand Down Expand Up @@ -163,6 +161,12 @@ func (a AbstractAcClient) GetTokenResponse(ctx context.Context, callbackURL *url
}

requestParams := map[string]string{"code": code}
origin := eval.NewRawOrigin(callbackURL)
absRedirectUri, err := lib.AbsoluteURL(a.getAcClientConfig().GetRedirectURI(), origin)
if err != nil {
return nil, nil, "", err
}
requestParams["redirect_uri"] = absRedirectUri

evalContext, _ := ctx.Value(eval.ContextType).(*eval.Context)

Expand Down
12 changes: 12 additions & 0 deletions server/http_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3265,6 +3265,12 @@ func TestOAuthPKCEFunctions(t *testing.T) {
if auq.Get("client_id") != "foo" {
t.Errorf("beta_oauth_authorization_url(): wrong client_id:\nactual:\t\t%s\nexpected:\t%s", auq.Get("client_id"), "foo")
}
au, err = url.Parse(res.Header.Get("x-au-pkce-rel"))
helper.Must(err)
auq = au.Query()
if auq.Get("redirect_uri") != "http://example.com:8080/oidc/callback" {
t.Errorf("oauth_authorization_url(): wrong redirect_uri query param:\nactual:\t\t%s\nexpected:\t%s", auq.Get("redirect_uri"), "http://example.com:8080/oidc/callback")
}

req, err = http.NewRequest(http.MethodGet, "http://example.com:8080/pkce", nil)
helper.Must(err)
Expand Down Expand Up @@ -3390,6 +3396,12 @@ func TestOIDCPKCEFunctions(t *testing.T) {
if auq.Get("client_id") != "foo" {
t.Errorf("beta_oauth_authorization_url(): wrong client_id:\nactual:\t\t%s\nexpected:\t%s", auq.Get("client_id"), "foo")
}
au, err = url.Parse(res.Header.Get("x-au-pkce-rel"))
helper.Must(err)
auq = au.Query()
if auq.Get("redirect_uri") != "http://example.com:8080/oidc/callback" {
t.Errorf("oauth_authorization_url(): wrong redirect_uri query param:\nactual:\t\t%s\nexpected:\t%s", auq.Get("redirect_uri"), "http://example.com:8080/oidc/callback")
}
}

func TestOIDCNonceFunctions(t *testing.T) {
Expand Down
2 changes: 2 additions & 0 deletions server/http_oauth2_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -327,6 +327,8 @@ func TestOAuth2AccessControl(t *testing.T) {
{"code; client_secret_post", "05_couper.hcl", "/cb?code=qeuboub", http.Header{"Cookie": []string{"pkcecv=qerbnr"}}, http.StatusOK, "client_id=foo&client_secret=etbinbp4in&code=qeuboub&code_verifier=qerbnr&grant_type=authorization_code&redirect_uri=http%3A%2F%2Flocalhost%3A8080%2Fcb", "", ""},
{"code, state param", "06_couper.hcl", "/cb?code=qeuboub&state=" + state, http.Header{"Cookie": []string{"st=" + st}}, http.StatusOK, "code=qeuboub&grant_type=authorization_code&redirect_uri=http%3A%2F%2Flocalhost%3A8080%2Fcb", "Basic Zm9vOmV0YmluYnA0aW4=", ""},
{"code, nonce param", "07_couper.hcl", "/cb?code=qeuboub-id", http.Header{"Cookie": []string{"nnc=" + st}}, http.StatusOK, "code=qeuboub-id&grant_type=authorization_code&redirect_uri=http%3A%2F%2Flocalhost%3A8080%2Fcb", "Basic Zm9vOmV0YmluYnA0aW4=", ""},
{"code; client_secret_basic; PKCE; relative redirect_uri", "08_couper.hcl", "/cb?code=qeuboub", http.Header{"Cookie": []string{"pkcecv=qerbnr"}, "X-Forwarded-Proto": []string{"https"}, "X-Forwarded-Host": []string{"www.example.com"}}, http.StatusOK, "code=qeuboub&code_verifier=qerbnr&grant_type=authorization_code&redirect_uri=https%3A%2F%2Fwww.example.com%2Fcb", "Basic Zm9vOmV0YmluYnA0aW4=", ""},
{"code; nonce param; relative redirect_uri", "09_couper.hcl", "/cb?code=qeuboub-id", http.Header{"Cookie": []string{"nnc=" + st}, "X-Forwarded-Proto": []string{"https"}, "X-Forwarded-Host": []string{"www.example.com"}}, http.StatusOK, "code=qeuboub-id&grant_type=authorization_code&redirect_uri=https%3A%2F%2Fwww.example.com%2Fcb", "Basic Zm9vOmV0YmluYnA0aW4=", ""},
} {
t.Run(tc.path[1:], func(subT *testing.T) {
shutdown, hook := newCouperWithTemplate("testdata/oauth2/"+tc.filename, test.New(t), map[string]interface{}{"asOrigin": oauthOrigin.URL})
Expand Down
16 changes: 16 additions & 0 deletions server/testdata/integration/functions/02_couper.hcl
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,11 @@ server "oauth-functions" {
x-v-2 = beta_oauth_verifier()
x-hv = internal_oauth_hashed_verifier()
x-au-pkce = beta_oauth_authorization_url("ac-pkce")
x-au-pkce-rel = beta_oauth_authorization_url("ac-pkce-relative")
}
}
}

endpoint "/csrf" {
response {
headers = {
Expand All @@ -18,6 +20,7 @@ server "oauth-functions" {
}
}
}

definitions {
beta_oauth2 "ac-pkce" {
grant_type = "authorization_code"
Expand All @@ -30,6 +33,19 @@ definitions {
verifier_method = "ccm_s256"
verifier_value = "not_used_here"
}

beta_oauth2 "ac-pkce-relative" {
grant_type = "authorization_code"
authorization_endpoint = "https://authorization.server/oauth/authorize"
scope = "openid profile email"
token_endpoint = "https://authorization.server/oauth/token"
redirect_uri = "/oidc/callback"
client_id = "foo"
client_secret = "5eCr3t"
verifier_method = "ccm_s256"
verifier_value = "not_used_here"
}

beta_oauth2 "ac-state" {
grant_type = "authorization_code"
authorization_endpoint = "https://authorization.server/oauth/authorize"
Expand Down
Loading

0 comments on commit 7e9ef89

Please sign in to comment.