f778b2d33b
The server implements a strategy called "Refresh Token Rotation" to ensure refresh tokens can only be claimed once. ref: https://tools.ietf.org/html/rfc6819#section-5.2.2.3 Previously "refresh_token" values in token responses where just the ID of the internal refresh object. To implement rotation, when a client redeemed a refresh token, the object would be deleted, a new one created, and the new ID returned as the new "refresh_token". However, this means there was no consistent ID for refresh tokens internally, making things like foreign keys very hard to implement. This is problematic for revocation features like showing all the refresh tokens a user or client has out. This PR updates the "refresh_token" to be an encoded protobuf message, which holds the internal ID and a nonce. When a refresh token is used, the nonce is updated to prevent reuse, but the ID remains the same. Additionally it adds the timestamp of each token's last use.
871 lines
28 KiB
Go
871 lines
28 KiB
Go
package server
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
"path"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gorilla/handlers"
|
|
"github.com/gorilla/mux"
|
|
jose "gopkg.in/square/go-jose.v2"
|
|
|
|
"github.com/coreos/dex/connector"
|
|
"github.com/coreos/dex/server/internal"
|
|
"github.com/coreos/dex/storage"
|
|
)
|
|
|
|
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
|
start := s.now()
|
|
err := func() error {
|
|
// Instead of trying to introspect health, just try to use the underlying storage.
|
|
a := storage.AuthRequest{
|
|
ID: storage.NewID(),
|
|
ClientID: storage.NewID(),
|
|
|
|
// Set a short expiry so if the delete fails this will be cleaned up quickly by garbage collection.
|
|
Expiry: s.now().Add(time.Minute),
|
|
}
|
|
|
|
if err := s.storage.CreateAuthRequest(a); err != nil {
|
|
return fmt.Errorf("create auth request: %v", err)
|
|
}
|
|
if err := s.storage.DeleteAuthRequest(a.ID); err != nil {
|
|
return fmt.Errorf("delete auth request: %v", err)
|
|
}
|
|
return nil
|
|
}()
|
|
|
|
t := s.now().Sub(start)
|
|
if err != nil {
|
|
s.logger.Errorf("Storage health check failed: %v", err)
|
|
s.renderError(w, http.StatusInternalServerError, "Health check failed.")
|
|
return
|
|
}
|
|
fmt.Fprintf(w, "Health check passed in %s", t)
|
|
}
|
|
|
|
func (s *Server) handlePublicKeys(w http.ResponseWriter, r *http.Request) {
|
|
// TODO(ericchiang): Cache this.
|
|
keys, err := s.storage.GetKeys()
|
|
if err != nil {
|
|
s.logger.Errorf("failed to get keys: %v", err)
|
|
s.renderError(w, http.StatusInternalServerError, "Internal server error.")
|
|
return
|
|
}
|
|
|
|
if keys.SigningKeyPub == nil {
|
|
s.logger.Errorf("No public keys found.")
|
|
s.renderError(w, http.StatusInternalServerError, "Internal server error.")
|
|
return
|
|
}
|
|
|
|
jwks := jose.JSONWebKeySet{
|
|
Keys: make([]jose.JSONWebKey, len(keys.VerificationKeys)+1),
|
|
}
|
|
jwks.Keys[0] = *keys.SigningKeyPub
|
|
for i, verificationKey := range keys.VerificationKeys {
|
|
jwks.Keys[i+1] = *verificationKey.PublicKey
|
|
}
|
|
|
|
data, err := json.MarshalIndent(jwks, "", " ")
|
|
if err != nil {
|
|
s.logger.Errorf("failed to marshal discovery data: %v", err)
|
|
s.renderError(w, http.StatusInternalServerError, "Internal server error.")
|
|
return
|
|
}
|
|
maxAge := keys.NextRotation.Sub(s.now())
|
|
if maxAge < (time.Minute * 2) {
|
|
maxAge = time.Minute * 2
|
|
}
|
|
|
|
w.Header().Set("Cache-Control", fmt.Sprintf("max-age=%d, must-revalidate", int(maxAge.Seconds())))
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.Header().Set("Content-Length", strconv.Itoa(len(data)))
|
|
w.Write(data)
|
|
}
|
|
|
|
type discovery struct {
|
|
Issuer string `json:"issuer"`
|
|
Auth string `json:"authorization_endpoint"`
|
|
Token string `json:"token_endpoint"`
|
|
Keys string `json:"jwks_uri"`
|
|
ResponseTypes []string `json:"response_types_supported"`
|
|
Subjects []string `json:"subject_types_supported"`
|
|
IDTokenAlgs []string `json:"id_token_signing_alg_values_supported"`
|
|
Scopes []string `json:"scopes_supported"`
|
|
AuthMethods []string `json:"token_endpoint_auth_methods_supported"`
|
|
Claims []string `json:"claims_supported"`
|
|
}
|
|
|
|
func (s *Server) discoveryHandler() (http.Handler, error) {
|
|
d := discovery{
|
|
Issuer: s.issuerURL.String(),
|
|
Auth: s.absURL("/auth"),
|
|
Token: s.absURL("/token"),
|
|
Keys: s.absURL("/keys"),
|
|
Subjects: []string{"public"},
|
|
IDTokenAlgs: []string{string(jose.RS256)},
|
|
Scopes: []string{"openid", "email", "groups", "profile", "offline_access"},
|
|
AuthMethods: []string{"client_secret_basic"},
|
|
Claims: []string{
|
|
"aud", "email", "email_verified", "exp",
|
|
"iat", "iss", "locale", "name", "sub",
|
|
},
|
|
}
|
|
|
|
for responseType := range s.supportedResponseTypes {
|
|
d.ResponseTypes = append(d.ResponseTypes, responseType)
|
|
}
|
|
sort.Strings(d.ResponseTypes)
|
|
|
|
data, err := json.MarshalIndent(d, "", " ")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to marshal discovery data: %v", err)
|
|
}
|
|
|
|
var discoveryHandler http.Handler
|
|
discoveryHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.Header().Set("Content-Length", strconv.Itoa(len(data)))
|
|
w.Write(data)
|
|
})
|
|
if len(s.discoveryAllowedOrigins) > 0 {
|
|
corsOption := handlers.AllowedOrigins(s.discoveryAllowedOrigins)
|
|
discoveryHandler = handlers.CORS(corsOption)(discoveryHandler)
|
|
}
|
|
|
|
return discoveryHandler, nil
|
|
}
|
|
|
|
// handleAuthorization handles the OAuth2 auth endpoint.
|
|
func (s *Server) handleAuthorization(w http.ResponseWriter, r *http.Request) {
|
|
authReq, err := s.parseAuthorizationRequest(r)
|
|
if err != nil {
|
|
s.logger.Errorf("Failed to parse authorization request: %v", err)
|
|
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)
|
|
s.renderError(w, http.StatusInternalServerError, "Failed to connect to the database.")
|
|
return
|
|
}
|
|
if len(s.connectors) == 1 {
|
|
for id := range s.connectors {
|
|
http.Redirect(w, r, s.absPath("/auth", id)+"?req="+authReq.ID, http.StatusFound)
|
|
return
|
|
}
|
|
}
|
|
|
|
connectorInfos := make([]connectorInfo, len(s.connectors))
|
|
i := 0
|
|
for id, conn := range s.connectors {
|
|
connectorInfos[i] = connectorInfo{
|
|
ID: id,
|
|
Name: conn.DisplayName,
|
|
URL: s.absPath("/auth", id),
|
|
}
|
|
i++
|
|
}
|
|
|
|
if err := s.templates.login(w, connectorInfos, authReq.ID); err != nil {
|
|
s.logger.Errorf("Server template error: %v", err)
|
|
}
|
|
}
|
|
|
|
func (s *Server) handleConnectorLogin(w http.ResponseWriter, r *http.Request) {
|
|
connID := mux.Vars(r)["connector"]
|
|
conn, ok := s.connectors[connID]
|
|
if !ok {
|
|
s.logger.Errorf("Failed to create authorization request.")
|
|
s.renderError(w, http.StatusBadRequest, "Requested resource does not exist.")
|
|
return
|
|
}
|
|
|
|
authReqID := r.FormValue("req")
|
|
|
|
authReq, err := s.storage.GetAuthRequest(authReqID)
|
|
if err != nil {
|
|
s.logger.Errorf("Failed to get auth request: %v", err)
|
|
s.renderError(w, http.StatusInternalServerError, "Database error.")
|
|
return
|
|
}
|
|
scopes := parseScopes(authReq.Scopes)
|
|
|
|
switch r.Method {
|
|
case "GET":
|
|
// Set the connector being used for the login.
|
|
updater := func(a storage.AuthRequest) (storage.AuthRequest, error) {
|
|
a.ConnectorID = connID
|
|
return a, nil
|
|
}
|
|
if err := s.storage.UpdateAuthRequest(authReqID, updater); err != nil {
|
|
s.logger.Errorf("Failed to set connector ID on auth request: %v", err)
|
|
s.renderError(w, http.StatusInternalServerError, "Database error.")
|
|
return
|
|
}
|
|
|
|
switch conn := conn.Connector.(type) {
|
|
case connector.CallbackConnector:
|
|
// Use the auth request ID as the "state" token.
|
|
//
|
|
// TODO(ericchiang): Is this appropriate or should we also be using a nonce?
|
|
callbackURL, err := conn.LoginURL(scopes, s.absURL("/callback"), authReqID)
|
|
if err != nil {
|
|
s.logger.Errorf("Connector %q returned error when creating callback: %v", connID, err)
|
|
s.renderError(w, http.StatusInternalServerError, "Login error.")
|
|
return
|
|
}
|
|
http.Redirect(w, r, callbackURL, http.StatusFound)
|
|
case connector.PasswordConnector:
|
|
if err := s.templates.password(w, authReqID, r.URL.String(), "", false); err != nil {
|
|
s.logger.Errorf("Server template error: %v", err)
|
|
}
|
|
case connector.SAMLConnector:
|
|
action, value, err := conn.POSTData(scopes)
|
|
if err != nil {
|
|
s.logger.Errorf("Creating SAML data: %v", err)
|
|
s.renderError(w, http.StatusInternalServerError, "Connector Login Error")
|
|
return
|
|
}
|
|
|
|
// TODO(ericchiang): Don't inline this.
|
|
fmt.Fprintf(w, `<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta http-equiv="content-type" content="text/html; charset=utf-8">
|
|
<title>SAML login</title>
|
|
</head>
|
|
<body>
|
|
<form method="post" action="%s" >
|
|
<input type="hidden" name="SAMLRequest" value="%s" />
|
|
<input type="hidden" name="RelayState" value="%s" />
|
|
</form>
|
|
<script>
|
|
document.forms[0].submit();
|
|
</script>
|
|
</body>
|
|
</html>`, action, value, authReqID)
|
|
default:
|
|
s.renderError(w, http.StatusBadRequest, "Requested resource does not exist.")
|
|
}
|
|
case "POST":
|
|
passwordConnector, ok := conn.Connector.(connector.PasswordConnector)
|
|
if !ok {
|
|
s.renderError(w, http.StatusBadRequest, "Requested resource does not exist.")
|
|
return
|
|
}
|
|
|
|
username := r.FormValue("login")
|
|
password := r.FormValue("password")
|
|
|
|
identity, ok, err := passwordConnector.Login(r.Context(), scopes, username, password)
|
|
if err != nil {
|
|
s.logger.Errorf("Failed to login user: %v", err)
|
|
s.renderError(w, http.StatusInternalServerError, "Login error.")
|
|
return
|
|
}
|
|
if !ok {
|
|
if err := s.templates.password(w, authReqID, r.URL.String(), username, true); err != nil {
|
|
s.logger.Errorf("Server template error: %v", err)
|
|
}
|
|
return
|
|
}
|
|
redirectURL, err := s.finalizeLogin(identity, authReq, conn.Connector)
|
|
if err != nil {
|
|
s.logger.Errorf("Failed to finalize login: %v", err)
|
|
s.renderError(w, http.StatusInternalServerError, "Login error.")
|
|
return
|
|
}
|
|
|
|
http.Redirect(w, r, redirectURL, http.StatusSeeOther)
|
|
default:
|
|
s.renderError(w, http.StatusBadRequest, "Unsupported request method.")
|
|
}
|
|
}
|
|
|
|
func (s *Server) handleConnectorCallback(w http.ResponseWriter, r *http.Request) {
|
|
var authID string
|
|
switch r.Method {
|
|
case "GET": // OAuth2 callback
|
|
if authID = r.URL.Query().Get("state"); authID == "" {
|
|
s.renderError(w, http.StatusBadRequest, "User session error.")
|
|
return
|
|
}
|
|
case "POST": // SAML POST binding
|
|
if authID = r.PostFormValue("RelayState"); authID == "" {
|
|
s.renderError(w, http.StatusBadRequest, "User session error.")
|
|
return
|
|
}
|
|
default:
|
|
s.renderError(w, http.StatusBadRequest, "Method not supported")
|
|
return
|
|
}
|
|
|
|
authReq, err := s.storage.GetAuthRequest(authID)
|
|
if err != nil {
|
|
if err == storage.ErrNotFound {
|
|
s.logger.Errorf("Invalid 'state' parameter provided: %v", err)
|
|
s.renderError(w, http.StatusInternalServerError, "Requested resource does not exist.")
|
|
return
|
|
}
|
|
s.logger.Errorf("Failed to get auth request: %v", err)
|
|
s.renderError(w, http.StatusInternalServerError, "Database error.")
|
|
return
|
|
}
|
|
|
|
conn, ok := s.connectors[authReq.ConnectorID]
|
|
if !ok {
|
|
s.renderError(w, http.StatusInternalServerError, "Requested resource does not exist.")
|
|
return
|
|
}
|
|
|
|
var identity connector.Identity
|
|
switch conn := conn.Connector.(type) {
|
|
case connector.CallbackConnector:
|
|
if r.Method != "GET" {
|
|
s.logger.Errorf("SAML request mapped to OAuth2 connector")
|
|
s.renderError(w, http.StatusBadRequest, "Invalid request")
|
|
return
|
|
}
|
|
identity, err = conn.HandleCallback(parseScopes(authReq.Scopes), r)
|
|
case connector.SAMLConnector:
|
|
if r.Method != "POST" {
|
|
s.logger.Errorf("OAuth2 request mapped to SAML connector")
|
|
s.renderError(w, http.StatusBadRequest, "Invalid request")
|
|
return
|
|
}
|
|
identity, err = conn.HandlePOST(parseScopes(authReq.Scopes), r.PostFormValue("SAMLResponse"))
|
|
default:
|
|
s.renderError(w, http.StatusInternalServerError, "Requested resource does not exist.")
|
|
return
|
|
}
|
|
|
|
if err != nil {
|
|
s.logger.Errorf("Failed to authenticate: %v", err)
|
|
s.renderError(w, http.StatusInternalServerError, "Failed to return user's identity.")
|
|
return
|
|
}
|
|
|
|
redirectURL, err := s.finalizeLogin(identity, authReq, conn.Connector)
|
|
if err != nil {
|
|
s.logger.Errorf("Failed to finalize login: %v", err)
|
|
s.renderError(w, http.StatusInternalServerError, "Login error.")
|
|
return
|
|
}
|
|
|
|
http.Redirect(w, r, redirectURL, http.StatusSeeOther)
|
|
}
|
|
|
|
func (s *Server) finalizeLogin(identity connector.Identity, authReq storage.AuthRequest, conn connector.Connector) (string, error) {
|
|
claims := storage.Claims{
|
|
UserID: identity.UserID,
|
|
Username: identity.Username,
|
|
Email: identity.Email,
|
|
EmailVerified: identity.EmailVerified,
|
|
Groups: identity.Groups,
|
|
}
|
|
|
|
updater := func(a storage.AuthRequest) (storage.AuthRequest, error) {
|
|
a.LoggedIn = true
|
|
a.Claims = claims
|
|
a.ConnectorData = identity.ConnectorData
|
|
return a, nil
|
|
}
|
|
if err := s.storage.UpdateAuthRequest(authReq.ID, updater); err != nil {
|
|
return "", fmt.Errorf("failed to update auth request: %v", err)
|
|
}
|
|
return path.Join(s.issuerURL.Path, "/approval") + "?req=" + authReq.ID, nil
|
|
}
|
|
|
|
func (s *Server) handleApproval(w http.ResponseWriter, r *http.Request) {
|
|
authReq, err := s.storage.GetAuthRequest(r.FormValue("req"))
|
|
if err != nil {
|
|
s.logger.Errorf("Failed to get auth request: %v", err)
|
|
s.renderError(w, http.StatusInternalServerError, "Database error.")
|
|
return
|
|
}
|
|
if !authReq.LoggedIn {
|
|
s.logger.Errorf("Auth request does not have an identity for approval")
|
|
s.renderError(w, http.StatusInternalServerError, "Login process not yet finalized.")
|
|
return
|
|
}
|
|
|
|
switch r.Method {
|
|
case "GET":
|
|
if s.skipApproval {
|
|
s.sendCodeResponse(w, r, authReq)
|
|
return
|
|
}
|
|
client, err := s.storage.GetClient(authReq.ClientID)
|
|
if err != nil {
|
|
s.logger.Errorf("Failed to get client %q: %v", authReq.ClientID, err)
|
|
s.renderError(w, http.StatusInternalServerError, "Failed to retrieve client.")
|
|
return
|
|
}
|
|
if err := s.templates.approval(w, authReq.ID, authReq.Claims.Username, client.Name, authReq.Scopes); err != nil {
|
|
s.logger.Errorf("Server template error: %v", err)
|
|
}
|
|
case "POST":
|
|
if r.FormValue("approval") != "approve" {
|
|
s.renderError(w, http.StatusInternalServerError, "Approval rejected.")
|
|
return
|
|
}
|
|
s.sendCodeResponse(w, r, authReq)
|
|
}
|
|
}
|
|
|
|
func (s *Server) sendCodeResponse(w http.ResponseWriter, r *http.Request, authReq storage.AuthRequest) {
|
|
if s.now().After(authReq.Expiry) {
|
|
s.renderError(w, http.StatusBadRequest, "User session has expired.")
|
|
return
|
|
}
|
|
|
|
if err := s.storage.DeleteAuthRequest(authReq.ID); err != nil {
|
|
if err != storage.ErrNotFound {
|
|
s.logger.Errorf("Failed to delete authorization request: %v", err)
|
|
s.renderError(w, http.StatusInternalServerError, "Internal server error.")
|
|
} else {
|
|
s.renderError(w, http.StatusBadRequest, "User session error.")
|
|
}
|
|
return
|
|
}
|
|
u, err := url.Parse(authReq.RedirectURI)
|
|
if err != nil {
|
|
s.renderError(w, http.StatusInternalServerError, "Invalid redirect URI.")
|
|
return
|
|
}
|
|
|
|
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{
|
|
ID: storage.NewID(),
|
|
ClientID: authReq.ClientID,
|
|
ConnectorID: authReq.ConnectorID,
|
|
Nonce: authReq.Nonce,
|
|
Scopes: authReq.Scopes,
|
|
Claims: authReq.Claims,
|
|
Expiry: s.now().Add(time.Minute * 30),
|
|
RedirectURI: authReq.RedirectURI,
|
|
ConnectorData: authReq.ConnectorData,
|
|
}
|
|
if err := s.storage.CreateAuthCode(code); err != nil {
|
|
s.logger.Errorf("Failed to create auth code: %v", err)
|
|
s.renderError(w, http.StatusInternalServerError, "Internal server error.")
|
|
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
|
|
}
|
|
case responseTypeToken:
|
|
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
|
|
}
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
func (s *Server) handleToken(w http.ResponseWriter, r *http.Request) {
|
|
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 = r.PostFormValue("client_id")
|
|
clientSecret = r.PostFormValue("client_secret")
|
|
}
|
|
|
|
client, err := s.storage.GetClient(clientID)
|
|
if err != nil {
|
|
if err != storage.ErrNotFound {
|
|
s.logger.Errorf("failed to get client: %v", err)
|
|
s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError)
|
|
} else {
|
|
s.tokenErrHelper(w, errInvalidClient, "Invalid client credentials.", http.StatusUnauthorized)
|
|
}
|
|
return
|
|
}
|
|
if client.Secret != clientSecret {
|
|
s.tokenErrHelper(w, errInvalidClient, "Invalid client credentials.", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
grantType := r.PostFormValue("grant_type")
|
|
switch grantType {
|
|
case grantTypeAuthorizationCode:
|
|
s.handleAuthCode(w, r, client)
|
|
case grantTypeRefreshToken:
|
|
s.handleRefreshToken(w, r, client)
|
|
default:
|
|
s.tokenErrHelper(w, errInvalidGrant, "", http.StatusBadRequest)
|
|
}
|
|
}
|
|
|
|
// handle an access token request https://tools.ietf.org/html/rfc6749#section-4.1.3
|
|
func (s *Server) handleAuthCode(w http.ResponseWriter, r *http.Request, client storage.Client) {
|
|
code := r.PostFormValue("code")
|
|
redirectURI := r.PostFormValue("redirect_uri")
|
|
|
|
authCode, err := s.storage.GetAuthCode(code)
|
|
if err != nil || s.now().After(authCode.Expiry) || authCode.ClientID != client.ID {
|
|
if err != storage.ErrNotFound {
|
|
s.logger.Errorf("failed to get auth code: %v", err)
|
|
s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError)
|
|
} else {
|
|
s.tokenErrHelper(w, errInvalidRequest, "Invalid or expired code parameter.", http.StatusBadRequest)
|
|
}
|
|
return
|
|
}
|
|
|
|
if authCode.RedirectURI != redirectURI {
|
|
s.tokenErrHelper(w, errInvalidRequest, "redirect_uri did not match URI from initial request.", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
idToken, expiry, err := s.newIDToken(client.ID, authCode.Claims, authCode.Scopes, authCode.Nonce)
|
|
if err != nil {
|
|
s.logger.Errorf("failed to create ID token: %v", err)
|
|
s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
if err := s.storage.DeleteAuthCode(code); err != nil {
|
|
s.logger.Errorf("failed to delete auth code: %v", err)
|
|
s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
reqRefresh := func() bool {
|
|
for _, scope := range authCode.Scopes {
|
|
if scope == scopeOfflineAccess {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}()
|
|
var refreshToken string
|
|
if reqRefresh {
|
|
refresh := storage.RefreshToken{
|
|
ID: storage.NewID(),
|
|
Token: storage.NewID(),
|
|
ClientID: authCode.ClientID,
|
|
ConnectorID: authCode.ConnectorID,
|
|
Scopes: authCode.Scopes,
|
|
Claims: authCode.Claims,
|
|
Nonce: authCode.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
|
|
}
|
|
}
|
|
s.writeAccessToken(w, idToken, refreshToken, expiry)
|
|
}
|
|
|
|
// handle a refresh token request https://tools.ietf.org/html/rfc6749#section-6
|
|
func (s *Server) handleRefreshToken(w http.ResponseWriter, r *http.Request, client storage.Client) {
|
|
code := r.PostFormValue("refresh_token")
|
|
scope := r.PostFormValue("scope")
|
|
if code == "" {
|
|
s.tokenErrHelper(w, errInvalidRequest, "No refresh token in request.", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
token := new(internal.RefreshToken)
|
|
if err := internal.Unmarshal(code, token); err != nil {
|
|
// For backward compatibility, assume the refresh_token is a raw refresh token ID
|
|
// if it fails to decode.
|
|
//
|
|
// Because refresh_token values that aren't unmarshable were generated by servers
|
|
// that don't have a Token value, we'll still reject any attempts to claim a
|
|
// refresh_token twice.
|
|
token = &internal.RefreshToken{RefreshId: code, Token: ""}
|
|
}
|
|
|
|
refresh, err := s.storage.GetRefresh(token.RefreshId)
|
|
if err != nil {
|
|
s.logger.Errorf("failed to get refresh token: %v", err)
|
|
if err == storage.ErrNotFound {
|
|
s.tokenErrHelper(w, errInvalidRequest, "Refresh token is invalid or has already been claimed by another client.", http.StatusBadRequest)
|
|
} else {
|
|
s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError)
|
|
}
|
|
return
|
|
}
|
|
if refresh.ClientID != client.ID {
|
|
s.logger.Errorf("client %s trying to claim token for client %s", client.ID, refresh.ClientID)
|
|
s.tokenErrHelper(w, errInvalidRequest, "Refresh token is invalid or has already been claimed by another client.", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if refresh.Token != token.Token {
|
|
s.logger.Errorf("refresh token with id %s claimed twice", refresh.ID)
|
|
s.tokenErrHelper(w, errInvalidRequest, "Refresh token is invalid or has already been claimed by another client.", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Per the OAuth2 spec, if the client has omitted the scopes, default to the original
|
|
// authorized scopes.
|
|
//
|
|
// https://tools.ietf.org/html/rfc6749#section-6
|
|
scopes := refresh.Scopes
|
|
if scope != "" {
|
|
requestedScopes := strings.Fields(scope)
|
|
var unauthorizedScopes []string
|
|
|
|
for _, s := range requestedScopes {
|
|
contains := func() bool {
|
|
for _, scope := range refresh.Scopes {
|
|
if s == scope {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}()
|
|
if !contains {
|
|
unauthorizedScopes = append(unauthorizedScopes, s)
|
|
}
|
|
}
|
|
|
|
if len(unauthorizedScopes) > 0 {
|
|
msg := fmt.Sprintf("Requested scopes contain unauthorized scope(s): %q.", unauthorizedScopes)
|
|
s.tokenErrHelper(w, errInvalidRequest, msg, http.StatusBadRequest)
|
|
return
|
|
}
|
|
scopes = requestedScopes
|
|
}
|
|
|
|
conn, ok := s.connectors[refresh.ConnectorID]
|
|
if !ok {
|
|
s.logger.Errorf("connector ID not found: %q", refresh.ConnectorID)
|
|
s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
ident := connector.Identity{
|
|
UserID: refresh.Claims.UserID,
|
|
Username: refresh.Claims.Username,
|
|
Email: refresh.Claims.Email,
|
|
EmailVerified: refresh.Claims.EmailVerified,
|
|
Groups: refresh.Claims.Groups,
|
|
ConnectorData: refresh.ConnectorData,
|
|
}
|
|
|
|
// Can the connector refresh the identity? If so, attempt to refresh the data
|
|
// in the connector.
|
|
//
|
|
// TODO(ericchiang): We may want a strict mode where connectors that don't implement
|
|
// this interface can't perform refreshing.
|
|
if refreshConn, ok := conn.Connector.(connector.RefreshConnector); ok {
|
|
newIdent, err := refreshConn.Refresh(r.Context(), parseScopes(scopes), ident)
|
|
if err != nil {
|
|
s.logger.Errorf("failed to refresh identity: %v", err)
|
|
s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
ident = newIdent
|
|
}
|
|
|
|
claims := storage.Claims{
|
|
UserID: ident.UserID,
|
|
Username: ident.Username,
|
|
Email: ident.Email,
|
|
EmailVerified: ident.EmailVerified,
|
|
Groups: ident.Groups,
|
|
}
|
|
|
|
idToken, expiry, err := s.newIDToken(client.ID, claims, scopes, refresh.Nonce)
|
|
if err != nil {
|
|
s.logger.Errorf("failed to create ID token: %v", err)
|
|
s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
newToken := &internal.RefreshToken{
|
|
RefreshId: refresh.ID,
|
|
Token: storage.NewID(),
|
|
}
|
|
rawNewToken, err := internal.Marshal(newToken)
|
|
if err != nil {
|
|
s.logger.Errorf("failed to marshal refresh token: %v", err)
|
|
s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
updater := func(old storage.RefreshToken) (storage.RefreshToken, error) {
|
|
if old.Token != refresh.Token {
|
|
return old, errors.New("refresh token claimed twice")
|
|
}
|
|
old.Token = newToken.Token
|
|
// Update the claims of the refresh token.
|
|
//
|
|
// UserID intentionally ignored for now.
|
|
old.Claims.Username = ident.Username
|
|
old.Claims.Email = ident.Email
|
|
old.Claims.EmailVerified = ident.EmailVerified
|
|
old.Claims.Groups = ident.Groups
|
|
old.ConnectorData = ident.ConnectorData
|
|
old.LastUsed = s.now()
|
|
return old, nil
|
|
}
|
|
if err := s.storage.UpdateRefreshToken(refresh.ID, updater); err != nil {
|
|
s.logger.Errorf("failed to update refresh token: %v", err)
|
|
s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
s.writeAccessToken(w, idToken, rawNewToken, expiry)
|
|
}
|
|
|
|
func (s *Server) writeAccessToken(w http.ResponseWriter, idToken, 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
|
|
// holding a specific structure.
|
|
resp := struct {
|
|
AccessToken string `json:"access_token"`
|
|
TokenType string `json:"token_type"`
|
|
ExpiresIn int `json:"expires_in"`
|
|
RefreshToken string `json:"refresh_token,omitempty"`
|
|
IDToken string `json:"id_token"`
|
|
}{
|
|
storage.NewID(),
|
|
"bearer",
|
|
int(expiry.Sub(s.now()).Seconds()),
|
|
refreshToken,
|
|
idToken,
|
|
}
|
|
data, err := json.Marshal(resp)
|
|
if err != nil {
|
|
s.logger.Errorf("failed to marshal access token response: %v", err)
|
|
s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.Header().Set("Content-Length", strconv.Itoa(len(data)))
|
|
w.Write(data)
|
|
}
|
|
|
|
func (s *Server) renderError(w http.ResponseWriter, status int, description string) {
|
|
if err := s.templates.err(w, http.StatusText(status), description); err != nil {
|
|
s.logger.Errorf("Server template error: %v", err)
|
|
}
|
|
}
|
|
|
|
func (s *Server) tokenErrHelper(w http.ResponseWriter, typ string, description string, statusCode int) {
|
|
if err := tokenErr(w, typ, description, statusCode); err != nil {
|
|
s.logger.Errorf("token error repsonse: %v", err)
|
|
}
|
|
}
|