From d1814d70510bbf2e0dda25f055cbe2f0348cb5d6 Mon Sep 17 00:00:00 2001 From: Bryan Frimin Date: Fri, 24 Jul 2026 21:16:11 +0000 Subject: [PATCH] Show why personal OIDC logins are refused Personal Google and Microsoft accounts were rejected with a raw JSON unauthorized response after the OIDC callback. Redirect to a dedicated auth page that explains the enterprise-account requirement, and check enterprise eligibility before xms_edov so Microsoft consumer accounts get the same clear error. Signed-off-by: Cursor Agent Co-authored-by: Bryan FRIMIN --- .../auth/PersonalAccountNotAllowedPage.tsx | 45 +++++++++++++ apps/console/src/routes.tsx | 6 ++ pkg/iam/oidc/service.go | 40 +++++++----- pkg/iam/oidc/service_test.go | 63 +++++++++++++++++++ pkg/server/api/connect/v1/oidc_handler.go | 8 +++ 5 files changed, 148 insertions(+), 14 deletions(-) create mode 100644 apps/console/src/pages/iam/auth/PersonalAccountNotAllowedPage.tsx diff --git a/apps/console/src/pages/iam/auth/PersonalAccountNotAllowedPage.tsx b/apps/console/src/pages/iam/auth/PersonalAccountNotAllowedPage.tsx new file mode 100644 index 000000000..2c399c34c --- /dev/null +++ b/apps/console/src/pages/iam/auth/PersonalAccountNotAllowedPage.tsx @@ -0,0 +1,45 @@ +// 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. + +import { usePageTitle } from "@probo/hooks"; +import { useTranslate } from "@probo/i18n"; +import { Button } from "@probo/ui"; + +export default function PersonalAccountNotAllowedPage() { + const { __ } = useTranslate(); + + usePageTitle(__("Enterprise account required")); + + return ( +
+
+

{__("Enterprise account required")}

+

+ {__( + "Personal Google and Microsoft accounts cannot be used to sign in. Please use your work or school account instead.", + )} +

+
+ +
+ ); +} diff --git a/apps/console/src/routes.tsx b/apps/console/src/routes.tsx index 2c2b4a656..b4569751e 100644 --- a/apps/console/src/routes.tsx +++ b/apps/console/src/routes.tsx @@ -127,6 +127,12 @@ const routes = [ path: "magic-link-already-used", Component: lazy(() => import("./pages/iam/auth/MagicLinkAlreadyUsedPage")), }, + { + path: "personal-account-not-allowed", + Component: lazy( + () => import("./pages/iam/auth/PersonalAccountNotAllowedPage"), + ), + }, ], }, { diff --git a/pkg/iam/oidc/service.go b/pkg/iam/oidc/service.go index e06ed8c39..33e8baec9 100644 --- a/pkg/iam/oidc/service.go +++ b/pkg/iam/oidc/service.go @@ -165,6 +165,30 @@ func (c *idTokenClaims) isEmailDomainOwnerVerified() bool { return false } +// validateIDTokenClaims enforces provider-specific account requirements. +// Enterprise eligibility is checked before email-verification claims so +// personal Google/Microsoft accounts surface ErrPersonalAccountNotAllowed +// rather than a generic email-verification failure (e.g. missing xms_edov). +func validateIDTokenClaims(info *providerInfo, claims *idTokenClaims) error { + if claims.Email == "" { + return NewMissingEmailClaimError() + } + + if !info.enterpriseChecker(claims) { + return NewPersonalAccountNotAllowedError() + } + + if !info.trustProviderEmail && !claims.isEmailVerified() { + return NewEmailNotVerifiedError() + } + + if info.requireEmailDomainOwnerVerified && !claims.isEmailDomainOwnerVerified() { + return NewEmailNotVerifiedError() + } + + return nil +} + var ( googleEndpoint = oauth2.Endpoint{ AuthURL: "https://accounts.google.com/o/oauth2/v2/auth", @@ -423,20 +447,8 @@ func (s *Service) HandleCallback( return nil, "", nil, fmt.Errorf("cannot verify id token: %w", err) } - if claims.Email == "" { - return nil, "", nil, NewMissingEmailClaimError() - } - - if !info.trustProviderEmail && !claims.isEmailVerified() { - return nil, "", nil, NewEmailNotVerifiedError() - } - - if info.requireEmailDomainOwnerVerified && !claims.isEmailDomainOwnerVerified() { - return nil, "", nil, NewEmailNotVerifiedError() - } - - if !info.enterpriseChecker(claims) { - return nil, "", nil, NewPersonalAccountNotAllowedError() + if err := validateIDTokenClaims(info, claims); err != nil { + return nil, "", nil, err } email, err := mail.ParseAddr(claims.Email) diff --git a/pkg/iam/oidc/service_test.go b/pkg/iam/oidc/service_test.go index 040f0be9a..c8d5c1888 100644 --- a/pkg/iam/oidc/service_test.go +++ b/pkg/iam/oidc/service_test.go @@ -21,6 +21,7 @@ package oidc import ( + "errors" "testing" "github.com/stretchr/testify/assert" @@ -89,3 +90,65 @@ func TestIsEmailDomainOwnerVerified(t *testing.T) { ) } } + +func TestValidateIDTokenClaims_PersonalAccounts(t *testing.T) { + t.Parallel() + + s := newTestService(t) + + t.Run("rejects Google personal account without hosted domain", func(t *testing.T) { + t.Parallel() + + err := validateIDTokenClaims( + s.providers[coredata.OIDCProviderGoogle], + &idTokenClaims{ + Email: "user@gmail.com", + EmailVerified: true, + }, + ) + _, ok := errors.AsType[*ErrPersonalAccountNotAllowed](err) + assert.True(t, ok, "got %T: %v", err, err) + }) + + t.Run("rejects Microsoft personal account before xms_edov check", func(t *testing.T) { + t.Parallel() + + err := validateIDTokenClaims( + s.providers[coredata.OIDCProviderMicrosoft], + &idTokenClaims{ + Issuer: "https://login.microsoftonline.com/" + microsoftConsumerTenantID + "/v2.0", + Email: "user@outlook.com", + }, + ) + _, ok := errors.AsType[*ErrPersonalAccountNotAllowed](err) + assert.True(t, ok, "got %T: %v", err, err) + }) + + t.Run("accepts Google Workspace account with hosted domain", func(t *testing.T) { + t.Parallel() + + err := validateIDTokenClaims( + s.providers[coredata.OIDCProviderGoogle], + &idTokenClaims{ + Email: "user@acme.com", + EmailVerified: true, + HostedDomain: "acme.com", + }, + ) + assert.NoError(t, err) + }) + + t.Run("rejects Microsoft enterprise account missing xms_edov", func(t *testing.T) { + t.Parallel() + + err := validateIDTokenClaims( + s.providers[coredata.OIDCProviderMicrosoft], + &idTokenClaims{ + Issuer: "https://login.microsoftonline.com/tenant-id/v2.0", + Email: "user@acme.com", + }, + ) + _, ok := errors.AsType[*ErrEmailNotVerified](err) + assert.True(t, ok, "got %T: %v", err, err) + }) +} diff --git a/pkg/server/api/connect/v1/oidc_handler.go b/pkg/server/api/connect/v1/oidc_handler.go index 9f9a60873..105da4a63 100644 --- a/pkg/server/api/connect/v1/oidc_handler.go +++ b/pkg/server/api/connect/v1/oidc_handler.go @@ -32,6 +32,7 @@ import ( "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" @@ -130,6 +131,13 @@ 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 { + h.logger.WarnCtx(ctx, "OIDC login rejected: personal account not allowed") + http.Redirect(w, r, "/auth/personal-account-not-allowed", http.StatusFound) + + return + } + h.logger.ErrorCtx(ctx, "cannot handle OIDC callback", log.Error(err)) httpserver.RenderError(w, http.StatusUnauthorized, errors.New("authentication failed"))