Merge pull request #766 from ericchiang/implicit-flow
server: fixes for the implicit and hybrid flow
This commit is contained in:
commit
c66cce8b40
6 changed files with 318 additions and 62 deletions
|
@ -144,12 +144,23 @@ func (s *Server) discoveryHandler() (http.Handler, error) {
|
|||
|
||||
// handleAuthorization handles the OAuth2 auth endpoint.
|
||||
func (s *Server) handleAuthorization(w http.ResponseWriter, r *http.Request) {
|
||||
authReq, err := s.parseAuthorizationRequest(s.supportedResponseTypes, r)
|
||||
authReq, err := s.parseAuthorizationRequest(r)
|
||||
if err != nil {
|
||||
s.logger.Errorf("Failed to parse authorization request: %v", err)
|
||||
s.renderError(w, http.StatusInternalServerError, "Failed to connect to the database.")
|
||||
if handler, ok := err.Handle(); ok {
|
||||
// client_id and redirect_uri checked out and we can redirect back to
|
||||
// the client with the error.
|
||||
handler.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Otherwise render the error to the user.
|
||||
//
|
||||
// TODO(ericchiang): Should we just always render the error?
|
||||
s.renderError(w, err.Status(), err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
authReq.Expiry = s.now().Add(time.Minute * 30)
|
||||
if err := s.storage.CreateAuthRequest(authReq); err != nil {
|
||||
s.logger.Errorf("Failed to create authorization request: %v", err)
|
||||
|
@ -441,12 +452,25 @@ func (s *Server) sendCodeResponse(w http.ResponseWriter, r *http.Request, authRe
|
|||
s.renderError(w, http.StatusInternalServerError, "Invalid redirect URI.")
|
||||
return
|
||||
}
|
||||
q := u.Query()
|
||||
|
||||
var (
|
||||
// Was the initial request using the implicit or hybrid flow instead of
|
||||
// the "normal" code flow?
|
||||
implicitOrHybrid = false
|
||||
|
||||
// Only present in hybrid or code flow. code.ID == "" if this is not set.
|
||||
code storage.AuthCode
|
||||
|
||||
// ID token returned immediately if the response_type includes "id_token".
|
||||
// Only valid for implicit and hybrid flows.
|
||||
idToken string
|
||||
idTokenExpiry time.Time
|
||||
)
|
||||
|
||||
for _, responseType := range authReq.ResponseTypes {
|
||||
switch responseType {
|
||||
case responseTypeCode:
|
||||
code := storage.AuthCode{
|
||||
code = storage.AuthCode{
|
||||
ID: storage.NewID(),
|
||||
ClientID: authReq.ClientID,
|
||||
ConnectorID: authReq.ConnectorID,
|
||||
|
@ -463,32 +487,73 @@ func (s *Server) sendCodeResponse(w http.ResponseWriter, r *http.Request, authRe
|
|||
return
|
||||
}
|
||||
|
||||
// Implicit and hybrid flows that try to use the OOB redirect URI are
|
||||
// rejected earlier. If we got here we're using the code flow.
|
||||
if authReq.RedirectURI == redirectURIOOB {
|
||||
if err := s.templates.oob(w, code.ID); err != nil {
|
||||
s.logger.Errorf("Server template error: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
q.Set("code", code.ID)
|
||||
case responseTypeToken:
|
||||
idToken, expiry, err := s.newIDToken(authReq.ClientID, authReq.Claims, authReq.Scopes, authReq.Nonce)
|
||||
implicitOrHybrid = true
|
||||
case responseTypeIDToken:
|
||||
implicitOrHybrid = true
|
||||
var err error
|
||||
idToken, idTokenExpiry, err = s.newIDToken(authReq.ClientID, authReq.Claims, authReq.Scopes, authReq.Nonce)
|
||||
if err != nil {
|
||||
s.logger.Errorf("failed to create ID token: %v", err)
|
||||
s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
v := url.Values{}
|
||||
v.Set("access_token", storage.NewID())
|
||||
v.Set("token_type", "bearer")
|
||||
v.Set("id_token", idToken)
|
||||
v.Set("state", authReq.State)
|
||||
v.Set("expires_in", strconv.Itoa(int(expiry.Sub(s.now()).Seconds())))
|
||||
u.Fragment = v.Encode()
|
||||
}
|
||||
}
|
||||
|
||||
q.Set("state", authReq.State)
|
||||
u.RawQuery = q.Encode()
|
||||
if implicitOrHybrid {
|
||||
v := url.Values{}
|
||||
v.Set("access_token", storage.NewID())
|
||||
v.Set("token_type", "bearer")
|
||||
v.Set("state", authReq.State)
|
||||
if idToken != "" {
|
||||
v.Set("id_token", idToken)
|
||||
// The hybrid flow with only "code token" or "code id_token" doesn't return an
|
||||
// "expires_in" value. If "code" wasn't provided, indicating the implicit flow,
|
||||
// don't add it.
|
||||
//
|
||||
// https://openid.net/specs/openid-connect-core-1_0.html#HybridAuthResponse
|
||||
if code.ID == "" {
|
||||
v.Set("expires_in", strconv.Itoa(int(idTokenExpiry.Sub(s.now()).Seconds())))
|
||||
}
|
||||
}
|
||||
if code.ID != "" {
|
||||
v.Set("code", code.ID)
|
||||
}
|
||||
|
||||
// Implicit and hybrid flows return their values as part of the fragment.
|
||||
//
|
||||
// HTTP/1.1 303 See Other
|
||||
// Location: https://client.example.org/cb#
|
||||
// access_token=SlAV32hkKG
|
||||
// &token_type=bearer
|
||||
// &id_token=eyJ0 ... NiJ9.eyJ1c ... I6IjIifX0.DeWt4Qu ... ZXso
|
||||
// &expires_in=3600
|
||||
// &state=af0ifjsldkj
|
||||
//
|
||||
u.Fragment = v.Encode()
|
||||
} else {
|
||||
// The code flow add values to the URL query.
|
||||
//
|
||||
// HTTP/1.1 303 See Other
|
||||
// Location: https://client.example.org/cb?
|
||||
// code=SplxlOBeZQQYbYS6WxSbIA
|
||||
// &state=af0ifjsldkj
|
||||
//
|
||||
q := u.Query()
|
||||
q.Set("code", code.ID)
|
||||
q.Set("state", authReq.State)
|
||||
u.RawQuery = q.Encode()
|
||||
}
|
||||
|
||||
http.Redirect(w, r, u.String(), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
|
|
129
server/oauth2.go
129
server/oauth2.go
|
@ -24,20 +24,39 @@ type authErr struct {
|
|||
Description string
|
||||
}
|
||||
|
||||
func (err *authErr) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
v := url.Values{}
|
||||
v.Add("state", err.State)
|
||||
v.Add("error", err.Type)
|
||||
if err.Description != "" {
|
||||
v.Add("error_description", err.Description)
|
||||
func (err *authErr) Status() int {
|
||||
if err.State == errServerError {
|
||||
return http.StatusInternalServerError
|
||||
}
|
||||
var redirectURI string
|
||||
if strings.Contains(err.RedirectURI, "?") {
|
||||
redirectURI = err.RedirectURI + "&" + v.Encode()
|
||||
} else {
|
||||
redirectURI = err.RedirectURI + "?" + v.Encode()
|
||||
return http.StatusBadRequest
|
||||
}
|
||||
|
||||
func (err *authErr) Error() string {
|
||||
return err.Description
|
||||
}
|
||||
|
||||
func (err *authErr) Handle() (http.Handler, bool) {
|
||||
// Didn't get a valid redirect URI.
|
||||
if err.RedirectURI == "" {
|
||||
return nil, false
|
||||
}
|
||||
http.Redirect(w, r, redirectURI, http.StatusSeeOther)
|
||||
|
||||
hf := func(w http.ResponseWriter, r *http.Request) {
|
||||
v := url.Values{}
|
||||
v.Add("state", err.State)
|
||||
v.Add("error", err.Type)
|
||||
if err.Description != "" {
|
||||
v.Add("error_description", err.Description)
|
||||
}
|
||||
var redirectURI string
|
||||
if strings.Contains(err.RedirectURI, "?") {
|
||||
redirectURI = err.RedirectURI + "&" + v.Encode()
|
||||
} else {
|
||||
redirectURI = err.RedirectURI + "?" + v.Encode()
|
||||
}
|
||||
http.Redirect(w, r, redirectURI, http.StatusSeeOther)
|
||||
}
|
||||
return http.HandlerFunc(hf), true
|
||||
}
|
||||
|
||||
func tokenErr(w http.ResponseWriter, typ, description string, statusCode int) error {
|
||||
|
@ -192,20 +211,19 @@ func (s *Server) newIDToken(clientID string, claims storage.Claims, scopes []str
|
|||
}
|
||||
|
||||
// parse the initial request from the OAuth2 client.
|
||||
//
|
||||
// For correctness the logic is largely copied from https://github.com/RangelReale/osin.
|
||||
func (s *Server) parseAuthorizationRequest(supportedResponseTypes map[string]bool, r *http.Request) (req storage.AuthRequest, oauth2Err *authErr) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
return req, &authErr{"", "", errInvalidRequest, "Failed to parse request."}
|
||||
}
|
||||
|
||||
redirectURI, err := url.QueryUnescape(r.Form.Get("redirect_uri"))
|
||||
func (s *Server) parseAuthorizationRequest(r *http.Request) (req storage.AuthRequest, oauth2Err *authErr) {
|
||||
q := r.URL.Query()
|
||||
redirectURI, err := url.QueryUnescape(q.Get("redirect_uri"))
|
||||
if err != nil {
|
||||
return req, &authErr{"", "", errInvalidRequest, "No redirect_uri provided."}
|
||||
}
|
||||
state := r.FormValue("state")
|
||||
|
||||
clientID := r.Form.Get("client_id")
|
||||
clientID := q.Get("client_id")
|
||||
state := q.Get("state")
|
||||
nonce := q.Get("nonce")
|
||||
// Some clients, like the old go-oidc, provide extra whitespace. Tolerate this.
|
||||
scopes := strings.Fields(q.Get("scope"))
|
||||
responseTypes := strings.Fields(q.Get("response_type"))
|
||||
|
||||
client, err := s.storage.GetClient(clientID)
|
||||
if err != nil {
|
||||
|
@ -222,12 +240,11 @@ func (s *Server) parseAuthorizationRequest(supportedResponseTypes map[string]boo
|
|||
return req, &authErr{"", "", errInvalidRequest, description}
|
||||
}
|
||||
|
||||
// From here on out, we want to redirect back to the client with an error.
|
||||
newErr := func(typ, format string, a ...interface{}) *authErr {
|
||||
return &authErr{state, redirectURI, typ, fmt.Sprintf(format, a...)}
|
||||
}
|
||||
|
||||
scopes := strings.Fields(r.Form.Get("scope"))
|
||||
|
||||
var (
|
||||
unrecognized []string
|
||||
invalidScopes []string
|
||||
|
@ -247,7 +264,7 @@ func (s *Server) parseAuthorizationRequest(supportedResponseTypes map[string]boo
|
|||
|
||||
isTrusted, err := s.validateCrossClientTrust(clientID, peerID)
|
||||
if err != nil {
|
||||
return req, newErr(errServerError, "")
|
||||
return req, newErr(errServerError, "Internal server error.")
|
||||
}
|
||||
if !isTrusted {
|
||||
invalidScopes = append(invalidScopes, scope)
|
||||
|
@ -264,37 +281,61 @@ func (s *Server) parseAuthorizationRequest(supportedResponseTypes map[string]boo
|
|||
return req, newErr("invalid_scope", "Client can't request scope(s) %q", invalidScopes)
|
||||
}
|
||||
|
||||
nonce := r.Form.Get("nonce")
|
||||
responseTypes := strings.Split(r.Form.Get("response_type"), " ")
|
||||
var rt struct {
|
||||
code bool
|
||||
idToken bool
|
||||
token bool
|
||||
}
|
||||
|
||||
for _, responseType := range responseTypes {
|
||||
if !supportedResponseTypes[responseType] {
|
||||
switch responseType {
|
||||
case responseTypeCode:
|
||||
rt.code = true
|
||||
case responseTypeIDToken:
|
||||
rt.idToken = true
|
||||
case responseTypeToken:
|
||||
rt.token = true
|
||||
default:
|
||||
return req, newErr("invalid_request", "Invalid response type %q", responseType)
|
||||
}
|
||||
|
||||
switch responseType {
|
||||
case responseTypeCode:
|
||||
case responseTypeToken:
|
||||
// Implicit flow requires a nonce value.
|
||||
// https://openid.net/specs/openid-connect-core-1_0.html#ImplicitAuthRequest
|
||||
if nonce == "" {
|
||||
return req, newErr("invalid_request", "Response type 'token' requires a 'nonce' value.")
|
||||
}
|
||||
if !s.supportedResponseTypes[responseType] {
|
||||
return req, newErr(errUnsupportedResponseType, "Unsupported response type %q", responseType)
|
||||
}
|
||||
}
|
||||
|
||||
if redirectURI == redirectURIOOB {
|
||||
err := fmt.Sprintf("Cannot use response type 'token' with redirect_uri '%s'.", redirectURIOOB)
|
||||
return req, newErr("invalid_request", err)
|
||||
}
|
||||
default:
|
||||
return req, newErr("invalid_request", "Invalid response type %q", responseType)
|
||||
if len(responseTypes) == 0 {
|
||||
return req, newErr("invalid_requests", "No response_type provided")
|
||||
}
|
||||
|
||||
if rt.token && !rt.code && !rt.idToken {
|
||||
// "token" can't be provided by its own.
|
||||
//
|
||||
// https://openid.net/specs/openid-connect-core-1_0.html#Authentication
|
||||
return req, newErr("invalid_request", "Response type 'token' must be provided with type 'id_token' and/or 'code'")
|
||||
}
|
||||
if !rt.code {
|
||||
// Either "id_token code" or "id_token" has been provided which implies the
|
||||
// implicit flow. Implicit flow requires a nonce value.
|
||||
//
|
||||
// https://openid.net/specs/openid-connect-core-1_0.html#ImplicitAuthRequest
|
||||
if nonce == "" {
|
||||
return req, newErr("invalid_request", "Response type 'token' requires a 'nonce' value.")
|
||||
}
|
||||
}
|
||||
if rt.token {
|
||||
if redirectURI == redirectURIOOB {
|
||||
err := fmt.Sprintf("Cannot use response type 'token' with redirect_uri '%s'.", redirectURIOOB)
|
||||
return req, newErr("invalid_request", err)
|
||||
}
|
||||
}
|
||||
|
||||
return storage.AuthRequest{
|
||||
ID: storage.NewID(),
|
||||
ClientID: client.ID,
|
||||
State: r.Form.Get("state"),
|
||||
State: state,
|
||||
Nonce: nonce,
|
||||
ForceApprovalPrompt: r.Form.Get("approval_prompt") == "force",
|
||||
ForceApprovalPrompt: q.Get("approval_prompt") == "force",
|
||||
Scopes: scopes,
|
||||
RedirectURI: redirectURI,
|
||||
ResponseTypes: responseTypes,
|
||||
|
|
|
@ -1 +1,150 @@
|
|||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/coreos/dex/storage"
|
||||
)
|
||||
|
||||
func TestParseAuthorizationRequest(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
clients []storage.Client
|
||||
supportedResponseTypes []string
|
||||
|
||||
queryParams map[string]string
|
||||
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "normal request",
|
||||
clients: []storage.Client{
|
||||
{
|
||||
ID: "foo",
|
||||
RedirectURIs: []string{"https://example.com/foo"},
|
||||
},
|
||||
},
|
||||
supportedResponseTypes: []string{"code"},
|
||||
queryParams: map[string]string{
|
||||
"client_id": "foo",
|
||||
"redirect_uri": "https://example.com/foo",
|
||||
"response_type": "code",
|
||||
"scope": "openid email profile",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "invalid client id",
|
||||
clients: []storage.Client{
|
||||
{
|
||||
ID: "foo",
|
||||
RedirectURIs: []string{"https://example.com/foo"},
|
||||
},
|
||||
},
|
||||
supportedResponseTypes: []string{"code"},
|
||||
queryParams: map[string]string{
|
||||
"client_id": "bar",
|
||||
"redirect_uri": "https://example.com/foo",
|
||||
"response_type": "code",
|
||||
"scope": "openid email profile",
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "invalid redirect uri",
|
||||
clients: []storage.Client{
|
||||
{
|
||||
ID: "bar",
|
||||
RedirectURIs: []string{"https://example.com/bar"},
|
||||
},
|
||||
},
|
||||
supportedResponseTypes: []string{"code"},
|
||||
queryParams: map[string]string{
|
||||
"client_id": "bar",
|
||||
"redirect_uri": "https://example.com/foo",
|
||||
"response_type": "code",
|
||||
"scope": "openid email profile",
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "implicit flow",
|
||||
clients: []storage.Client{
|
||||
{
|
||||
ID: "bar",
|
||||
RedirectURIs: []string{"https://example.com/bar"},
|
||||
},
|
||||
},
|
||||
supportedResponseTypes: []string{"code", "id_token", "token"},
|
||||
queryParams: map[string]string{
|
||||
"client_id": "bar",
|
||||
"redirect_uri": "https://example.com/bar",
|
||||
"response_type": "code id_token",
|
||||
"scope": "openid email profile",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "unsupported response type",
|
||||
clients: []storage.Client{
|
||||
{
|
||||
ID: "bar",
|
||||
RedirectURIs: []string{"https://example.com/bar"},
|
||||
},
|
||||
},
|
||||
supportedResponseTypes: []string{"code"},
|
||||
queryParams: map[string]string{
|
||||
"client_id": "bar",
|
||||
"redirect_uri": "https://example.com/bar",
|
||||
"response_type": "code id_token",
|
||||
"scope": "openid email profile",
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "only token response type",
|
||||
clients: []storage.Client{
|
||||
{
|
||||
ID: "bar",
|
||||
RedirectURIs: []string{"https://example.com/bar"},
|
||||
},
|
||||
},
|
||||
supportedResponseTypes: []string{"code", "id_token", "token"},
|
||||
queryParams: map[string]string{
|
||||
"client_id": "bar",
|
||||
"redirect_uri": "https://example.com/bar",
|
||||
"response_type": "token",
|
||||
"scope": "openid email profile",
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
func() {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
httpServer, server := newTestServer(ctx, t, func(c *Config) {
|
||||
c.SupportedResponseTypes = tc.supportedResponseTypes
|
||||
c.Storage = storage.WithStaticClients(c.Storage, tc.clients)
|
||||
})
|
||||
defer httpServer.Close()
|
||||
|
||||
params := url.Values{}
|
||||
for k, v := range tc.queryParams {
|
||||
params.Set(k, v)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest("GET", httpServer.URL+"/auth?"+params.Encode(), nil)
|
||||
_, err := server.parseAuthorizationRequest(req)
|
||||
if err != nil && !tc.wantErr {
|
||||
t.Errorf("%s: %v", tc.name, err)
|
||||
}
|
||||
if err == nil && tc.wantErr {
|
||||
t.Errorf("%s: expected error", tc.name)
|
||||
}
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -130,6 +130,7 @@ func (k keyRotater) rotate() error {
|
|||
|
||||
// Remove expired verification keys.
|
||||
i := 0
|
||||
|
||||
for _, key := range keys.VerificationKeys {
|
||||
if !key.Expiry.After(tNow) {
|
||||
keys.VerificationKeys[i] = key
|
||||
|
|
|
@ -159,7 +159,7 @@ func newServer(ctx context.Context, c Config, rotationStrategy rotationStrategy)
|
|||
supported := make(map[string]bool)
|
||||
for _, respType := range c.SupportedResponseTypes {
|
||||
switch respType {
|
||||
case responseTypeCode, responseTypeToken:
|
||||
case responseTypeCode, responseTypeIDToken, responseTypeToken:
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported response_type %q", respType)
|
||||
}
|
||||
|
|
|
@ -510,7 +510,7 @@ func TestOAuth2ImplicitFlow(t *testing.T) {
|
|||
|
||||
httpServer, s := newTestServer(ctx, t, func(c *Config) {
|
||||
// Enable support for the implicit flow.
|
||||
c.SupportedResponseTypes = []string{"code", "token"}
|
||||
c.SupportedResponseTypes = []string{"code", "token", "id_token"}
|
||||
})
|
||||
defer httpServer.Close()
|
||||
|
||||
|
@ -553,7 +553,7 @@ func TestOAuth2ImplicitFlow(t *testing.T) {
|
|||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
u := oauth2Config.AuthCodeURL(state, oauth2.SetAuthURLParam("response_type", "token"), oidc.Nonce(nonce))
|
||||
u := oauth2Config.AuthCodeURL(state, oauth2.SetAuthURLParam("response_type", "id_token token"), oidc.Nonce(nonce))
|
||||
http.Redirect(w, r, u, http.StatusSeeOther)
|
||||
}))
|
||||
|
||||
|
|
Reference in a new issue