zitadel/internal/api/ui/login/device_auth.go
Livio Spring 40082745f4
fix(login): allow fallback to local auth in case of IdP errors (#9178)
# Which Problems Are Solved

The current login will always prefer external authentication (through an
IdP) over local authentication. So as soon as either the user had
connected to an IdP or even when the login policy was just set up to
have an IdP allowed, users would be redirected to that IdP for
(re)authentication.
This could lead to problems, where the IdP was not available or any
other error occurred in the process (such as secret expired for
EntraID).
Even when local authentication (passkeys or password) was allowed for
the corresponding user, they would always be redirected to the IdP
again, preventing any authentication. If admins were affected, they
might not even be able to update the client secret of the IdP.

# How the Problems Are Solved

Errors during the external IdP flow are handled in an
`externalAuthFailed` function, which will check if the organisation
allows local authentication and if the user has set up such.
If either password or passkeys is set up, the corresponding login page
will be presented to the user. As already with local auth passkeys is
preferred over password authentication.
The user is informed that the external login failed and fail back to
local auth as an error on the corresponding page in a focused mode. Any
interaction or after 5 second the focus mode is disabled.

# Additional Changes

None.

# Additional Context

closes #6466
2025-01-15 10:39:28 +00:00

192 lines
6.8 KiB
Go

package login
import (
"errors"
"fmt"
"net/http"
"net/url"
"time"
"github.com/gorilla/mux"
"github.com/muhlemmer/gu"
"github.com/zitadel/logging"
"github.com/zitadel/zitadel/internal/api/authz"
"github.com/zitadel/zitadel/internal/api/http/middleware"
"github.com/zitadel/zitadel/internal/domain"
)
const (
tmplDeviceAuthUserCode = "device-usercode"
tmplDeviceAuthAction = "device-action"
)
func (l *Login) renderDeviceAuthUserCode(w http.ResponseWriter, r *http.Request, err error) {
if err != nil {
logging.WithError(err).Error()
}
translator := l.getTranslator(r.Context(), nil)
data := l.getBaseData(r, nil, translator, "DeviceAuth.Title", "DeviceAuth.UserCode.Description", err)
l.renderer.RenderTemplate(w, r, translator, l.renderer.Templates[tmplDeviceAuthUserCode], data, nil)
}
func (l *Login) renderDeviceAuthAction(w http.ResponseWriter, r *http.Request, authReq *domain.AuthRequest, scopes []string) {
translator := l.getTranslator(r.Context(), authReq)
data := &struct {
baseData
AuthRequestID string
Username string
ClientID string
Scopes []string
}{
baseData: l.getBaseData(r, authReq, translator, "DeviceAuth.Title", "DeviceAuth.Action.Description", nil),
AuthRequestID: authReq.ID,
Username: authReq.UserName,
ClientID: authReq.ApplicationID,
Scopes: scopes,
}
l.renderer.RenderTemplate(w, r, translator, l.renderer.Templates[tmplDeviceAuthAction], data, nil)
}
const (
deviceAuthAllowed = "allowed"
deviceAuthDenied = "denied"
)
// renderDeviceAuthDone renders success.html when the action was allowed and error.html when it was denied.
func (l *Login) renderDeviceAuthDone(w http.ResponseWriter, r *http.Request, authReq *domain.AuthRequest, action string) {
translator := l.getTranslator(r.Context(), authReq)
data := &struct {
baseData
Message string
}{
baseData: l.getBaseData(r, authReq, translator, "DeviceAuth.Title", "DeviceAuth.Done.Description", nil),
}
switch action {
case deviceAuthAllowed:
data.Message = translator.LocalizeFromRequest(r, "DeviceAuth.Done.Approved", nil)
l.renderer.RenderTemplate(w, r, translator, l.renderer.Templates[tmplSuccess], data, nil)
case deviceAuthDenied:
data.ErrMessage = translator.LocalizeFromRequest(r, "DeviceAuth.Done.Denied", nil)
l.renderer.RenderTemplate(w, r, translator, l.renderer.Templates[tmplError], data, nil)
}
}
// handleDeviceAuthUserCode serves the Device Authorization user code submission form.
// The "user_code" may be submitted by URL (GET) or form (POST).
// When a "user_code" is received and found through query,
// handleDeviceAuthUserCode will create a new AuthRequest in the repository.
// The user is then redirected to the /login endpoint to complete authentication.
//
// The agent ID from the context is set to the authentication request
// to ensure the complete login flow is completed from the same browser.
func (l *Login) handleDeviceAuthUserCode(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
err := r.ParseForm()
if err != nil {
w.WriteHeader(http.StatusBadRequest)
l.renderDeviceAuthUserCode(w, r, err)
return
}
userCode := r.Form.Get("user_code")
if userCode == "" {
if prompt, _ := url.QueryUnescape(r.Form.Get("prompt")); prompt != "" {
err = errors.New(prompt)
}
l.renderDeviceAuthUserCode(w, r, err)
return
}
deviceAuthReq, err := l.query.DeviceAuthRequestByUserCode(ctx, userCode)
if err != nil {
l.renderDeviceAuthUserCode(w, r, err)
return
}
userAgentID, ok := middleware.UserAgentIDFromCtx(ctx)
if !ok {
l.renderDeviceAuthUserCode(w, r, errors.New("internal error: agent ID missing"))
return
}
authRequest, err := l.authRepo.CreateAuthRequest(ctx, &domain.AuthRequest{
CreationDate: time.Now(),
BrowserInfo: domain.BrowserInfoFromRequest(r),
AgentID: userAgentID,
ApplicationID: deviceAuthReq.ClientID,
InstanceID: authz.GetInstance(ctx).InstanceID(),
Request: deviceAuthReq,
})
if err != nil {
l.renderDeviceAuthUserCode(w, r, err)
return
}
http.Redirect(w, r, l.renderer.pathPrefix+EndpointLogin+"?authRequestID="+authRequest.ID, http.StatusFound)
}
// redirectDeviceAuthStart redirects the user to the start point of
// the device authorization flow. A prompt can be set to inform the user
// of the reason why they are redirected back.
func (l *Login) redirectDeviceAuthStart(w http.ResponseWriter, r *http.Request, prompt string) {
values := make(url.Values)
values.Set("prompt", url.QueryEscape(prompt))
url := url.URL{
Path: l.renderer.pathPrefix + EndpointDeviceAuth,
RawQuery: values.Encode(),
}
http.Redirect(w, r, url.String(), http.StatusSeeOther)
}
// handleDeviceAuthAction is the handler where the user is redirected after login.
// The authRequest is checked if the login was indeed completed.
// When the action of "allowed" or "denied", the device authorization is updated accordingly.
// Else the user is presented with a page where they can choose / submit either action.
func (l *Login) handleDeviceAuthAction(w http.ResponseWriter, r *http.Request) {
authReq, err := l.ensureAuthRequest(r)
if err != nil {
l.redirectDeviceAuthStart(w, r, err.Error())
return
}
if !authReq.Done() {
l.redirectDeviceAuthStart(w, r, "authentication not completed")
return
}
authDev, ok := authReq.Request.(*domain.AuthRequestDevice)
if !ok {
l.redirectDeviceAuthStart(w, r, fmt.Sprintf("wrong auth request type: %T", authReq.Request))
return
}
action := mux.Vars(r)["action"]
switch action {
case deviceAuthAllowed:
_, err = l.command.ApproveDeviceAuth(r.Context(), authDev.DeviceCode, authReq.UserID, authReq.UserOrgID, authReq.UserAuthMethodTypes(), authReq.AuthTime, authReq.PreferredLanguage, authReq.ToUserAgent(), authReq.SessionID)
case deviceAuthDenied:
_, err = l.command.CancelDeviceAuth(r.Context(), authDev.DeviceCode, domain.DeviceAuthCanceledDenied)
default:
l.renderDeviceAuthAction(w, r, authReq, authDev.Scopes)
return
}
if err != nil {
l.redirectDeviceAuthStart(w, r, err.Error())
return
}
l.renderDeviceAuthDone(w, r, authReq, action)
}
// deviceAuthCallbackURL creates the callback URL with which the user
// is redirected back to the device authorization flow.
func (l *Login) deviceAuthCallbackURL(authRequestID string) string {
return l.renderer.pathPrefix + EndpointDeviceAuthAction + "?authRequestID=" + authRequestID
}
// RedirectDeviceAuthToPrefix allows users to use https://domain.com/device without the /ui/login prefix
// and redirects them to the prefixed endpoint.
// [rfc 8628](https://www.rfc-editor.org/rfc/rfc8628#section-3.2) recommends the URL to be as short as possible.
func RedirectDeviceAuthToPrefix(w http.ResponseWriter, r *http.Request) {
target := gu.PtrCopy(r.URL)
target.Path = HandlerPrefix + EndpointDeviceAuth
http.Redirect(w, r, target.String(), http.StatusFound)
}