From 54c055ebccdd5cc141382ee52229751a1399a010 Mon Sep 17 00:00:00 2001 From: Sacha Al Himdani Date: Fri, 10 Jul 2026 17:22:02 +0200 Subject: [PATCH] Require verified domain ownership for Microsoft OIDC Stop trusting the email on its own: set trustProviderEmail to false so email_verified is required, and additionally require the "xms_edov" claim, which Azure sets only after verifying the issuing tenant owns the email's domain. A token that lacks it is rejected before any identity is matched. Signed-off-by: Sacha Al Himdani --- pkg/iam/oidc/service.go | 27 +++++++++++- pkg/iam/oidc/service_test.go | 83 ++++++++++++++++++++++++++++++++++++ 2 files changed, 108 insertions(+), 2 deletions(-) create mode 100644 pkg/iam/oidc/service_test.go diff --git a/pkg/iam/oidc/service.go b/pkg/iam/oidc/service.go index dc2fa5961..c4554019e 100644 --- a/pkg/iam/oidc/service.go +++ b/pkg/iam/oidc/service.go @@ -64,6 +64,10 @@ type ( // the email_verified claim does not need to be present in // the ID token. trustProviderEmail bool + + // requireEmailDomainOwnerVerified requires the "xms_edov" + // claim (nOAuth mitigation). + requireEmailDomainOwnerVerified bool } UserInfo struct { @@ -112,6 +116,9 @@ type ( EmailVerified any `json:"email_verified"` Name string `json:"name"` HostedDomain string `json:"hd"` + + // EmailDomainOwnerVerified is Microsoft's "xms_edov" domain-ownership claim. + EmailDomainOwnerVerified any `json:"xms_edov"` } ) @@ -141,6 +148,17 @@ func (c *idTokenClaims) isEmailVerified() bool { return false } +func (c *idTokenClaims) isEmailDomainOwnerVerified() bool { + switch v := c.EmailDomainOwnerVerified.(type) { + case bool: + return v + case string: + return strings.EqualFold(v, "true") + } + + return false +} + var ( googleEndpoint = oauth2.Endpoint{ AuthURL: "https://accounts.google.com/o/oauth2/v2/auth", @@ -208,8 +226,9 @@ func NewService( RedirectURL: baseURL + "/api/connect/v1/oidc/microsoft/callback", Scopes: []string{"openid", "email", "profile"}, }, - jwksURL: microsoftJWKSURL, - trustProviderEmail: true, + jwksURL: microsoftJWKSURL, + trustProviderEmail: false, + requireEmailDomainOwnerVerified: true, issuerValidator: func(iss string) bool { return strings.HasPrefix(iss, "https://login.microsoftonline.com/") && strings.HasSuffix(iss, "/v2.0") @@ -404,6 +423,10 @@ func (s *Service) HandleCallback( return nil, "", nil, NewEmailNotVerifiedError() } + if info.requireEmailDomainOwnerVerified && !claims.isEmailDomainOwnerVerified() { + return nil, "", nil, NewEmailNotVerifiedError() + } + if !info.enterpriseChecker(claims) { return nil, "", nil, NewPersonalAccountNotAllowedError() } diff --git a/pkg/iam/oidc/service_test.go b/pkg/iam/oidc/service_test.go new file mode 100644 index 000000000..e59bfa526 --- /dev/null +++ b/pkg/iam/oidc/service_test.go @@ -0,0 +1,83 @@ +// 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 oidc + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.gearno.de/kit/log" + "go.probo.inc/probo/pkg/coredata" +) + +func newTestService(t *testing.T) *Service { + t.Helper() + + return NewService( + nil, + "https://app.probo.test", + ProviderConfig{ClientID: "google-client", ClientSecret: "s", Enabled: true}, + ProviderConfig{ClientID: "microsoft-client", ClientSecret: "s", Enabled: true}, + log.NewLogger(), + ) +} + +// TestMicrosoftRequiresDomainOwnerVerified pins the nOAuth mitigation: the +// Microsoft provider must not trust the email claim on email_verified alone and +// must require the xms_edov domain-ownership claim. +func TestMicrosoftRequiresDomainOwnerVerified(t *testing.T) { + t.Parallel() + + s := newTestService(t) + + microsoft := s.providers[coredata.OIDCProviderMicrosoft] + require.NotNil(t, microsoft) + assert.False(t, microsoft.trustProviderEmail, "Microsoft email must not be trusted unconditionally") + assert.True(t, microsoft.requireEmailDomainOwnerVerified, "Microsoft must require xms_edov") + + google := s.providers[coredata.OIDCProviderGoogle] + require.NotNil(t, google) + assert.False(t, google.requireEmailDomainOwnerVerified, "Google verifies its domains and does not use xms_edov") +} + +func TestIsEmailDomainOwnerVerified(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + value any + want bool + }{ + {"bool true", true, true}, + {"bool false", false, false}, + {"string true", "true", true}, + {"string True", "True", true}, + {"string false", "false", false}, + {"absent", nil, false}, + } + + for _, tt := range tests { + t.Run( + tt.name, + func(t *testing.T) { + t.Parallel() + + claims := &idTokenClaims{EmailDomainOwnerVerified: tt.value} + assert.Equal(t, tt.want, claims.isEmailDomainOwnerVerified()) + }, + ) + } +}