diff --git a/pkg/server/api/connect/v1/oauth2_handler.go b/pkg/server/api/connect/v1/oauth2_handler.go index 1d1a87d5b..e16de1910 100644 --- a/pkg/server/api/connect/v1/oauth2_handler.go +++ b/pkg/server/api/connect/v1/oauth2_handler.go @@ -32,6 +32,7 @@ import ( "go.gearno.de/kit/log" "go.probo.inc/probo/pkg/baseurl" "go.probo.inc/probo/pkg/bearertoken" + trust "go.probo.inc/probo/pkg/complianceportal/visitor" "go.probo.inc/probo/pkg/coredata" "go.probo.inc/probo/pkg/iam" "go.probo.inc/probo/pkg/iam/oauth2" @@ -42,23 +43,29 @@ import ( ) type OAuth2Handler struct { - iam *iam.Service - sessionCookie *authn.Cookie - baseURL *baseurl.BaseURL - logger *log.Logger + iam *iam.Service + trust *trust.Service + sessionCookie *authn.Cookie + baseURL *baseurl.BaseURL + portalLoginPath string + logger *log.Logger } func NewOAuth2Handler( svc *iam.Service, + trustSvc *trust.Service, cookieConfig securecookie.Config, baseURL *baseurl.BaseURL, + portalLoginPath string, logger *log.Logger, ) *OAuth2Handler { return &OAuth2Handler{ - iam: svc, - sessionCookie: authn.NewCookie(&cookieConfig), - baseURL: baseURL, - logger: logger.Named("oauth2"), + iam: svc, + trust: trustSvc, + sessionCookie: authn.NewCookie(&cookieConfig), + baseURL: baseURL, + portalLoginPath: portalLoginPath, + logger: logger.Named("oauth2"), } } @@ -102,27 +109,15 @@ func (h *OAuth2Handler) BearerTokenMiddleware(next http.Handler) http.Handler { }) } -func (h *OAuth2Handler) endpoints() oauth2.Endpoints { - api := h.baseURL.String() + "/api/connect/v1" - - return oauth2.Endpoints{ - Authorization: uri.URI(api + "/oauth2/authorize"), - Token: uri.URI(api + "/oauth2/token"), - Userinfo: uri.URI(api + "/oauth2/userinfo"), - JWKS: uri.URI(api + "/oauth2/jwks"), - Registration: uri.URI(api + "/oauth2/register"), - Introspection: uri.URI(api + "/oauth2/introspect"), - Revocation: uri.URI(api + "/oauth2/revoke"), - DeviceAuthorization: uri.URI(api + "/oauth2/device"), - } -} - // --- Handlers --- // DiscoveryHandler serves the OpenID Connect Discovery document. // GET /.well-known/openid-configuration func (h *OAuth2Handler) DiscoveryHandler(w http.ResponseWriter, r *http.Request) { - metadata := h.iam.OAuth2ServerMetadata(h.endpoints()) + metadata := OAuth2ServerMetadata( + h.baseURL, + h.iam.OAuth2ScopeRegistry.RegisteredScopes(), + ) PublicCache(w, 1*time.Hour) httpserver.RenderJSON(w, http.StatusOK, metadata) @@ -142,9 +137,29 @@ func (h *OAuth2Handler) JWKSHandler(w http.ResponseWriter, r *http.Request) { func (h *OAuth2Handler) AuthorizeHandler(w http.ResponseWriter, r *http.Request) { identity := authn.IdentityFromContext(r.Context()) if identity == nil { - continueURL := h.baseURL.WithPath("/api/connect/v1/oauth2/authorize"). - WithQueryValues(r.URL.Query()). - MustString() + metadata := OAuth2ServerMetadata( + h.baseURL, + h.iam.OAuth2ScopeRegistry.RegisteredScopes(), + ) + + continueURL, err := oauth2.AuthorizationURLWithQuery(metadata.AuthorizationEndpoint, r.URL.Query()) + if err != nil { + h.logger.ErrorCtx(r.Context(), "cannot build authorization continue URL", log.Error(err)) + httpserver.RenderError(w, http.StatusInternalServerError, errors.New("internal server error")) + + return + } + + clientID := r.URL.Query().Get("client_id") + if _, err := portalFromCIMDClientID(r.Context(), h.trust, clientID); err == nil { + q := url.Values{} + q.Set("authorize", r.URL.Query().Encode()) + loginURL := h.baseURL.WithPath(h.portalLoginPath).WithQueryValues(q).MustString() + http.Redirect(w, r, loginURL, http.StatusFound) + + return + } + loginURL := h.baseURL.WithPath("/auth/login"). WithQuery("continue", continueURL). MustString() @@ -468,7 +483,7 @@ func (h *OAuth2Handler) handleAuthorizationCodeGrant(w http.ResponseWriter, r *h result, err := h.iam.OAuth2ServerService.ExchangeAuthorizationCode( r.Context(), - client, + client.ExternalClientID, in.Code, in.RedirectURI, in.CodeVerifier, diff --git a/pkg/server/api/connect/v1/oauth2_metadata.go b/pkg/server/api/connect/v1/oauth2_metadata.go new file mode 100644 index 000000000..41642e049 --- /dev/null +++ b/pkg/server/api/connect/v1/oauth2_metadata.go @@ -0,0 +1,53 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package connect_v1 + +import ( + "go.probo.inc/probo/pkg/baseurl" + "go.probo.inc/probo/pkg/coredata" + "go.probo.inc/probo/pkg/iam/oauth2" + "go.probo.inc/probo/pkg/uri" +) + +const ( + oauth2AuthorizePath = "/oauth2/authorize" + oauth2TokenPath = "/oauth2/token" + oauth2UserinfoPath = "/oauth2/userinfo" + oauth2JWKSPath = "/oauth2/jwks" + oauth2RegisterPath = "/oauth2/register" + oauth2IntrospectPath = "/oauth2/introspect" + oauth2RevokePath = "/oauth2/revoke" + oauth2DeviceAuthorizationPath = "/oauth2/device" +) + +func OAuth2ServerMetadata( + baseURL *baseurl.BaseURL, + registeredScopes []coredata.OAuth2Scope, +) *oauth2.ServerMetadata { + return oauth2.NewMetadata(uri.URI(baseURL.String()), oauth2Endpoints(baseURL), registeredScopes) +} + +func oauth2Endpoints(baseURL *baseurl.BaseURL) oauth2.Endpoints { + return oauth2.Endpoints{ + Authorization: uri.URI(baseURL.WithPath("/api/connect/v1" + oauth2AuthorizePath).MustString()), + Token: uri.URI(baseURL.WithPath("/api/connect/v1" + oauth2TokenPath).MustString()), + Userinfo: uri.URI(baseURL.WithPath("/api/connect/v1" + oauth2UserinfoPath).MustString()), + JWKS: uri.URI(baseURL.WithPath("/api/connect/v1" + oauth2JWKSPath).MustString()), + Registration: uri.URI(baseURL.WithPath("/api/connect/v1" + oauth2RegisterPath).MustString()), + Introspection: uri.URI(baseURL.WithPath("/api/connect/v1" + oauth2IntrospectPath).MustString()), + Revocation: uri.URI(baseURL.WithPath("/api/connect/v1" + oauth2RevokePath).MustString()), + DeviceAuthorization: uri.URI(baseURL.WithPath("/api/connect/v1" + oauth2DeviceAuthorizationPath).MustString()), + } +} diff --git a/pkg/server/api/connect/v1/oauth2_metadata_test.go b/pkg/server/api/connect/v1/oauth2_metadata_test.go new file mode 100644 index 000000000..e75822bd3 --- /dev/null +++ b/pkg/server/api/connect/v1/oauth2_metadata_test.go @@ -0,0 +1,45 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package connect_v1_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "go.probo.inc/probo/pkg/baseurl" + "go.probo.inc/probo/pkg/coredata" + connect_v1 "go.probo.inc/probo/pkg/server/api/connect/v1" + "go.probo.inc/probo/pkg/uri" +) + +func TestOAuth2ServerMetadata(t *testing.T) { + t.Parallel() + + baseURL := baseurl.MustParse("https://auth.example.com") + metadata := connect_v1.OAuth2ServerMetadata(baseURL, nil) + + assert.Equal(t, uri.URI("https://auth.example.com"), metadata.Issuer) + assert.Equal( + t, + uri.URI("https://auth.example.com/api/connect/v1/oauth2/authorize"), + metadata.AuthorizationEndpoint, + ) + assert.Equal( + t, + uri.URI("https://auth.example.com/api/connect/v1/oauth2/token"), + metadata.TokenEndpoint, + ) + assert.Contains(t, metadata.ScopesSupported, coredata.OAuth2Scope("openid")) +} diff --git a/pkg/server/api/connect/v1/oidc_handler.go b/pkg/server/api/connect/v1/oidc_handler.go index 6b06ec800..c9c31cbf3 100644 --- a/pkg/server/api/connect/v1/oidc_handler.go +++ b/pkg/server/api/connect/v1/oidc_handler.go @@ -30,25 +30,23 @@ import ( "github.com/go-chi/chi/v5" "go.gearno.de/kit/httpserver" "go.gearno.de/kit/log" + "go.probo.inc/probo/pkg/baseurl" + trust "go.probo.inc/probo/pkg/complianceportal/visitor" "go.probo.inc/probo/pkg/coredata" "go.probo.inc/probo/pkg/gid" "go.probo.inc/probo/pkg/iam" + "go.probo.inc/probo/pkg/iam/oauth2" + "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" ) -// IsTrustCenterDomainFunc checks whether a given host is a trust center -// custom domain. -type IsTrustCenterDomainFunc func(ctx context.Context, host string) bool - type OIDCHandler struct { - iam *iam.Service - sessionCookie *authn.Cookie - cookieSecret string - logger *log.Logger - safeRedirect *saferedirect.SafeRedirect - isTrustCenterDomain IsTrustCenterDomainFunc + iam *iam.Service + sessionCookie *authn.Cookie + logger *log.Logger + safeRedirect *saferedirect.SafeRedirect } func NewOIDCHandler( @@ -56,15 +54,12 @@ func NewOIDCHandler( cookieConfig securecookie.Config, logger *log.Logger, allowedHost saferedirect.AllowedHostFunc, - isTrustCenterDomain IsTrustCenterDomainFunc, ) *OIDCHandler { return &OIDCHandler{ - iam: iam, - sessionCookie: authn.NewCookie(&cookieConfig), - cookieSecret: cookieConfig.Secret, - logger: logger, - safeRedirect: saferedirect.New(allowedHost), - isTrustCenterDomain: isTrustCenterDomain, + iam: iam, + sessionCookie: authn.NewCookie(&cookieConfig), + logger: logger, + safeRedirect: saferedirect.New(allowedHost), } } @@ -210,47 +205,9 @@ func (h *OIDCHandler) CallbackHandler(w http.ResponseWriter, r *http.Request) { } redirectURL := h.safeRedirect.GetSafeRedirectURL(ctx, continueURL, defaultRedirect) - - if transferURL, ok := h.buildSessionTransferURL(ctx, redirectURL, rootSession.ID.String()); ok { - http.Redirect(w, r, transferURL, http.StatusFound) - return - } - http.Redirect(w, r, redirectURL, http.StatusFound) } -// buildSessionTransferURL returns a session-transfer URL on the target trust -// center custom domain. This lets the custom domain set its own cookie for the -// session. -func (h *OIDCHandler) buildSessionTransferURL(ctx context.Context, redirectURL string, sessionID string) (string, bool) { - parsed, err := url.Parse(redirectURL) - if err != nil || !parsed.IsAbs() { - return "", false - } - - if !h.isTrustCenterDomain(ctx, parsed.Host) { - return "", false - } - - token, err := authn.SignSessionTransfer(sessionID, redirectURL, h.cookieSecret) - if err != nil { - h.logger.ErrorCtx(ctx, "cannot sign session transfer token", log.Error(err)) - return "", false - } - - transferURL := &url.URL{ - Scheme: parsed.Scheme, - Host: parsed.Host, - Path: "/api/trust/v1/session-transfer", - } - - q := transferURL.Query() - q.Set("token", token) - transferURL.RawQuery = q.Encode() - - return transferURL.String(), true -} - func parseOIDCProvider(s string) (coredata.OIDCProvider, error) { switch strings.ToLower(s) { case "google": @@ -261,3 +218,185 @@ func parseOIDCProvider(s string) (coredata.OIDCProvider, error) { return "", errors.New("unknown provider") } } + +type MagicLinkHandler struct { + iam *iam.Service + trust *trust.Service + proboBaseURL *baseurl.BaseURL + sessionCookie *authn.Cookie + safeRedirect *saferedirect.SafeRedirect + logger *log.Logger +} + +func NewMagicLinkHandler( + iamSvc *iam.Service, + trustSvc *trust.Service, + proboBaseURL *baseurl.BaseURL, + cookieConfig securecookie.Config, + logger *log.Logger, +) *MagicLinkHandler { + return &MagicLinkHandler{ + iam: iamSvc, + trust: trustSvc, + proboBaseURL: proboBaseURL, + sessionCookie: authn.NewCookie(&cookieConfig), + safeRedirect: saferedirect.New(nil), + logger: logger, + } +} + +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 + } + + authorizeContinue := h.authorizeContinueURL(r.FormValue("authorize")) + if authorizeContinue == "" { + httpserver.RenderError(w, http.StatusBadRequest, errors.New("invalid authorize parameters")) + return + } + + compliancePageID, organizationID, err := h.portalIDsFromAuthorize(ctx, r.FormValue("authorize")) + if err != nil { + h.logger.WarnCtx(ctx, "cannot resolve compliance portal from authorize params", log.Error(err)) + httpserver.RenderError(w, http.StatusBadRequest, errors.New("invalid authorize parameters")) + + return + } + + proboURL := h.proboBaseURL.String() + + req := &iam.SendMagicLinkRequest{ + Email: emailAddr, + CompliancePageID: compliancePageID, + OrganizationID: organizationID, + URLPath: "/api/connect/v1/magic-link/verify", + Continue: &authorizeContinue, + MagicLinkBaseURL: &proboURL, + } + + 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 == "" { + httpserver.RenderError(w, http.StatusBadRequest, errors.New("missing token")) + return + } + + identity, session, continueURL, err := h.iam.AuthService.OpenSessionWithMagicLink(ctx, token) + if err != nil { + if _, ok := errors.AsType[*iam.ErrExpiredToken](err); ok { + http.Redirect(w, r, "/auth/magic-link-expired", http.StatusFound) + return + } + + if _, ok := errors.AsType[*iam.ErrTokenAlreadyUsed](err); ok { + http.Redirect(w, r, "/auth/magic-link-already-used", http.StatusFound) + return + } + + if _, ok := errors.AsType[*iam.ErrInvalidToken](err); ok { + httpserver.RenderError(w, http.StatusBadRequest, errors.New("invalid token")) + return + } + + h.logger.ErrorCtx(ctx, "cannot open session with magic link", log.Error(err)) + httpserver.RenderError(w, http.StatusInternalServerError, errors.New("internal server error")) + + 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) +} + +func (h *MagicLinkHandler) authorizeContinueURL(encodedAuthorize string) string { + if encodedAuthorize == "" { + return "" + } + + values, err := url.ParseQuery(encodedAuthorize) + if err != nil { + return "" + } + + metadata := OAuth2ServerMetadata( + h.proboBaseURL, + h.iam.OAuth2ScopeRegistry.RegisteredScopes(), + ) + + continueURL, err := oauth2.AuthorizationURLWithQuery(metadata.AuthorizationEndpoint, values) + if err != nil { + return "" + } + + return continueURL +} + +func portalFromCIMDClientID( + ctx context.Context, + trustSvc *trust.Service, + clientID string, +) (*coredata.TrustCenter, error) { + host, ok := oauth2.CIMDClientIDHost(clientID) + if !ok { + return nil, errors.New("invalid cimd client_id") + } + + portal, err := trustSvc.GetPortalByDomainName(ctx, host) + if err != nil { + return nil, err + } + + return portal, nil +} + +func (h *MagicLinkHandler) portalIDsFromAuthorize( + ctx context.Context, + encodedAuthorize string, +) (*gid.GID, gid.GID, error) { + values, err := url.ParseQuery(encodedAuthorize) + if err != nil { + return nil, gid.GID{}, err + } + + portal, err := portalFromCIMDClientID(ctx, h.trust, values.Get("client_id")) + if err != nil { + return nil, gid.GID{}, err + } + + return &portal.ID, portal.OrganizationID, nil +} diff --git a/pkg/server/api/connect/v1/resolver.go b/pkg/server/api/connect/v1/resolver.go index a2b2202b1..e0e06c4fe 100644 --- a/pkg/server/api/connect/v1/resolver.go +++ b/pkg/server/api/connect/v1/resolver.go @@ -49,6 +49,7 @@ import ( "github.com/go-chi/chi/v5" "go.gearno.de/kit/log" "go.probo.inc/probo/pkg/baseurl" + trust "go.probo.inc/probo/pkg/complianceportal/visitor" "go.probo.inc/probo/pkg/filemanager" "go.probo.inc/probo/pkg/gid" "go.probo.inc/probo/pkg/iam" @@ -77,12 +78,12 @@ type ( func NewMux( logger *log.Logger, svc *iam.Service, + trustSvc *trust.Service, cookieConfig securecookie.Config, tokenSecret string, fileManagerSvc *filemanager.Service, baseURL *baseurl.BaseURL, allowedRedirectHost saferedirect.AllowedHostFunc, - isTrustCenterDomain IsTrustCenterDomainFunc, graphqlLimits gqlutils.Limits, ) *chi.Mux { r := chi.NewMux() @@ -101,7 +102,24 @@ func NewMux( oauth2Middleware, ) - oidcHandler := NewOIDCHandler(svc, cookieConfig, logger, allowedRedirectHost, isTrustCenterDomain) + oidcHandler := NewOIDCHandler(svc, cookieConfig, logger, allowedRedirectHost) + + magicLinkHandler := NewMagicLinkHandler( + svc, + trustSvc, + baseURL, + cookieConfig, + logger, + ) + + oauth2Handler := NewOAuth2Handler( + svc, + trustSvc, + cookieConfig, + baseURL, + "/auth/portal-login", + logger, + ) router.Handle("/graphql", graphqlHandler) router.Get("/saml/2.0/metadata", samlHandler.MetadataHandler) @@ -110,32 +128,34 @@ func NewMux( router.Get("/oidc/{provider}/login", oidcHandler.LoginHandler) router.Get("/oidc/{provider}/callback", oidcHandler.CallbackHandler) + r.Post("/magic-link/send", magicLinkHandler.SendHandler) + r.Get("/magic-link/verify", magicLinkHandler.VerifyHandler) + // SCIM 2.0 endpoints - these use their own bearer token authentication scimServer := NewSCIMServer(scimHandler) r.Mount("/scim/2.0", http.StripPrefix("/scim/2.0", scimHandler.BearerTokenMiddleware(scimServer))) // OAuth2 / OpenID Connect server endpoints. - oauth2Handler := NewOAuth2Handler(svc, cookieConfig, baseURL, logger) // Public endpoints (no authentication). - r.Get("/oauth2/jwks", oauth2Handler.JWKSHandler) - r.Post("/oauth2/token", oauth2Handler.TokenHandler) - r.Post("/oauth2/device", oauth2Handler.DeviceAuthHandler) + r.Get(oauth2JWKSPath, oauth2Handler.JWKSHandler) + r.Post(oauth2TokenPath, oauth2Handler.TokenHandler) + r.Post(oauth2DeviceAuthorizationPath, oauth2Handler.DeviceAuthHandler) // Bearer-token authenticated endpoints. bearerAuth := r.With(oauth2Handler.BearerTokenMiddleware) - bearerAuth.Get("/oauth2/userinfo", oauth2Handler.UserInfoHandler) + bearerAuth.Get(oauth2UserinfoPath, oauth2Handler.UserInfoHandler) // Client-authenticated endpoints. clientAuth := r.With(oauth2Handler.ClientAuthMiddleware) - clientAuth.Post("/oauth2/introspect", oauth2Handler.IntrospectHandler) - clientAuth.Post("/oauth2/revoke", oauth2Handler.RevokeHandler) + clientAuth.Post(oauth2IntrospectPath, oauth2Handler.IntrospectHandler) + clientAuth.Post(oauth2RevokePath, oauth2Handler.RevokeHandler) // Session-authenticated endpoints. - router.Get("/oauth2/authorize", oauth2Handler.AuthorizeHandler) + router.Get(oauth2AuthorizePath, oauth2Handler.AuthorizeHandler) requireIdentity := router.With(identityPresenceMiddleware) - requireIdentity.Post("/oauth2/register", oauth2Handler.RegisterHandler) + requireIdentity.Post(oauth2RegisterPath, oauth2Handler.RegisterHandler) return r }