forked from mystiq/dex
Add support for password grant #926
This commit is contained in:
parent
3cbba11012
commit
13be146d2a
6 changed files with 262 additions and 0 deletions
|
@ -129,6 +129,8 @@ type OAuth2 struct {
|
||||||
SkipApprovalScreen bool `json:"skipApprovalScreen"`
|
SkipApprovalScreen bool `json:"skipApprovalScreen"`
|
||||||
// If specified, show the connector selection screen even if there's only one
|
// If specified, show the connector selection screen even if there's only one
|
||||||
AlwaysShowLoginScreen bool `json:"alwaysShowLoginScreen"`
|
AlwaysShowLoginScreen bool `json:"alwaysShowLoginScreen"`
|
||||||
|
// This is the connector that can be used for password grant
|
||||||
|
PasswordConnector string `json:"passwordConnector"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Web is the config format for the HTTP server.
|
// Web is the config format for the HTTP server.
|
||||||
|
|
|
@ -201,6 +201,9 @@ func serve(cmd *cobra.Command, args []string) error {
|
||||||
if c.OAuth2.SkipApprovalScreen {
|
if c.OAuth2.SkipApprovalScreen {
|
||||||
logger.Infof("config skipping approval screen")
|
logger.Infof("config skipping approval screen")
|
||||||
}
|
}
|
||||||
|
if c.OAuth2.PasswordConnector != "" {
|
||||||
|
logger.Infof("config using password grant connector: %s", c.OAuth2.PasswordConnector)
|
||||||
|
}
|
||||||
if len(c.Web.AllowedOrigins) > 0 {
|
if len(c.Web.AllowedOrigins) > 0 {
|
||||||
logger.Infof("config allowed origins: %s", c.Web.AllowedOrigins)
|
logger.Infof("config allowed origins: %s", c.Web.AllowedOrigins)
|
||||||
}
|
}
|
||||||
|
@ -212,6 +215,7 @@ func serve(cmd *cobra.Command, args []string) error {
|
||||||
SupportedResponseTypes: c.OAuth2.ResponseTypes,
|
SupportedResponseTypes: c.OAuth2.ResponseTypes,
|
||||||
SkipApprovalScreen: c.OAuth2.SkipApprovalScreen,
|
SkipApprovalScreen: c.OAuth2.SkipApprovalScreen,
|
||||||
AlwaysShowLoginScreen: c.OAuth2.AlwaysShowLoginScreen,
|
AlwaysShowLoginScreen: c.OAuth2.AlwaysShowLoginScreen,
|
||||||
|
PasswordConnector: c.OAuth2.PasswordConnector,
|
||||||
AllowedOrigins: c.Web.AllowedOrigins,
|
AllowedOrigins: c.Web.AllowedOrigins,
|
||||||
Issuer: c.Issuer,
|
Issuer: c.Issuer,
|
||||||
Storage: s,
|
Storage: s,
|
||||||
|
|
|
@ -53,6 +53,8 @@ telemetry:
|
||||||
# go directly to it. For connected IdPs, this redirects the browser away
|
# go directly to it. For connected IdPs, this redirects the browser away
|
||||||
# from application to upstream provider such as the Google login page
|
# from application to upstream provider such as the Google login page
|
||||||
# alwaysShowLoginScreen: false
|
# alwaysShowLoginScreen: false
|
||||||
|
# Uncommend the passwordConnector to use a specific connector for password grants
|
||||||
|
# passwordConnector: local
|
||||||
|
|
||||||
# Instead of reading from an external storage, use this list of clients.
|
# Instead of reading from an external storage, use this list of clients.
|
||||||
#
|
#
|
||||||
|
|
|
@ -756,6 +756,8 @@ func (s *Server) handleToken(w http.ResponseWriter, r *http.Request) {
|
||||||
s.handleAuthCode(w, r, client)
|
s.handleAuthCode(w, r, client)
|
||||||
case grantTypeRefreshToken:
|
case grantTypeRefreshToken:
|
||||||
s.handleRefreshToken(w, r, client)
|
s.handleRefreshToken(w, r, client)
|
||||||
|
case grantTypePassword:
|
||||||
|
s.handlePasswordGrant(w, r, client)
|
||||||
default:
|
default:
|
||||||
s.tokenErrHelper(w, errInvalidGrant, "", http.StatusBadRequest)
|
s.tokenErrHelper(w, errInvalidGrant, "", http.StatusBadRequest)
|
||||||
}
|
}
|
||||||
|
@ -1150,6 +1152,251 @@ func (s *Server) handleUserInfo(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Write(claims)
|
w.Write(claims)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) handlePasswordGrant(w http.ResponseWriter, r *http.Request, client storage.Client) {
|
||||||
|
|
||||||
|
// Parse the fields
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
s.tokenErrHelper(w, errInvalidRequest, "Couldn't parse data", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
q := r.Form
|
||||||
|
|
||||||
|
// Get the clientID and secret from basic auth or form variables
|
||||||
|
clientID, clientSecret, ok := r.BasicAuth()
|
||||||
|
if ok {
|
||||||
|
var err error
|
||||||
|
if clientID, err = url.QueryUnescape(clientID); err != nil {
|
||||||
|
s.tokenErrHelper(w, errInvalidRequest, "client_id improperly encoded", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if clientSecret, err = url.QueryUnescape(clientSecret); err != nil {
|
||||||
|
s.tokenErrHelper(w, errInvalidRequest, "client_secret improperly encoded", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
clientID = q.Get("client_id")
|
||||||
|
clientSecret = q.Get("client_secret")
|
||||||
|
}
|
||||||
|
|
||||||
|
nonce := q.Get("nonce")
|
||||||
|
// Some clients, like the old go-oidc, provide extra whitespace. Tolerate this.
|
||||||
|
scopes := strings.Fields(q.Get("scope"))
|
||||||
|
|
||||||
|
// Get the client from the database
|
||||||
|
client, err := s.storage.GetClient(clientID)
|
||||||
|
if err != nil {
|
||||||
|
if err == storage.ErrNotFound {
|
||||||
|
s.tokenErrHelper(w, errInvalidClient, fmt.Sprintf("Invalid client_id (%q).", clientID), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.tokenErrHelper(w, errInvalidClient, fmt.Sprintf("Failed to get client %v.", err), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the scopes if they are passed
|
||||||
|
var (
|
||||||
|
unrecognized []string
|
||||||
|
invalidScopes []string
|
||||||
|
)
|
||||||
|
hasOpenIDScope := false
|
||||||
|
for _, scope := range scopes {
|
||||||
|
switch scope {
|
||||||
|
case scopeOpenID:
|
||||||
|
hasOpenIDScope = true
|
||||||
|
case scopeOfflineAccess, scopeEmail, scopeProfile, scopeGroups, scopeFederatedID:
|
||||||
|
default:
|
||||||
|
peerID, ok := parseCrossClientScope(scope)
|
||||||
|
if !ok {
|
||||||
|
unrecognized = append(unrecognized, scope)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
isTrusted, err := s.validateCrossClientTrust(clientID, peerID)
|
||||||
|
if err != nil {
|
||||||
|
s.tokenErrHelper(w, errInvalidClient, fmt.Sprintf("Error validating cross client trust %v.", err), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !isTrusted {
|
||||||
|
invalidScopes = append(invalidScopes, scope)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !hasOpenIDScope {
|
||||||
|
s.tokenErrHelper(w, errInvalidRequest, `Missing required scope(s) ["openid"].`, http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(unrecognized) > 0 {
|
||||||
|
s.tokenErrHelper(w, errInvalidRequest, fmt.Sprintf("Unrecognized scope(s) %q", unrecognized), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(invalidScopes) > 0 {
|
||||||
|
s.tokenErrHelper(w, errInvalidRequest, fmt.Sprintf("Client can't request scope(s) %q", invalidScopes), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Which connector
|
||||||
|
connID := s.passwordConnector
|
||||||
|
conn, err := s.getConnector(connID)
|
||||||
|
if err != nil {
|
||||||
|
s.tokenErrHelper(w, errInvalidRequest, "Requested connector does not exist.", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
passwordConnector, ok := conn.Connector.(connector.PasswordConnector)
|
||||||
|
if !ok {
|
||||||
|
s.tokenErrHelper(w, errInvalidRequest, "Requested password connector does not correct type.", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Login
|
||||||
|
username := q.Get("username")
|
||||||
|
password := q.Get("password")
|
||||||
|
identity, ok, err := passwordConnector.Login(r.Context(), parseScopes(scopes), username, password)
|
||||||
|
if err != nil {
|
||||||
|
s.tokenErrHelper(w, errInvalidRequest, "Could not login user", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
s.tokenErrHelper(w, errAccessDenied, "Invalid username or password", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the claims to send the id token
|
||||||
|
claims := storage.Claims{
|
||||||
|
UserID: identity.UserID,
|
||||||
|
Username: identity.Username,
|
||||||
|
PreferredUsername: identity.PreferredUsername,
|
||||||
|
Email: identity.Email,
|
||||||
|
EmailVerified: identity.EmailVerified,
|
||||||
|
Groups: identity.Groups,
|
||||||
|
}
|
||||||
|
|
||||||
|
accessToken := storage.NewID()
|
||||||
|
idToken, expiry, err := s.newIDToken(client.ID, claims, scopes, nonce, accessToken, connID)
|
||||||
|
if err != nil {
|
||||||
|
s.tokenErrHelper(w, errServerError, fmt.Sprintf("failed to create ID token: %v", err), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
reqRefresh := func() bool {
|
||||||
|
// Ensure the connector supports refresh tokens.
|
||||||
|
//
|
||||||
|
// Connectors like `saml` do not implement RefreshConnector.
|
||||||
|
_, ok := conn.Connector.(connector.RefreshConnector)
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, scope := range scopes {
|
||||||
|
if scope == scopeOfflineAccess {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}()
|
||||||
|
var refreshToken string
|
||||||
|
if reqRefresh {
|
||||||
|
refresh := storage.RefreshToken{
|
||||||
|
ID: storage.NewID(),
|
||||||
|
Token: storage.NewID(),
|
||||||
|
ClientID: clientID,
|
||||||
|
ConnectorID: connID,
|
||||||
|
Scopes: scopes,
|
||||||
|
Claims: claims,
|
||||||
|
Nonce: nonce,
|
||||||
|
// ConnectorData: authCode.ConnectorData,
|
||||||
|
CreatedAt: s.now(),
|
||||||
|
LastUsed: s.now(),
|
||||||
|
}
|
||||||
|
token := &internal.RefreshToken{
|
||||||
|
RefreshId: refresh.ID,
|
||||||
|
Token: refresh.Token,
|
||||||
|
}
|
||||||
|
if refreshToken, err = internal.Marshal(token); err != nil {
|
||||||
|
s.logger.Errorf("failed to marshal refresh token: %v", err)
|
||||||
|
s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.storage.CreateRefresh(refresh); err != nil {
|
||||||
|
s.logger.Errorf("failed to create refresh token: %v", err)
|
||||||
|
s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// deleteToken determines if we need to delete the newly created refresh token
|
||||||
|
// due to a failure in updating/creating the OfflineSession object for the
|
||||||
|
// corresponding user.
|
||||||
|
var deleteToken bool
|
||||||
|
defer func() {
|
||||||
|
if deleteToken {
|
||||||
|
// Delete newly created refresh token from storage.
|
||||||
|
if err := s.storage.DeleteRefresh(refresh.ID); err != nil {
|
||||||
|
s.logger.Errorf("failed to delete refresh token: %v", err)
|
||||||
|
s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
tokenRef := storage.RefreshTokenRef{
|
||||||
|
ID: refresh.ID,
|
||||||
|
ClientID: refresh.ClientID,
|
||||||
|
CreatedAt: refresh.CreatedAt,
|
||||||
|
LastUsed: refresh.LastUsed,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to retrieve an existing OfflineSession object for the corresponding user.
|
||||||
|
if session, err := s.storage.GetOfflineSessions(refresh.Claims.UserID, refresh.ConnectorID); err != nil {
|
||||||
|
if err != storage.ErrNotFound {
|
||||||
|
s.logger.Errorf("failed to get offline session: %v", err)
|
||||||
|
s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError)
|
||||||
|
deleteToken = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
offlineSessions := storage.OfflineSessions{
|
||||||
|
UserID: refresh.Claims.UserID,
|
||||||
|
ConnID: refresh.ConnectorID,
|
||||||
|
Refresh: make(map[string]*storage.RefreshTokenRef),
|
||||||
|
}
|
||||||
|
offlineSessions.Refresh[tokenRef.ClientID] = &tokenRef
|
||||||
|
|
||||||
|
// Create a new OfflineSession object for the user and add a reference object for
|
||||||
|
// the newly received refreshtoken.
|
||||||
|
if err := s.storage.CreateOfflineSessions(offlineSessions); err != nil {
|
||||||
|
s.logger.Errorf("failed to create offline session: %v", err)
|
||||||
|
s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError)
|
||||||
|
deleteToken = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if oldTokenRef, ok := session.Refresh[tokenRef.ClientID]; ok {
|
||||||
|
// Delete old refresh token from storage.
|
||||||
|
if err := s.storage.DeleteRefresh(oldTokenRef.ID); err != nil {
|
||||||
|
s.logger.Errorf("failed to delete refresh token: %v", err)
|
||||||
|
s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError)
|
||||||
|
deleteToken = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update existing OfflineSession obj with new RefreshTokenRef.
|
||||||
|
if err := s.storage.UpdateOfflineSessions(session.UserID, session.ConnID, func(old storage.OfflineSessions) (storage.OfflineSessions, error) {
|
||||||
|
old.Refresh[tokenRef.ClientID] = &tokenRef
|
||||||
|
return old, nil
|
||||||
|
}); err != nil {
|
||||||
|
s.logger.Errorf("failed to update offline session: %v", err)
|
||||||
|
s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError)
|
||||||
|
deleteToken = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
s.writeAccessToken(w, idToken, accessToken, refreshToken, expiry)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) writeAccessToken(w http.ResponseWriter, idToken, accessToken, refreshToken string, expiry time.Time) {
|
func (s *Server) writeAccessToken(w http.ResponseWriter, idToken, accessToken, refreshToken string, expiry time.Time) {
|
||||||
resp := struct {
|
resp := struct {
|
||||||
AccessToken string `json:"access_token"`
|
AccessToken string `json:"access_token"`
|
||||||
|
|
|
@ -121,6 +121,7 @@ const (
|
||||||
const (
|
const (
|
||||||
grantTypeAuthorizationCode = "authorization_code"
|
grantTypeAuthorizationCode = "authorization_code"
|
||||||
grantTypeRefreshToken = "refresh_token"
|
grantTypeRefreshToken = "refresh_token"
|
||||||
|
grantTypePassword = "password"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|
|
@ -76,6 +76,8 @@ type Config struct {
|
||||||
RotateKeysAfter time.Duration // Defaults to 6 hours.
|
RotateKeysAfter time.Duration // Defaults to 6 hours.
|
||||||
IDTokensValidFor time.Duration // Defaults to 24 hours
|
IDTokensValidFor time.Duration // Defaults to 24 hours
|
||||||
AuthRequestsValidFor time.Duration // Defaults to 24 hours
|
AuthRequestsValidFor time.Duration // Defaults to 24 hours
|
||||||
|
// If set, the server will use this connector to handle password grants
|
||||||
|
PasswordConnector string
|
||||||
|
|
||||||
GCFrequency time.Duration // Defaults to 5 minutes
|
GCFrequency time.Duration // Defaults to 5 minutes
|
||||||
|
|
||||||
|
@ -145,6 +147,9 @@ type Server struct {
|
||||||
// If enabled, show the connector selection screen even if there's only one
|
// If enabled, show the connector selection screen even if there's only one
|
||||||
alwaysShowLogin bool
|
alwaysShowLogin bool
|
||||||
|
|
||||||
|
// Used for password grant
|
||||||
|
passwordConnector string
|
||||||
|
|
||||||
supportedResponseTypes map[string]bool
|
supportedResponseTypes map[string]bool
|
||||||
|
|
||||||
now func() time.Time
|
now func() time.Time
|
||||||
|
@ -216,6 +221,7 @@ func newServer(ctx context.Context, c Config, rotationStrategy rotationStrategy)
|
||||||
alwaysShowLogin: c.AlwaysShowLoginScreen,
|
alwaysShowLogin: c.AlwaysShowLoginScreen,
|
||||||
now: now,
|
now: now,
|
||||||
templates: tmpls,
|
templates: tmpls,
|
||||||
|
passwordConnector: c.PasswordConnector,
|
||||||
logger: c.Logger,
|
logger: c.Logger,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue