Preserve continue URL on auth error re-login

Failed OIDC, magic-link, and SAML sign-ins sent users to /auth/error
without the post-login destination, so Sign in dropped OAuth flows
and deep links. Propagate a validated continue query through auth
error redirects, recover it from OIDC state when the IdP denies or
cancels login, and forward it from AuthErrorPage to /auth/login.

Signed-off-by: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
This commit is contained in:
Cursor Agent
2026-07-27 10:27:38 +00:00
committed by Bryan Frimin
parent 428d28fade
commit 9abea50507
8 changed files with 205 additions and 29 deletions

View File

@@ -35,10 +35,14 @@ const (
authErrorMagicLinkInvalid = "magic_link_invalid"
)
func redirectAuthError(w http.ResponseWriter, r *http.Request, code string) {
func redirectAuthError(w http.ResponseWriter, r *http.Request, code string, continueURL string) {
q := url.Values{}
q.Set("error", code)
if continueURL != "" {
q.Set("continue", continueURL)
}
redirectURL := url.URL{
Path: "/auth/error",
RawQuery: q.Encode(),

View File

@@ -35,11 +35,29 @@ func TestRedirectAuthError(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/connect/v1/oidc/google/callback", nil)
rec := httptest.NewRecorder()
redirectAuthError(rec, req, authErrorPersonalAccountNotAllowed)
redirectAuthError(rec, req, authErrorPersonalAccountNotAllowed, "")
assert.Equal(t, http.StatusFound, rec.Code)
location, err := rec.Result().Location()
require.NoError(t, err)
assert.Equal(t, "/auth/error", location.Path)
assert.Equal(t, authErrorPersonalAccountNotAllowed, location.Query().Get("error"))
assert.Empty(t, location.Query().Get("continue"))
}
func TestRedirectAuthErrorWithContinue(t *testing.T) {
t.Parallel()
req := httptest.NewRequest(http.MethodGet, "/api/connect/v1/oidc/google/callback", nil)
rec := httptest.NewRecorder()
continueURL := "/overview"
redirectAuthError(rec, req, authErrorAuthenticationFailed, continueURL)
assert.Equal(t, http.StatusFound, rec.Code)
location, err := rec.Result().Location()
require.NoError(t, err)
assert.Equal(t, "/auth/error", location.Path)
assert.Equal(t, authErrorAuthenticationFailed, location.Query().Get("error"))
assert.Equal(t, continueURL, location.Query().Get("continue"))
}

View File

@@ -21,6 +21,7 @@
package connect_v1
import (
"context"
"errors"
"net/http"
"strings"
@@ -60,6 +61,31 @@ func NewOIDCHandler(
}
}
func (h *OIDCHandler) redirectAuthError(w http.ResponseWriter, r *http.Request, code string, continueURL string) {
safeContinue := ""
if continueURL != "" {
if validated, ok := h.safeRedirect.Validate(r.Context(), continueURL); ok {
safeContinue = validated
}
}
redirectAuthError(w, r, code, safeContinue)
}
func (h *OIDCHandler) continueURLFromCallbackState(
ctx context.Context,
provider coredata.OIDCProvider,
stateParam string,
) string {
continueURL, err := h.iam.OIDCService.ContinueURLFromCallbackState(ctx, provider, stateParam)
if err != nil {
return ""
}
return continueURL
}
func (h *OIDCHandler) LoginHandler(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
@@ -116,7 +142,8 @@ func (h *OIDCHandler) CallbackHandler(w http.ResponseWriter, r *http.Request) {
log.String("error", errParam),
log.String("error_description", r.URL.Query().Get("error_description")),
)
redirectAuthError(w, r, authErrorAuthenticationFailed)
continueURL := h.continueURLFromCallbackState(ctx, provider, r.URL.Query().Get("state"))
h.redirectAuthError(w, r, authErrorAuthenticationFailed, continueURL)
return
}
@@ -125,7 +152,12 @@ func (h *OIDCHandler) CallbackHandler(w http.ResponseWriter, r *http.Request) {
code := r.URL.Query().Get("code")
if stateParam == "" || code == "" {
redirectAuthError(w, r, authErrorInvalidState)
continueURL := ""
if stateParam != "" {
continueURL = h.continueURLFromCallbackState(ctx, provider, stateParam)
}
h.redirectAuthError(w, r, authErrorInvalidState, continueURL)
return
}
@@ -133,25 +165,25 @@ func (h *OIDCHandler) CallbackHandler(w http.ResponseWriter, r *http.Request) {
identity, continueURL, organizationID, err := h.iam.OIDCService.HandleCallback(ctx, provider, stateParam, code)
if err != nil {
if _, ok := errors.AsType[*oidc.ErrPersonalAccountNotAllowed](err); ok {
redirectAuthError(w, r, authErrorPersonalAccountNotAllowed)
h.redirectAuthError(w, r, authErrorPersonalAccountNotAllowed, continueURL)
return
}
if _, ok := errors.AsType[*oidc.ErrEmailNotVerified](err); ok {
redirectAuthError(w, r, authErrorEmailNotVerified)
h.redirectAuthError(w, r, authErrorEmailNotVerified, continueURL)
return
}
if _, ok := errors.AsType[*oidc.ErrInvalidState](err); ok {
redirectAuthError(w, r, authErrorInvalidState)
h.redirectAuthError(w, r, authErrorInvalidState, continueURL)
return
}
h.logger.ErrorCtx(ctx, "cannot handle OIDC callback", log.Error(err))
redirectAuthError(w, r, authErrorAuthenticationFailed)
h.redirectAuthError(w, r, authErrorAuthenticationFailed, continueURL)
return
}
@@ -259,6 +291,21 @@ func NewMagicLinkHandler(
}
}
func (h *MagicLinkHandler) redirectAuthError(w http.ResponseWriter, r *http.Request, code string, token string) {
safeContinue := ""
if token != "" {
continueURL, err := h.iam.AuthService.MagicLinkContinueFromToken(token)
if err == nil && continueURL != nil && *continueURL != "" {
if validated, ok := h.safeRedirect.Validate(r.Context(), *continueURL); ok {
safeContinue = validated
}
}
}
redirectAuthError(w, r, code, safeContinue)
}
func (h *MagicLinkHandler) SendHandler(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
@@ -313,29 +360,33 @@ func (h *MagicLinkHandler) VerifyHandler(w http.ResponseWriter, r *http.Request)
token := r.URL.Query().Get("token")
if token == "" {
redirectAuthError(w, r, authErrorMagicLinkInvalid)
h.redirectAuthError(w, r, authErrorMagicLinkInvalid, "")
return
}
identity, session, continueURL, err := h.iam.AuthService.OpenSessionWithMagicLink(ctx, token)
if err != nil {
if _, ok := errors.AsType[*iam.ErrExpiredToken](err); ok {
redirectAuthError(w, r, authErrorMagicLinkExpired)
h.redirectAuthError(w, r, authErrorMagicLinkExpired, token)
return
}
if _, ok := errors.AsType[*iam.ErrTokenAlreadyUsed](err); ok {
redirectAuthError(w, r, authErrorMagicLinkAlreadyUsed)
h.redirectAuthError(w, r, authErrorMagicLinkAlreadyUsed, token)
return
}
if _, ok := errors.AsType[*iam.ErrInvalidToken](err); ok {
redirectAuthError(w, r, authErrorMagicLinkInvalid)
h.redirectAuthError(w, r, authErrorMagicLinkInvalid, token)
return
}
h.logger.ErrorCtx(ctx, "cannot open session with magic link", log.Error(err))
redirectAuthError(w, r, authErrorAuthenticationFailed)
h.redirectAuthError(w, r, authErrorAuthenticationFailed, token)
return
}

View File

@@ -21,6 +21,7 @@
package connect_v1
import (
"context"
"errors"
"fmt"
"net/http"
@@ -59,9 +60,28 @@ func (h *SAMLHandler) renderInternalServerError(w http.ResponseWriter) {
httpserver.RenderError(w, http.StatusInternalServerError, errors.New("internal server error"))
}
func (h *SAMLHandler) renderAssertionError(w http.ResponseWriter, r *http.Request, err error) {
func (h *SAMLHandler) continueURLFromRelayState(ctx context.Context, relayState string) string {
if len(relayState) <= gid.EncodedGIDSize {
return ""
}
unescapedContinueURL, err := url.QueryUnescape(relayState[gid.EncodedGIDSize:])
if err != nil {
return ""
}
safeContinue, ok := h.safeRedirect.Validate(ctx, unescapedContinueURL)
if !ok {
return ""
}
return safeContinue
}
func (h *SAMLHandler) renderAssertionError(w http.ResponseWriter, r *http.Request, relayState string, err error) {
h.logger.ErrorCtx(r.Context(), "cannot handle SAML assertion", log.Error(err))
redirectAuthError(w, r, authErrorAuthenticationFailed)
continueURL := h.continueURLFromRelayState(r.Context(), relayState)
redirectAuthError(w, r, authErrorAuthenticationFailed, continueURL)
}
func (h *SAMLHandler) MetadataHandler(w http.ResponseWriter, r *http.Request) {
@@ -106,7 +126,8 @@ func (h *SAMLHandler) ConsumeHandler(w http.ResponseWriter, r *http.Request) {
user, membership, err := h.iam.SAMLService.HandleAssertion(ctx, samlResponse, configID)
if err != nil {
h.renderAssertionError(w, r, err)
h.renderAssertionError(w, r, relayState, err)
return
}