Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

OAuth2 Authorization Code Grant Flow #247

Merged
merged 79 commits into from
Jun 25, 2021
Merged
Show file tree
Hide file tree
Changes from 61 commits
Commits
Show all changes
79 commits
Select commit Hold shift + click to select a range
5b3debe
Added OAuth2 PKCE functions: oauth_code_verifier() and oauth_code_cha…
May 21, 2021
d1890b8
Refactor: use memorize map instead of inner context
May 26, 2021
2bd1105
copy memorize map when creating new Contexts
May 26, 2021
3f61b3c
attach updateFunctions() to struct
May 26, 2021
9355148
memoize code verifier, pass memoizing function to cty function factories
May 26, 2021
e06f492
Refactored OAuth2: split functions into a) OAuth2 (the token requeste…
May 25, 2021
e48c31d
Renamed OAuth2Credentials -> OAuth2RequestConfig (meanwhile it's more…
May 25, 2021
8191aeb
Fixup memorize map initialization
May 26, 2021
8dc904b
oauth2 access control
May 26, 2021
fc2adb3
corrected interface comments
May 27, 2021
bd277bb
renamed config.OAuth2 struct to config.OAuthReqAuth
May 27, 2021
bf1ea47
moved config.OAuth2RA
May 27, 2021
3e16172
moved config.OAuth2Config to its own file
May 27, 2021
1abbb7f
renamed config.OAuth2Config to config.OAuth2
May 27, 2021
fa0de85
use GrantType instead of GetGrantType()
May 27, 2021
a604a3c
more/better error messages
May 27, 2021
b98cbd9
added code to token request
May 27, 2021
a06386f
create backend for token request earlier
May 27, 2021
b92e63c
error handling for OAuth2AC
May 27, 2021
56dba1d
Test for OAuth2 access control
May 27, 2021
2db8a44
renamed error type OAuth2 to Oauth2
May 28, 2021
bb598d9
better handling of OAuth2 token request errors
May 28, 2021
4313714
reuse token response parsing code
May 28, 2021
08be83f
Added functions oauth_csrf_token() and oauth_hashed_csrf_token(); val…
May 28, 2021
80927e5
More configuration properties "static"
May 31, 2021
0cfc583
Use sync.Map for memorize
May 31, 2021
3b56ac1
More conf properties; checks for conf properties
May 31, 2021
3d9f680
Added oauth_authorization_url() function; documentation
May 31, 2021
6f307b6
Re-introduce regular map for memorize instead of sync.Map
May 31, 2021
ead1a5f
Added scope check to tests
Jun 2, 2021
af67678
Added placeholders for OIDC additions
Jun 4, 2021
55fe390
expires_in is only RECOMMENDED, not REQUIRED; centralize token respon…
Jun 7, 2021
49ac377
Added userinfo request, added validation of ID token claims
Jun 7, 2021
dabeb37
Added nonce handling
Jun 8, 2021
7930a7e
Don't send scope in token request for authorization_code grant; it wa…
Jun 8, 2021
3b57763
Added test for nonce handling; more tests for state handling
Jun 8, 2021
2f211c4
Renamed helper header; it's not the token endpoint or URL
Jun 8, 2021
163d4d0
Added azp claim checks
Jun 9, 2021
0d1c4e7
Removed userinfo request, not needed for authorization code grant flow
Jun 9, 2021
2d70591
Added JWKS (RSA public key only), signature validation
Jun 9, 2021
dd49ed3
Added beta prefixes
Jun 10, 2021
4788544
Removed documentation of probably unnecessary functions
Jun 10, 2021
68abb7d
Added base64url Encode() and Decode() functions in utils
Jun 10, 2021
66e720a
Moved issuer and audience option to initial slice
Jun 10, 2021
3e02505
pkce and csrf blocks
Jun 11, 2021
68943d3
Added test for authorization URL with nonce
Jun 11, 2021
d873e02
store id token claims in id_token_claims, id token string in id_token
Jun 16, 2021
2da045b
Use existing go impl of base64url encode and decode
Jun 17, 2021
a889b62
No collision of package and variable name
Jun 17, 2021
94c0d30
No snake case
Jun 17, 2021
0679c7d
evaluation error instead of oauth2 error when parsing pkce or csrf block
Jun 17, 2021
eedc713
better and earlier report config problems
Jun 17, 2021
448feea
jwks.Key() -> jwks.GetKeys()
Jun 17, 2021
f93a627
recreate functions less often
Jun 17, 2021
02f84e6
Added test cases for wrong iss/aud claims in id token
Jun 22, 2021
50b1238
Added changelog entry
Jun 22, 2021
892eaf4
Added more documentation, cross-references
Jun 22, 2021
c483d0b
Removed JWKS handling
Jun 23, 2021
eea95d7
Added more id token claims checks (existing sub, exp, iat) and tests
Jun 23, 2021
7e01e1e
Revert "Removed userinfo request, not needed for authorization code g…
Jun 23, 2021
2376abc
Removed duplicate check for existing sub claim; added test for sub mi…
Jun 23, 2021
339f7cd
rename function also in comment
johakoch Jun 24, 2021
56f4fe5
Create and use subtest helper
Jun 24, 2021
0288efa
some more Messagef() -> Message()
Jun 24, 2021
a2af6ba
remove unnecessary comments
Jun 24, 2021
5436e22
use %q instead of '%s'
Jun 24, 2021
675d3d8
Base64url_s256 -> Base64urlSha256
Jun 24, 2021
dd39adf
Context now first argument
Jun 24, 2021
bbfd584
Merge branch 'master' into oauth2-ac
johakoch Jun 24, 2021
f447120
Corrected indentation
Jun 24, 2021
af49068
Add test helper for template couper configurations
Jun 24, 2021
39f0bc1
make userinfo_endpoint static
Jun 24, 2021
e4db466
Added docu for userinfo_endpoint
Jun 24, 2021
d20a4d8
make token_endpoint static; and mandatory for OAuth2 AC flow
Jun 24, 2021
d0e1475
set resource server origin via templating
Jun 24, 2021
3495f71
store userinfo in request.context.<label>.userinfo because it maybe d…
Jun 24, 2021
99c6288
Merge branch 'master' into oauth2-ac
Jun 25, 2021
8c22818
some docu corrections/additions
Jun 25, 2021
883addc
Added note about not disabling certificate validation
Jun 25, 2021
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@

Unreleased changes are available as `avenga/couper:edge` container.

* **Added**
* OAuth2 Authorization Code Grant Flow: `beta_oauth2 {}` block; `beta_oauth_authorization_url()`, `beta_oauth_code_verifier()` and `beta_oauth_csrf_token()` functions ([#247](https://github.com/avenga/couper/pull/247))

* **Changed**
* `Error` log-level for upstream responses with status `500` to `Info` log-level ([#258](https://github.com/avenga/couper/pull/258))

Expand Down
332 changes: 332 additions & 0 deletions accesscontrol/oauth2.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,332 @@
package accesscontrol

import (
"context"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"io/ioutil"
"net/http"
"strings"
"time"

"github.com/dgrijalva/jwt-go/v4"

"github.com/avenga/couper/config"
"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/handler/transport"
"github.com/avenga/couper/internal/seetie"
)

var _ AccessControl = &OAuth2Callback{}

type OAuth2Callback struct {
config *config.OAuth2AC
oauth2 *transport.OAuth2
jwtParser *jwt.Parser
}

// NewOAuth2Callback creates a new AC-OAuth2 object
func NewOAuth2Callback(conf *config.OAuth2AC, oauth2 *transport.OAuth2) (*OAuth2Callback, error) {
confErr := errors.Configuration.Label(conf.Name)

const grantType = "authorization_code"
if conf.GrantType != grantType {
return nil, confErr.Messagef("grant_type %s not supported", conf.GrantType)
}
if conf.Pkce == nil && conf.Csrf == nil {
return nil, confErr.Message("CSRF protection not configured")
}
if conf.Csrf != nil {
if conf.Csrf.TokenParam != "state" && conf.Csrf.TokenParam != "nonce" {
return nil, confErr.Messagef("csrf_token_param %s not supported", conf.Csrf.TokenParam)
}
content, _, diags := conf.Csrf.HCLBody().PartialContent(conf.Csrf.Schema(true))
if diags.HasErrors() {
return nil, errors.Evaluation.With(diags)
}
conf.Csrf.Content = content
}
if conf.Pkce != nil {
if conf.Pkce.CodeChallengeMethod != lib.CcmPlain && conf.Pkce.CodeChallengeMethod != lib.CcmS256 {
return nil, confErr.Messagef("code_challenge_method %s not supported", conf.Pkce.CodeChallengeMethod)
}
content, _, diags := conf.Pkce.HCLBody().PartialContent(conf.Pkce.Schema(true))
if diags.HasErrors() {
return nil, errors.Evaluation.With(diags)
}
conf.Pkce.Content = content
}

options := []jwt.ParserOption{
// jwt.WithValidMethods([]string{algo.String()}),
jwt.WithLeeway(time.Second),
// 2. The Issuer Identifier for the OpenID Provider (which is typically
// obtained during Discovery) MUST exactly match the value of the iss
// (issuer) Claim.
jwt.WithIssuer(conf.Issuer),
// 3. The Client MUST validate that the aud (audience) Claim contains its
// client_id value registered at the Issuer identified by the iss
// (issuer) Claim as an audience. The aud (audience) Claim MAY contain
// an array with more than one element. The ID Token MUST be rejected if
// the ID Token does not list the Client as a valid audience, or if it
// contains additional audiences not trusted by the Client.
jwt.WithAudience(conf.ClientID),
}
jwtParser := jwt.NewParser(options...)

return &OAuth2Callback{
config: conf,
jwtParser: jwtParser,
oauth2: oauth2,
}, nil
}

// Validate implements the AccessControl interface
func (oa *OAuth2Callback) Validate(req *http.Request) error {
if req.Method != http.MethodGet {
return errors.Oauth2.Messagef("wrong method: %s", req.Method)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This applies to all Oauth2 errors, could we add more context e.g. oa.conf.Name as .Label(...) ?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should the caller give the context? Or the callee?

}

query := req.URL.Query()
code := query.Get("code")
if code == "" {
return errors.Oauth2.Messagef("missing code query parameter; query='%s'", req.URL.RawQuery)
malud marked this conversation as resolved.
Show resolved Hide resolved
}

requestConfig, err := oa.oauth2.GetRequestConfig(req)
if err != nil {
return errors.Oauth2.With(err)
}

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

if oa.config.Pkce != nil {
v, _ := oa.config.Pkce.Content.Attributes["code_verifier_value"]
ctyVal, _ := v.Expr.Value(evalContext.HCLContext())
codeVerifierValue := strings.TrimSpace(seetie.ValueToString(ctyVal))
if codeVerifierValue == "" {
return errors.Oauth2.Messagef("Empty PKCE code_verifier_value")
}
requestConfig.CodeVerifier = &codeVerifierValue
}

var csrfToken, csrfTokenValue string
if oa.config.Csrf != nil {
v, _ := oa.config.Csrf.Content.Attributes["token_value"]
ctyVal, _ := v.Expr.Value(evalContext.HCLContext())
csrfTokenValue = strings.TrimSpace(seetie.ValueToString(ctyVal))
if csrfTokenValue == "" {
return errors.Oauth2.Messagef("Empty CSRF token_value")
}
csrfToken = Base64url_s256(csrfTokenValue)

// validate state param value against CSRF token
if oa.config.Csrf.TokenParam == "state" {
csrfTokenFromParam := query.Get(oa.config.Csrf.TokenParam)
if csrfTokenFromParam == "" {
return errors.Oauth2.Messagef("missing state query parameter; query='%s'", req.URL.RawQuery)
}

if csrfToken != csrfTokenFromParam {
return errors.Oauth2.Messagef("CSRF token mismatch: '%s' (from query param) vs. '%s' (s256: '%s')", csrfTokenFromParam, csrfTokenValue, csrfToken)
johakoch marked this conversation as resolved.
Show resolved Hide resolved
}
}
}

requestConfig.Code = &code
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe this could be a task (read/assign query) for GetRequestConfig since the method argument is the request object (currently not used).

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As GetRequestConfig() is used by both accesscontrol.OAuth2Callback and transport.OAuth2ReqAuth and this is specific to the AC flow, I would rather keep it here.

requestConfig.RedirectURI = oa.config.RedirectURI

tokenResponse, err := oa.oauth2.RequestToken(req.Context(), requestConfig)
if err != nil {
return errors.Oauth2.Message("requesting token failed").With(err)
}

tokenData, accessToken, err := transport.ParseAccessToken(tokenResponse)
if err != nil {
return errors.Oauth2.Messagef("parsing token response JSON failed, response='%s'", string(tokenResponse)).With(err)
}

ctx := req.Context()
if idTokenString, ok := tokenData["id_token"].(string); ok {
idToken, _, err := oa.jwtParser.ParseUnverified(idTokenString, jwt.MapClaims{})
if err != nil {
return err
}

// 2. ID Token
// iss
// REQUIRED.
// aud
// REQUIRED.
// 3.1.3.7. ID Token Validation
// 3. The Client MUST validate that the aud (audience) Claim contains
// its client_id value registered at the Issuer identified by the
// iss (issuer) Claim as an audience. The aud (audience) Claim MAY
// contain an array with more than one element. The ID Token MUST
// be rejected if the ID Token does not list the Client as a valid
// audience, or if it contains additional audiences not trusted by
// the Client.
if err := idToken.Claims.Valid(oa.jwtParser.ValidationHelper); err != nil {
return err
}

idtc, err := oa.validateIdTokenClaims(idToken.Claims, csrfToken, csrfTokenValue, ctx, accessToken)
if err != nil {
return err
}

tokenData["id_token_claims"] = idtc
}

acMap, ok := ctx.Value(request.AccessControls).(map[string]interface{})
if !ok {
acMap = make(map[string]interface{})
}
acMap[oa.config.Name] = tokenData
ctx = context.WithValue(ctx, request.AccessControls, acMap)
*req = *req.WithContext(ctx)

return nil
}

func (oa *OAuth2Callback) validateIdTokenClaims(claims jwt.Claims, csrfToken, csrfTokenValue string, ctx context.Context, accessToken string) (map[string]interface{}, error) {
johakoch marked this conversation as resolved.
Show resolved Hide resolved
var idTokenClaims jwt.MapClaims
if tc, ok := claims.(jwt.MapClaims); ok {
idTokenClaims = tc
}

// 2. ID Token
// exp
// REQUIRED.
if _, expExists := idTokenClaims["exp"]; !expExists {
return nil, errors.Oauth2.Messagef("missing exp claim in ID token, claims='%#v'", idTokenClaims)
}
// iat
// REQUIRED.
if _, iatExists := idTokenClaims["iat"]; !iatExists {
return nil, errors.Oauth2.Messagef("missing iat claim in ID token, claims='%#v'", idTokenClaims)
}

// 3.1.3.7. ID Token Validation
// 4. If the ID Token contains multiple audiences, the Client SHOULD verify
// that an azp Claim is present.
azp, azpExists := idTokenClaims["azp"]
if auds, audsOK := idTokenClaims["aud"].([]interface{}); audsOK && len(auds) > 1 && !azpExists {
return nil, errors.Oauth2.Messagef("missing azp claim in ID token, claims='%#v'", idTokenClaims)
}
// 5. If an azp (authorized party) Claim is present, the Client SHOULD
// verify that its client_id is the Claim Value.
if azpExists && azp != oa.config.ClientID {
return nil, errors.Oauth2.Messagef("azp claim / client ID mismatch, azp = '%s', client ID = '%s'", azp, oa.config.ClientID)
}

// validate nonce claim value against CSRF token
if oa.config.Csrf != nil && oa.config.Csrf.TokenParam == "nonce" {
// 11. If a nonce value was sent in the Authentication Request, a nonce
// Claim MUST be present and its value checked to verify that it is the
// same value as the one that was sent in the Authentication Request.
// The Client SHOULD check the nonce value for replay attacks. The
// precise method for detecting replay attacks is Client specific.
var nonce string
if n, ok := idTokenClaims["nonce"].(string); ok {
nonce = n
} else {
return nil, errors.Oauth2.Messagef("missing nonce claim in ID token, claims='%#v'", idTokenClaims)
}

if csrfToken != nonce {
return nil, errors.Oauth2.Messagef("CSRF token mismatch: '%s' (from nonce claim) vs. '%s' (s256: '%s')", nonce, csrfTokenValue, csrfToken)
}
}

// 2. ID Token
// sub
// REQUIRED.
var subIdtoken string
if s, ok := idTokenClaims["sub"].(string); ok {
subIdtoken = s
} else {
return nil, errors.Oauth2.Messagef("missing sub claim in ID token, claims='%#v'", idTokenClaims)
}

userinfoResponse, err := oa.requestUserinfo(ctx, accessToken)
if err != nil {
return nil, err
}

userinfoResponseString := string(userinfoResponse)
var userinfoData map[string]interface{}
err = json.Unmarshal(userinfoResponse, &userinfoData)
if err != nil {
return nil, errors.Oauth2.Messagef("parsing userinfo response JSON failed, response='%s'", userinfoResponseString).With(err)
}

var subUserinfo string
if s, ok := userinfoData["sub"].(string); ok {
subUserinfo = s
} else {
return nil, errors.Oauth2.Messagef("missing sub property in userinfo response, response='%s'", userinfoResponseString)
}

if subIdtoken != subUserinfo {
return nil, errors.Oauth2.Messagef("subject mismatch, in ID token '%s', in userinfo response '%s'", subIdtoken, subUserinfo)
}

return idTokenClaims, nil
}

func (oa *OAuth2Callback) requestUserinfo(ctx context.Context, accessToken string) ([]byte, error) {
userinfoReq, err := oa.newUserinfoRequest(ctx, accessToken)
if err != nil {
return nil, err
}

userinfoRes, err := oa.oauth2.Backend.RoundTrip(userinfoReq)
if err != nil {
return nil, err
}

userinfoResBytes, err := ioutil.ReadAll(userinfoRes.Body)
if err != nil {
return nil, errors.Backend.Label(oa.config.Reference()).Message("userinfo request read error").With(err)
}

if userinfoRes.StatusCode != http.StatusOK {
return nil, errors.Backend.Label(oa.config.Reference()).Messagef("userinfo request failed, response='%s'", string(userinfoResBytes))
}

return userinfoResBytes, nil
}

func (oa *OAuth2Callback) newUserinfoRequest(ctx context.Context, accessToken string) (*http.Request, error) {
url, err := eval.GetContextAttribute(oa.config.HCLBody(), ctx, "userinfo_endpoint")
if err != nil {
return nil, err
}

if url == "" {
return nil, errors.Oauth2.Messagef("missing userinfo_endpoint in config")
}

// url will be configured via backend roundtrip
outreq, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return nil, err
}

outreq.Header.Set("Authorization", "Bearer "+accessToken)

outCtx := context.WithValue(ctx, request.URLAttribute, url)

return outreq.WithContext(outCtx), nil
}

func Base64url_s256(value string) string {
johakoch marked this conversation as resolved.
Show resolved Hide resolved
h := sha256.New()
h.Write([]byte(value))
return base64.RawURLEncoding.EncodeToString(h.Sum(nil))
}
Loading