dex/server/http.go
Frode Nordahl 5d284e08ae Change status code used for redirects from StatusTemporaryRedirect (307) to StatusFound (302)
HTTP code 307 aka. StatusTemporaryRedirect is used throughout the
project. However, the endpoints redirected to explicitly expects
the client to make a GET request.

If a HTTP client issues a POST request to a server and receives a
HTTP 307 redirect, it forwards the POST request to the new URL.

When using 302 the HTTP client will issue a GET request.

Fixes #287
2016-01-23 22:33:53 +01:00

558 lines
15 KiB
Go

package server
import (
"encoding/json"
"fmt"
"html/template"
"io"
"net/http"
"net/url"
"strings"
"time"
"github.com/coreos/go-oidc/jose"
"github.com/coreos/go-oidc/key"
"github.com/coreos/go-oidc/oauth2"
"github.com/coreos/go-oidc/oidc"
"github.com/coreos/pkg/health"
"github.com/jonboulle/clockwork"
"github.com/coreos/dex/client"
"github.com/coreos/dex/connector"
phttp "github.com/coreos/dex/pkg/http"
"github.com/coreos/dex/pkg/log"
)
const (
lastSeenMaxAge = time.Minute * 5
discoveryMaxAge = time.Hour * 24
)
var (
httpPathDiscovery = "/.well-known/openid-configuration"
httpPathToken = "/token"
httpPathKeys = "/keys"
httpPathAuth = "/auth"
httpPathHealth = "/health"
httpPathAPI = "/api"
httpPathRegister = "/register"
httpPathEmailVerify = "/verify-email"
httpPathVerifyEmailResend = "/resend-verify-email"
httpPathSendResetPassword = "/send-reset-password"
httpPathResetPassword = "/reset-password"
httpPathAcceptInvitation = "/accept-invitation"
httpPathDebugVars = "/debug/vars"
cookieLastSeen = "LastSeen"
cookieShowEmailVerifiedMessage = "ShowEmailVerifiedMessage"
)
func handleDiscoveryFunc(cfg oidc.ProviderConfig) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" {
w.Header().Set("Allow", "GET")
phttp.WriteError(w, http.StatusMethodNotAllowed, "GET only acceptable method")
return
}
b, err := json.Marshal(cfg)
if err != nil {
log.Errorf("Unable to marshal %#v to JSON: %v", cfg, err)
}
w.Header().Set("Cache-Control", fmt.Sprintf("public, max-age=%d", int(discoveryMaxAge.Seconds())))
w.Header().Set("Content-Type", "application/json")
w.Write(b)
}
}
func handleKeysFunc(km key.PrivateKeyManager, clock clockwork.Clock) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" {
w.Header().Set("Allow", "GET")
phttp.WriteError(w, http.StatusMethodNotAllowed, "GET only acceptable method")
return
}
jwks, err := km.JWKs()
if err != nil {
log.Errorf("Failed to get JWKs while serving HTTP request: %v", err)
phttp.WriteError(w, http.StatusInternalServerError, "")
return
}
keys := struct {
Keys []jose.JWK `json:"keys"`
}{
Keys: jwks,
}
b, err := json.Marshal(keys)
if err != nil {
log.Errorf("Unable to marshal signing key to JSON: %v", err)
}
exp := km.ExpiresAt()
w.Header().Set("Expires", exp.Format(time.RFC1123))
ttl := int(exp.Sub(clock.Now()).Seconds())
w.Header().Set("Cache-Control", fmt.Sprintf("public, max-age=%d", ttl))
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write(b)
}
}
type Link struct {
URL string
ID string
DisplayName string
}
type templateData struct {
Error bool
Message string
Instruction string
Detail string
Register bool
RegisterOrLoginURL string
MsgCode string
ShowEmailVerifiedMessage bool
Links []Link
}
// TODO(sym3tri): store this with the connector config
var connectorDisplayNameMap = map[string]string{
"google": "Google",
"local": "Email",
"github": "GitHub",
"bitbucket": "Bitbucket",
}
type Template interface {
Execute(io.Writer, interface{}) error
}
func execTemplate(w http.ResponseWriter, tpl Template, data interface{}) {
execTemplateWithStatus(w, tpl, data, http.StatusOK)
}
func execTemplateWithStatus(w http.ResponseWriter, tpl Template, data interface{}, status int) {
w.WriteHeader(status)
if err := tpl.Execute(w, data); err != nil {
log.Errorf("Error loading page: %q", err)
phttp.WriteError(w, http.StatusInternalServerError, "error loading page")
return
}
}
func renderLoginPage(w http.ResponseWriter, r *http.Request, srv OIDCServer, idpcs []connector.Connector, register bool, tpl *template.Template) {
if tpl == nil {
phttp.WriteError(w, http.StatusInternalServerError, "error loading login page")
return
}
td := templateData{
Message: "Error",
Instruction: "Please try again or contact the system administrator",
Register: register,
ShowEmailVerifiedMessage: consumeShowEmailVerifiedCookie(r, w),
}
// Render error if remote IdP connector errored and redirected here.
q := r.URL.Query()
e := q.Get("error")
connectorID := q.Get("connector_id")
if e != "" {
td.Error = true
td.Message = "Authentication Error"
remoteMsg := q.Get("error_description")
if remoteMsg == "" {
remoteMsg = q.Get("error")
}
if connectorID == "" {
td.Detail = remoteMsg
} else {
td.Detail = fmt.Sprintf("Error from %s: %s.", connectorID, remoteMsg)
}
execTemplate(w, tpl, td)
return
}
if q.Get("msg_code") != "" {
td.MsgCode = q.Get("msg_code")
}
// Render error message if client id is invalid.
clientID := q.Get("client_id")
cm, err := srv.ClientMetadata(clientID)
if err != nil {
log.Errorf("Failed fetching client %q from repo: %v", clientID, err)
td.Error = true
td.Message = "Server Error"
execTemplate(w, tpl, td)
return
}
if cm == nil {
td.Error = true
td.Message = "Authentication Error"
td.Detail = "Invalid client ID"
execTemplate(w, tpl, td)
return
}
if len(idpcs) == 0 {
td.Error = true
td.Message = "Server Error"
td.Instruction = "Unable to authenticate users at this time"
td.Detail = "Authentication service may be misconfigured"
execTemplate(w, tpl, td)
return
}
link := *r.URL
linkParams := link.Query()
if !register {
linkParams.Set("register", "1")
} else {
linkParams.Del("register")
}
linkParams.Del("msg_code")
linkParams.Del("show_connectors")
link.RawQuery = linkParams.Encode()
td.RegisterOrLoginURL = link.String()
var showConnectors map[string]struct{}
// Only show the following connectors, if param is present
if q.Get("show_connectors") != "" {
conns := strings.Split(q.Get("show_connectors"), ",")
if len(conns) != 0 {
showConnectors = make(map[string]struct{})
for _, connID := range conns {
showConnectors[connID] = struct{}{}
}
}
}
for _, idpc := range idpcs {
id := idpc.ID()
if showConnectors != nil {
if _, ok := showConnectors[id]; !ok {
continue
}
}
var link Link
link.ID = id
displayName, ok := connectorDisplayNameMap[id]
if !ok {
displayName = id
}
link.DisplayName = displayName
v := r.URL.Query()
v.Set("connector_id", idpc.ID())
v.Set("response_type", "code")
link.URL = httpPathAuth + "?" + v.Encode()
td.Links = append(td.Links, link)
}
execTemplate(w, tpl, td)
}
func handleAuthFunc(srv OIDCServer, idpcs []connector.Connector, tpl *template.Template, registrationEnabled bool) http.HandlerFunc {
idx := makeConnectorMap(idpcs)
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" {
w.Header().Set("Allow", "GET")
phttp.WriteError(w, http.StatusMethodNotAllowed, "GET only acceptable method")
return
}
q := r.URL.Query()
register := q.Get("register") == "1" && registrationEnabled
e := q.Get("error")
if e != "" {
sessionKey := q.Get("state")
if err := srv.KillSession(sessionKey); err != nil {
log.Errorf("Failed killing sessionKey %q: %v", sessionKey, err)
}
renderLoginPage(w, r, srv, idpcs, register, tpl)
return
}
connectorID := q.Get("connector_id")
idpc, ok := idx[connectorID]
if !ok {
renderLoginPage(w, r, srv, idpcs, register, tpl)
return
}
acr, err := oauth2.ParseAuthCodeRequest(q)
if err != nil {
log.Errorf("Invalid auth request: %v", err)
writeAuthError(w, err, acr.State)
return
}
cm, err := srv.ClientMetadata(acr.ClientID)
if err != nil {
log.Errorf("Failed fetching client %q from repo: %v", acr.ClientID, err)
writeAuthError(w, oauth2.NewError(oauth2.ErrorServerError), acr.State)
return
}
if cm == nil {
log.Errorf("Client %q not found", acr.ClientID)
writeAuthError(w, oauth2.NewError(oauth2.ErrorInvalidRequest), acr.State)
return
}
if len(cm.RedirectURLs) == 0 {
log.Errorf("Client %q has no redirect URLs", acr.ClientID)
writeAuthError(w, oauth2.NewError(oauth2.ErrorServerError), acr.State)
return
}
redirectURL, err := client.ValidRedirectURL(acr.RedirectURL, cm.RedirectURLs)
if err != nil {
switch err {
case (client.ErrorCantChooseRedirectURL):
log.Errorf("Request must provide redirect URL as client %q has registered many", acr.ClientID)
writeAuthError(w, oauth2.NewError(oauth2.ErrorInvalidRequest), acr.State)
return
case (client.ErrorInvalidRedirectURL):
log.Errorf("Request provided unregistered redirect URL: %s", acr.RedirectURL)
writeAuthError(w, oauth2.NewError(oauth2.ErrorInvalidRequest), acr.State)
return
case (client.ErrorNoValidRedirectURLs):
log.Errorf("There are no registered URLs for the requested client: %s", acr.RedirectURL)
writeAuthError(w, oauth2.NewError(oauth2.ErrorInvalidRequest), acr.State)
return
}
}
if acr.ResponseType != oauth2.ResponseTypeCode {
log.Errorf("unexpected ResponseType: %v: ", acr.ResponseType)
redirectAuthError(w, oauth2.NewError(oauth2.ErrorUnsupportedResponseType), acr.State, redirectURL)
return
}
// Check scopes.
var scopes []string
foundOpenIDScope := false
for _, scope := range acr.Scope {
switch scope {
case "openid":
foundOpenIDScope = true
scopes = append(scopes, scope)
case "offline_access":
// According to the spec, for offline_access scope, the client must
// use a response_type value that would result in an Authorization Code.
// Currently oauth2.ResponseTypeCode is the only supported response type,
// and it's been checked above, so we don't need to check it again here.
//
// TODO(yifan): Verify that 'consent' should be in 'prompt'.
scopes = append(scopes, scope)
default:
// Pass all other scopes.
scopes = append(scopes, scope)
}
}
if !foundOpenIDScope {
log.Errorf("Invalid auth request: missing 'openid' in 'scope'")
writeAuthError(w, oauth2.NewError(oauth2.ErrorInvalidRequest), acr.State)
return
}
nonce := q.Get("nonce")
key, err := srv.NewSession(connectorID, acr.ClientID, acr.State, redirectURL, nonce, register, acr.Scope)
if err != nil {
log.Errorf("Error creating new session: %v: ", err)
redirectAuthError(w, err, acr.State, redirectURL)
return
}
if register {
_, ok := idpc.(*connector.LocalConnector)
if ok {
q := url.Values{}
q.Set("code", key)
ru := httpPathRegister + "?" + q.Encode()
w.Header().Set("Location", ru)
w.WriteHeader(http.StatusFound)
return
}
}
var p string
if register {
p = "select_account consent"
}
if shouldReprompt(r) || register {
p = "select_account"
}
lu, err := idpc.LoginURL(key, p)
if err != nil {
log.Errorf("Connector.LoginURL failed: %v", err)
redirectAuthError(w, err, acr.State, redirectURL)
return
}
http.SetCookie(w, createLastSeenCookie())
w.Header().Set("Location", lu)
w.WriteHeader(http.StatusFound)
return
}
}
func handleTokenFunc(srv OIDCServer) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
w.Header().Set("Allow", "POST")
phttp.WriteError(w, http.StatusMethodNotAllowed, fmt.Sprintf("POST only acceptable method"))
return
}
err := r.ParseForm()
if err != nil {
log.Errorf("error parsing request: %v", err)
writeTokenError(w, oauth2.NewError(oauth2.ErrorInvalidRequest), "")
return
}
state := r.PostForm.Get("state")
user, password, ok := r.BasicAuth()
if !ok {
log.Errorf("error parsing basic auth")
writeTokenError(w, oauth2.NewError(oauth2.ErrorInvalidClient), state)
return
}
creds := oidc.ClientCredentials{ID: user, Secret: password}
var jwt *jose.JWT
var refreshToken string
grantType := r.PostForm.Get("grant_type")
switch grantType {
case oauth2.GrantTypeAuthCode:
code := r.PostForm.Get("code")
if code == "" {
log.Errorf("missing code param")
writeTokenError(w, oauth2.NewError(oauth2.ErrorInvalidRequest), state)
return
}
jwt, refreshToken, err = srv.CodeToken(creds, code)
if err != nil {
log.Errorf("couldn't exchange code for token: %v", err)
writeTokenError(w, err, state)
return
}
case oauth2.GrantTypeClientCreds:
jwt, err = srv.ClientCredsToken(creds)
if err != nil {
log.Errorf("couldn't creds for token: %v", err)
writeTokenError(w, err, state)
return
}
case oauth2.GrantTypeRefreshToken:
token := r.PostForm.Get("refresh_token")
if token == "" {
writeTokenError(w, oauth2.NewError(oauth2.ErrorInvalidRequest), state)
return
}
jwt, err = srv.RefreshToken(creds, token)
if err != nil {
writeTokenError(w, err, state)
return
}
default:
log.Errorf("unsupported grant: %v", grantType)
writeTokenError(w, oauth2.NewError(oauth2.ErrorUnsupportedGrantType), state)
return
}
t := oAuth2Token{
AccessToken: jwt.Encode(),
IDToken: jwt.Encode(),
TokenType: "bearer",
RefreshToken: refreshToken,
}
b, err := json.Marshal(t)
if err != nil {
log.Errorf("Failed marshaling %#v to JSON: %v", t, err)
writeTokenError(w, oauth2.NewError(oauth2.ErrorServerError), state)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write(b)
}
}
func makeHealthHandler(checks []health.Checkable) http.Handler {
return health.Checker{
Checks: checks,
}
}
type oAuth2Token struct {
AccessToken string `json:"access_token"`
IDToken string `json:"id_token"`
TokenType string `json:"token_type"`
RefreshToken string `json:"refresh_token,omitempty"`
}
func createLastSeenCookie() *http.Cookie {
now := time.Now()
return &http.Cookie{
HttpOnly: true,
Name: cookieLastSeen,
MaxAge: int(lastSeenMaxAge.Seconds()),
// For old IE, ignored by most browsers.
Expires: now.Add(lastSeenMaxAge),
}
}
// shouldReprompt determines if user should be re-prompted for login based on existence of a cookie.
func shouldReprompt(r *http.Request) bool {
_, err := r.Cookie(cookieLastSeen)
if err == nil {
return true
}
return false
}
func consumeShowEmailVerifiedCookie(r *http.Request, w http.ResponseWriter) bool {
_, err := r.Cookie(cookieShowEmailVerifiedMessage)
if err == nil {
deleteCookie(w, cookieShowEmailVerifiedMessage)
return true
}
return false
}
func deleteCookie(w http.ResponseWriter, name string) {
now := time.Now()
http.SetCookie(w, &http.Cookie{
Name: name,
MaxAge: -100,
Expires: now.Add(time.Second * -100),
})
}
func makeConnectorMap(idpcs []connector.Connector) map[string]connector.Connector {
idx := make(map[string]connector.Connector, len(idpcs))
for _, idpc := range idpcs {
idx[idpc.ID()] = idpc
}
return idx
}