-
Notifications
You must be signed in to change notification settings - Fork 15
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
Changes from all commits
5b3debe
d1890b8
2bd1105
3f61b3c
9355148
e06f492
e48c31d
8191aeb
8dc904b
fc2adb3
bd277bb
bf1ea47
3e16172
1abbb7f
fa0de85
a604a3c
b98cbd9
a06386f
b92e63c
56dba1d
2db8a44
bb598d9
4313714
08be83f
80927e5
0cfc583
3b56ac1
3d9f680
6f307b6
ead1a5f
af67678
55fe390
49ac377
dabeb37
7930a7e
3b57763
2f211c4
163d4d0
0d1c4e7
2d70591
dd49ed3
4788544
68abb7d
66e720a
3e02505
68943d3
d873e02
2da045b
a889b62
94c0d30
0679c7d
eedc713
448feea
f93a627
02f84e6
50b1238
892eaf4
c483d0b
eea95d7
7e01e1e
2376abc
339f7cd
56f4fe5
0288efa
a2af6ba
5436e22
675d3d8
dd39adf
bbfd584
f447120
af49068
39f0bc1
e4db466
d20a4d8
d0e1475
3495f71
99c6288
8c22818
883addc
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,328 @@ | ||
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) | ||
} | ||
|
||
query := req.URL.Query() | ||
code := query.Get("code") | ||
if code == "" { | ||
return errors.Oauth2.Messagef("missing code query parameter; query=%q", req.URL.RawQuery) | ||
} | ||
|
||
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.Message("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.Message("Empty CSRF token_value") | ||
} | ||
csrfToken = Base64urlSha256(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=%q", req.URL.RawQuery) | ||
} | ||
|
||
if csrfToken != csrfTokenFromParam { | ||
return errors.Oauth2.Messagef("CSRF token mismatch: %q (from query param) vs. %q (s256: %q)", csrfTokenFromParam, csrfTokenValue, csrfToken) | ||
} | ||
} | ||
} | ||
|
||
requestConfig.Code = &code | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Maybe this could be a task (read/assign query) for There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. As |
||
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=%q", 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, userinfo, err := oa.validateIdTokenClaims(ctx, idToken.Claims, csrfToken, csrfTokenValue, accessToken) | ||
if err != nil { | ||
return err | ||
} | ||
|
||
tokenData["id_token_claims"] = idtc | ||
tokenData["userinfo"] = userinfo | ||
} | ||
|
||
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(ctx context.Context, claims jwt.Claims, csrfToken, csrfTokenValue string, accessToken string) (map[string]interface{}, map[string]interface{}, error) { | ||
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, nil, errors.Oauth2.Messagef("missing exp claim in ID token, claims='%#v'", idTokenClaims) | ||
} | ||
// iat | ||
// REQUIRED. | ||
if _, iatExists := idTokenClaims["iat"]; !iatExists { | ||
return nil, 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, 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, nil, errors.Oauth2.Messagef("azp claim / client ID mismatch, azp = %q, client ID = %q", 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, nil, errors.Oauth2.Messagef("missing nonce claim in ID token, claims='%#v'", idTokenClaims) | ||
} | ||
|
||
if csrfToken != nonce { | ||
return nil, nil, errors.Oauth2.Messagef("CSRF token mismatch: %q (from nonce claim) vs. %q (s256: %q)", nonce, csrfTokenValue, csrfToken) | ||
} | ||
} | ||
|
||
// 2. ID Token | ||
// sub | ||
// REQUIRED. | ||
var subIdtoken string | ||
if s, ok := idTokenClaims["sub"].(string); ok { | ||
subIdtoken = s | ||
} else { | ||
return nil, nil, errors.Oauth2.Messagef("missing sub claim in ID token, claims='%#v'", idTokenClaims) | ||
} | ||
|
||
userinfoResponse, err := oa.requestUserinfo(ctx, accessToken) | ||
if err != nil { | ||
return nil, nil, err | ||
} | ||
|
||
userinfoResponseString := string(userinfoResponse) | ||
var userinfoData map[string]interface{} | ||
err = json.Unmarshal(userinfoResponse, &userinfoData) | ||
if err != nil { | ||
return nil, nil, errors.Oauth2.Messagef("parsing userinfo response JSON failed, response=%q", userinfoResponseString).With(err) | ||
} | ||
|
||
var subUserinfo string | ||
if s, ok := userinfoData["sub"].(string); ok { | ||
subUserinfo = s | ||
} else { | ||
return nil, nil, errors.Oauth2.Messagef("missing sub property in userinfo response, response=%q", userinfoResponseString) | ||
} | ||
|
||
if subIdtoken != subUserinfo { | ||
return nil, nil, errors.Oauth2.Messagef("subject mismatch, in ID token %q, in userinfo response %q", subIdtoken, subUserinfo) | ||
} | ||
|
||
return idTokenClaims, userinfoData, 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=%q", string(userinfoResBytes)) | ||
} | ||
|
||
return userinfoResBytes, nil | ||
} | ||
|
||
func (oa *OAuth2Callback) newUserinfoRequest(ctx context.Context, accessToken string) (*http.Request, error) { | ||
if oa.config.UserinfoEndpoint == "" { | ||
return nil, errors.Oauth2.Message("missing userinfo_endpoint in config") | ||
} | ||
|
||
// url will be configured via backend roundtrip | ||
outreq, err := http.NewRequest(http.MethodGet, oa.config.UserinfoEndpoint, nil) | ||
if err != nil { | ||
return nil, err | ||
} | ||
|
||
outreq.Header.Set("Authorization", "Bearer "+accessToken) | ||
|
||
outCtx := context.WithValue(ctx, request.URLAttribute, oa.config.UserinfoEndpoint) | ||
|
||
return outreq.WithContext(outCtx), nil | ||
} | ||
|
||
func Base64urlSha256(value string) string { | ||
h := sha256.New() | ||
h.Write([]byte(value)) | ||
return base64.RawURLEncoding.EncodeToString(h.Sum(nil)) | ||
} |
There was a problem hiding this comment.
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(...)
?There was a problem hiding this comment.
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?