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

Add userinfo_endpoint #1454

Closed
wants to merge 4 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
115 changes: 112 additions & 3 deletions server/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package server

import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
Expand All @@ -22,6 +23,10 @@ import (
"github.com/dexidp/dex/storage"
)

var (
errTokenExpired = errors.New("token has expired")
)

// newHealthChecker returns the healthz handler. The handler runs until the
// provided context is canceled.
func (s *Server) newHealthChecker(ctx context.Context) http.Handler {
Expand Down Expand Up @@ -151,6 +156,7 @@ type discovery struct {
Auth string `json:"authorization_endpoint"`
Token string `json:"token_endpoint"`
Keys string `json:"jwks_uri"`
UserInfo string `json:"userinfo_endpoint"`
ResponseTypes []string `json:"response_types_supported"`
Subjects []string `json:"subject_types_supported"`
IDTokenAlgs []string `json:"id_token_signing_alg_values_supported"`
Expand All @@ -165,6 +171,7 @@ func (s *Server) discoveryHandler() (http.HandlerFunc, error) {
Auth: s.absURL("/auth"),
Token: s.absURL("/token"),
Keys: s.absURL("/keys"),
UserInfo: s.absURL("/userinfo"),
Subjects: []string{"public"},
IDTokenAlgs: []string{string(jose.RS256)},
Scopes: []string{"openid", "email", "groups", "profile", "offline_access"},
Expand Down Expand Up @@ -559,7 +566,8 @@ func (s *Server) sendCodeResponse(w http.ResponseWriter, r *http.Request, authRe
idToken string
idTokenExpiry time.Time

accessToken = storage.NewID()
// Access token
accessToken string
)

for _, responseType := range authReq.ResponseTypes {
Expand Down Expand Up @@ -595,6 +603,14 @@ func (s *Server) sendCodeResponse(w http.ResponseWriter, r *http.Request, authRe
case responseTypeIDToken:
implicitOrHybrid = true
var err error

accessToken, err := s.newAccessToken(authReq.ClientID, authReq.Claims, authReq.Scopes, authReq.Nonce, authReq.ConnectorID)
if err != nil {
s.logger.Errorf("failed to create new access token: %v", err)
s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError)
return
}

idToken, idTokenExpiry, err = s.newIDToken(authReq.ClientID, authReq.Claims, authReq.Scopes, authReq.Nonce, accessToken, authReq.ConnectorID)
if err != nil {
s.logger.Errorf("failed to create ID token: %v", err)
Expand Down Expand Up @@ -716,7 +732,13 @@ func (s *Server) handleAuthCode(w http.ResponseWriter, r *http.Request, client s
return
}

accessToken := storage.NewID()
accessToken, err := s.newAccessToken(client.ID, authCode.Claims, authCode.Scopes, authCode.Nonce, authCode.ConnectorID)
if err != nil {
s.logger.Errorf("failed to create new access token: %v", err)
s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError)
return
}

idToken, expiry, err := s.newIDToken(client.ID, authCode.Claims, authCode.Scopes, authCode.Nonce, accessToken, authCode.ConnectorID)
if err != nil {
s.logger.Errorf("failed to create ID token: %v", err)
Expand Down Expand Up @@ -965,7 +987,13 @@ func (s *Server) handleRefreshToken(w http.ResponseWriter, r *http.Request, clie
Groups: ident.Groups,
}

accessToken := storage.NewID()
accessToken, err := s.newAccessToken(client.ID, claims, scopes, refresh.Nonce, refresh.ConnectorID)
if err != nil {
s.logger.Errorf("failed to create new access token: %v", err)
s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError)
return
}

idToken, expiry, err := s.newIDToken(client.ID, claims, scopes, refresh.Nonce, accessToken, refresh.ConnectorID)
if err != nil {
s.logger.Errorf("failed to create ID token: %v", err)
Expand Down Expand Up @@ -1026,6 +1054,87 @@ func (s *Server) handleRefreshToken(w http.ResponseWriter, r *http.Request, clie
s.writeAccessToken(w, idToken, accessToken, rawNewToken, expiry)
}

func (s *Server) handleUserInfo(w http.ResponseWriter, r *http.Request) {
authorization := r.Header.Get("Authorization")
parts := strings.Fields(authorization)

if len(parts) != 2 || !strings.EqualFold(parts[0], "bearer") {
msg := "invalid authorization header"
w.Header().Set("WWW-Authenticate", fmt.Sprintf(`Bearer realm="dex", error="%s", error_description="%s"`, errInvalidRequest, msg))
s.tokenErrHelper(w, errInvalidRequest, msg, http.StatusBadRequest)
return
}

token := parts[1]

verified, err := s.verify(token)
if err != nil {
if err == errTokenExpired {
s.tokenErrHelper(w, errAccessDenied, err.Error(), http.StatusUnauthorized)
return
}
s.tokenErrHelper(w, errInvalidRequest, err.Error(), http.StatusBadRequest)
return
}

w.Header().Set("Content-Type", "application/json")
w.Write(verified)
}

func (s *Server) verify(token string) ([]byte, error) {
keys, err := s.storage.GetKeys()
if err != nil {
return nil, fmt.Errorf("failed to get keys: %v", err)
}

if keys.SigningKey == nil {
return nil, fmt.Errorf("no private keys found")
}

object, err := jose.ParseSigned(token)
if err != nil {
return nil, fmt.Errorf("unable to parse signed message")
}

parts := strings.Split(token, ".")
if len(parts) != 3 {
return nil, fmt.Errorf("compact JWS format must have three parts")
}

payload, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return nil, err
}

// TODO: check other claims
Copy link
Contributor

Choose a reason for hiding this comment

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

This could be a "big" TODO. I realised later on that the jose or any other JWT token lib should do all the checks, but I'm not sure they do all the needed. Or maybe they do, and I'm just being over paranoid here to check again. Need a JWT expert or jose expert here to answer the question.

var tokenInfo struct {
Expiry int64 `json:"exp"`
}

if err := json.Unmarshal(payload, &tokenInfo); err != nil {
return nil, err
}

if tokenInfo.Expiry < s.now().Unix() {
return nil, errTokenExpired
}

var allKeys []*jose.JSONWebKey

allKeys = append(allKeys, keys.SigningKeyPub)
for _, key := range keys.VerificationKeys {
allKeys = append(allKeys, key.PublicKey)
}

for _, pubKey := range allKeys {
verified, err := object.Verify(pubKey)
if err == nil {
return verified, nil
}
}
return nil, errors.New("unable to verify jwt")
}

func (s *Server) writeAccessToken(w http.ResponseWriter, idToken, accessToken, refreshToken string, expiry time.Time) {
// TODO(ericchiang): figure out an access token story and support the user info
// endpoint. For now use a random value so no one depends on the access_token
Expand Down
5 changes: 5 additions & 0 deletions server/oauth2.go
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,11 @@ type federatedIDClaims struct {
UserID string `json:"user_id,omitempty"`
}

func (s *Server) newAccessToken(clientID string, claims storage.Claims, scopes []string, nonce, connID string) (accessToken string, err error) {
idToken, _, err := s.newIDToken(clientID, claims, scopes, nonce, storage.NewID(), connID)
Copy link
Contributor

Choose a reason for hiding this comment

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

Hmm! So we'll be using a JWT for access_token... but it's a different one; we don't hand out the same we give as id_token. 💭

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Maybe @jackielii can answer this one?

Copy link
Contributor

Choose a reason for hiding this comment

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

Like in my original PR comment: "this is a stop gap solution". In my understanding, access_token is a "key" that should be saved away and later used to retrieve "user_info". Here I'm abusing the ID token signature verification: Use an ID token as the access token, and use signature verification to verify the JWT and just return the claims as the user_info response.

Because the access_token is opaque to the user, so this change is forward compatible with the "proper solution". I even created this function to later swap out for the proper "save to db" solution.

Copy link
Contributor

Choose a reason for hiding this comment

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

@srenatus sorry forgot to answer your original question. Within one auth request, the id_token won't be exactly the same as the access_token, albeit access_token looks and "can" behave like a jwt id_token. We're just abusing the fact that access_token is just a "random string".

Copy link
Contributor

Choose a reason for hiding this comment

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

Quite clever! And thanks for considering "forward compat with a proper solution" 😄

return idToken, err
}

func (s *Server) newIDToken(clientID string, claims storage.Claims, scopes []string, nonce, accessToken, connID string) (idToken string, expiry time.Time, err error) {
keys, err := s.storage.GetKeys()
if err != nil {
Expand Down
1 change: 1 addition & 0 deletions server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,7 @@ func newServer(ctx context.Context, c Config, rotationStrategy rotationStrategy)
// TODO(ericchiang): rate limit certain paths based on IP.
handleWithCORS("/token", s.handleToken)
handleWithCORS("/keys", s.handlePublicKeys)
handleWithCORS("/userinfo", s.handleUserInfo)
handleFunc("/auth", s.handleAuthorization)
handleFunc("/auth/{connector}", s.handleConnectorLogin)
r.HandleFunc(path.Join(issuerURL.Path, "/callback"), func(w http.ResponseWriter, r *http.Request) {
Expand Down