diff --git a/pkg/iam/auth_service.go b/pkg/iam/auth_service.go index df9c24479..09a3615d2 100644 --- a/pkg/iam/auth_service.go +++ b/pkg/iam/auth_service.go @@ -726,6 +726,13 @@ func (s AuthService) OpenSessionWithMagicLink(ctx context.Context, tokenString s } else { return fmt.Errorf("cannot load identity by email: %w", err) } + } else if !identity.EmailAddressVerified { + identity.EmailAddressVerified = true + identity.UpdatedAt = now + + if err := identity.Update(ctx, tx); err != nil { + return fmt.Errorf("cannot update identity: %w", err) + } } session = coredata.NewRootSession(identity.ID, coredata.AuthMethodMagicLink, s.sessionDuration) diff --git a/pkg/server/api/connect/v1/magic_link_handler.go b/pkg/server/api/connect/v1/magic_link_handler.go new file mode 100644 index 000000000..7db3f4b73 --- /dev/null +++ b/pkg/server/api/connect/v1/magic_link_handler.go @@ -0,0 +1,176 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package connect_v1 + +import ( + "errors" + "net/http" + + "go.gearno.de/kit/httpserver" + "go.gearno.de/kit/log" + "go.probo.inc/probo/pkg/baseurl" + "go.probo.inc/probo/pkg/iam" + "go.probo.inc/probo/pkg/mail" + "go.probo.inc/probo/pkg/saferedirect" + "go.probo.inc/probo/pkg/securecookie" + "go.probo.inc/probo/pkg/server/api/authn" +) + +type MagicLinkHandler struct { + iam *iam.Service + proboBaseURL *baseurl.BaseURL + sessionCookie *authn.Cookie + safeRedirect *saferedirect.SafeRedirect + logger *log.Logger +} + +func NewMagicLinkHandler( + iamSvc *iam.Service, + proboBaseURL *baseurl.BaseURL, + cookieConfig securecookie.Config, + logger *log.Logger, + allowedHost saferedirect.AllowedHostFunc, +) *MagicLinkHandler { + return &MagicLinkHandler{ + iam: iamSvc, + proboBaseURL: proboBaseURL, + sessionCookie: authn.NewCookie(&cookieConfig), + safeRedirect: saferedirect.New(allowedHost), + logger: logger, + } +} + +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() + + if err := r.ParseForm(); err != nil { + httpserver.RenderError(w, http.StatusBadRequest, errors.New("invalid form data")) + return + } + + emailAddr, err := mail.ParseAddr(r.FormValue("email")) + if err != nil { + httpserver.RenderError(w, http.StatusBadRequest, errors.New("invalid email")) + return + } + + continueParam := r.FormValue("continue") + if continueParam == "" { + httpserver.RenderError(w, http.StatusBadRequest, errors.New("invalid magic link parameters")) + return + } + + safeContinue, ok := h.safeRedirect.Validate(ctx, continueParam) + if !ok { + httpserver.RenderError(w, http.StatusBadRequest, errors.New("invalid continue URL")) + return + } + + proboURL := h.proboBaseURL.String() + + req := &iam.SendMagicLinkRequest{ + Email: emailAddr, + URLPath: "/api/connect/v1/magic-link/verify", + MagicLinkBaseURL: &proboURL, + Continue: &safeContinue, + } + + if clientID := oauth2ClientIDFromContinueURL(safeContinue); clientID != "" { + req.OAuth2ClientIDRaw = &clientID + } + + if err := h.iam.AuthService.SendMagicLink(ctx, req); err != nil { + h.logger.ErrorCtx(ctx, "cannot send magic link", log.Error(err)) + httpserver.RenderError(w, http.StatusInternalServerError, errors.New("internal server error")) + + return + } + + w.WriteHeader(http.StatusNoContent) +} + +func (h *MagicLinkHandler) VerifyHandler(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + token := r.URL.Query().Get("token") + if token == "" { + 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 { + h.redirectAuthError(w, r, authErrorMagicLinkExpired, token) + + return + } + + if _, ok := errors.AsType[*iam.ErrTokenAlreadyUsed](err); ok { + h.redirectAuthError(w, r, authErrorMagicLinkAlreadyUsed, token) + + return + } + + if _, ok := errors.AsType[*iam.ErrInvalidToken](err); ok { + h.redirectAuthError(w, r, authErrorMagicLinkInvalid, token) + + return + } + + h.logger.ErrorCtx(ctx, "cannot open session with magic link", log.Error(err)) + h.redirectAuthError(w, r, authErrorAuthenticationFailed, token) + + return + } + + _ = identity + + h.sessionCookie.Set(w, session) + + metadata := OAuth2ServerMetadata( + h.proboBaseURL, + h.iam.OAuth2ScopeRegistry.RegisteredScopes(), + ) + + redirectURL := metadata.AuthorizationEndpoint.String() + if continueURL != nil && *continueURL != "" { + redirectURL = *continueURL + } + + http.Redirect(w, r, redirectURL, http.StatusFound) +} diff --git a/pkg/server/api/connect/v1/oidc_handler.go b/pkg/server/api/connect/v1/oidc_handler.go index cb02f15b7..3dd40049b 100644 --- a/pkg/server/api/connect/v1/oidc_handler.go +++ b/pkg/server/api/connect/v1/oidc_handler.go @@ -29,12 +29,10 @@ import ( "github.com/go-chi/chi/v5" "go.gearno.de/kit/httpserver" "go.gearno.de/kit/log" - "go.probo.inc/probo/pkg/baseurl" "go.probo.inc/probo/pkg/coredata" "go.probo.inc/probo/pkg/gid" "go.probo.inc/probo/pkg/iam" "go.probo.inc/probo/pkg/iam/oidc" - "go.probo.inc/probo/pkg/mail" "go.probo.inc/probo/pkg/saferedirect" "go.probo.inc/probo/pkg/securecookie" "go.probo.inc/probo/pkg/server/api/authn" @@ -266,144 +264,3 @@ func parseOIDCProvider(s string) (coredata.OIDCProvider, error) { return "", errors.New("unknown provider") } } - -type MagicLinkHandler struct { - iam *iam.Service - proboBaseURL *baseurl.BaseURL - sessionCookie *authn.Cookie - safeRedirect *saferedirect.SafeRedirect - logger *log.Logger -} - -func NewMagicLinkHandler( - iamSvc *iam.Service, - proboBaseURL *baseurl.BaseURL, - cookieConfig securecookie.Config, - logger *log.Logger, - allowedHost saferedirect.AllowedHostFunc, -) *MagicLinkHandler { - return &MagicLinkHandler{ - iam: iamSvc, - proboBaseURL: proboBaseURL, - sessionCookie: authn.NewCookie(&cookieConfig), - safeRedirect: saferedirect.New(allowedHost), - logger: logger, - } -} - -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() - - if err := r.ParseForm(); err != nil { - httpserver.RenderError(w, http.StatusBadRequest, errors.New("invalid form data")) - return - } - - emailAddr, err := mail.ParseAddr(r.FormValue("email")) - if err != nil { - httpserver.RenderError(w, http.StatusBadRequest, errors.New("invalid email")) - return - } - - continueParam := r.FormValue("continue") - if continueParam == "" { - httpserver.RenderError(w, http.StatusBadRequest, errors.New("invalid magic link parameters")) - return - } - - safeContinue, ok := h.safeRedirect.Validate(ctx, continueParam) - if !ok { - httpserver.RenderError(w, http.StatusBadRequest, errors.New("invalid continue URL")) - return - } - - proboURL := h.proboBaseURL.String() - - req := &iam.SendMagicLinkRequest{ - Email: emailAddr, - URLPath: "/api/connect/v1/magic-link/verify", - MagicLinkBaseURL: &proboURL, - Continue: &safeContinue, - } - - if clientID := oauth2ClientIDFromContinueURL(safeContinue); clientID != "" { - req.OAuth2ClientIDRaw = &clientID - } - - if err := h.iam.AuthService.SendMagicLink(ctx, req); err != nil { - h.logger.ErrorCtx(ctx, "cannot send magic link", log.Error(err)) - httpserver.RenderError(w, http.StatusInternalServerError, errors.New("internal server error")) - - return - } - - w.WriteHeader(http.StatusNoContent) -} - -func (h *MagicLinkHandler) VerifyHandler(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - - token := r.URL.Query().Get("token") - if token == "" { - 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 { - h.redirectAuthError(w, r, authErrorMagicLinkExpired, token) - - return - } - - if _, ok := errors.AsType[*iam.ErrTokenAlreadyUsed](err); ok { - h.redirectAuthError(w, r, authErrorMagicLinkAlreadyUsed, token) - - return - } - - if _, ok := errors.AsType[*iam.ErrInvalidToken](err); ok { - h.redirectAuthError(w, r, authErrorMagicLinkInvalid, token) - - return - } - - h.logger.ErrorCtx(ctx, "cannot open session with magic link", log.Error(err)) - h.redirectAuthError(w, r, authErrorAuthenticationFailed, token) - - return - } - - _ = identity - - h.sessionCookie.Set(w, session) - - metadata := OAuth2ServerMetadata( - h.proboBaseURL, - h.iam.OAuth2ScopeRegistry.RegisteredScopes(), - ) - - redirectURL := metadata.AuthorizationEndpoint.String() - if continueURL != nil && *continueURL != "" { - redirectURL = *continueURL - } - - http.Redirect(w, r, redirectURL, http.StatusFound) -}