Skip to content

Commit 22fd2ee

Browse files
Avery-Dunn4gust
andauthored
Adjust issuer validation to handle cases where instance discovery is not performed (#591)
* Adjust issuer validation to handle cases where instance discovery is not performed * Added more trusted host * Removed some unnecessary conditions Added a check for if the host for auth and issuer is same apart for region. --------- Co-authored-by: Nilesh Choudhary <[email protected]>
1 parent 8ac5ff5 commit 22fd2ee

File tree

2 files changed

+107
-14
lines changed

2 files changed

+107
-14
lines changed

apps/internal/oauth/ops/authority/authority.go

Lines changed: 27 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@ type jsonCaller interface {
4848
}
4949

5050
// For backward compatibility, accept both old and new China endpoints for a transition period.
51+
// This list is derived from the AAD instance discovery metadata and represents all known trusted hosts
52+
// across different Azure clouds (Public, China, Germany, US Government, etc.)
5153
var aadTrustedHostList = map[string]bool{
5254
"login.windows.net": true, // Microsoft Azure Worldwide - Used in validation scenarios where host is not this list
5355
"login.partner.microsoftonline.cn": true, // Microsoft Azure China (new)
@@ -56,6 +58,9 @@ var aadTrustedHostList = map[string]bool{
5658
"login-us.microsoftonline.com": true, // Microsoft Azure US Government - Legacy
5759
"login.microsoftonline.us": true, // Microsoft Azure US Government
5860
"login.microsoftonline.com": true, // Microsoft Azure Worldwide
61+
"login.microsoft.com": true,
62+
"sts.windows.net": true,
63+
"login.usgovcloudapi.net": true,
5964
}
6065

6166
// TrustedHost checks if an AAD host is trusted/valid.
@@ -104,36 +109,46 @@ func (r *TenantDiscoveryResponse) Validate() error {
104109
// ValidateIssuerMatchesAuthority validates that the issuer in the TenantDiscoveryResponse matches the authority.
105110
// This is used to identity security or configuration issues in authorities and the OIDC endpoint
106111
func (r *TenantDiscoveryResponse) ValidateIssuerMatchesAuthority(authorityURI string, aliases map[string]bool) error {
107-
108112
if authorityURI == "" {
109113
return errors.New("TenantDiscoveryResponse: empty authorityURI provided for validation")
110114
}
115+
if r.Issuer == "" {
116+
return errors.New("TenantDiscoveryResponse: empty issuer in response")
117+
}
111118

112-
// Parse the issuer URL
113119
issuerURL, err := url.Parse(r.Issuer)
114120
if err != nil {
115121
return fmt.Errorf("TenantDiscoveryResponse: failed to parse issuer URL: %w", err)
116122
}
123+
authorityURL, err := url.Parse(authorityURI)
124+
if err != nil {
125+
return fmt.Errorf("TenantDiscoveryResponse: failed to parse authority URL: %w", err)
126+
}
127+
128+
// Fast path: exact scheme + host match
129+
if issuerURL.Scheme == authorityURL.Scheme && issuerURL.Host == authorityURL.Host {
130+
return nil
131+
}
117132

118-
// Even if it doesn't match the authority, issuers from known and trusted hosts are valid
133+
// Alias-based acceptance
119134
if aliases != nil && aliases[issuerURL.Host] {
120135
return nil
121136
}
122137

123-
// Parse the authority URL for comparison
124-
authorityURL, err := url.Parse(authorityURI)
125-
if err != nil {
126-
return fmt.Errorf("TenantDiscoveryResponse: failed to parse authority URL: %w", err)
138+
issuerHost := issuerURL.Host
139+
authorityHost := authorityURL.Host
140+
141+
// Accept if issuer host is trusted
142+
if TrustedHost(issuerHost) {
143+
return nil
127144
}
128145

129-
// Check if the scheme and host match (paths can be ignored when validating the issuer)
130-
if issuerURL.Scheme == authorityURL.Scheme && issuerURL.Host == authorityURL.Host {
146+
// Accept if authority is a regional variant ending with ".<issuerHost>"
147+
if strings.HasSuffix(authorityHost, "."+issuerHost) {
131148
return nil
132149
}
133150

134-
// If we get here, validation failed
135-
return fmt.Errorf("TenantDiscoveryResponse: issuer from OIDC discovery '%s' does not match authority '%s' or a known pattern",
136-
r.Issuer, authorityURI)
151+
return fmt.Errorf("TenantDiscoveryResponse: issuer '%s' does not match authority '%s' or any trusted/alias rule", r.Issuer, authorityURI)
137152
}
138153

139154
type InstanceDiscoveryMetadata struct {

apps/internal/oauth/ops/authority/authority_test.go

Lines changed: 80 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -553,10 +553,10 @@ func TestTenantDiscoveryValidateIssuer(t *testing.T) {
553553
expectError: false,
554554
},
555555
{
556-
desc: "custom authority with a non-matching Entra issuer",
556+
desc: "custom authority with a trusted Entra issuer",
557557
issuer: "https://login.microsoftonline.com/",
558558
authority: "https://contoso.com/tenant-id",
559-
expectError: true,
559+
expectError: false, // Trusted hosts are always valid issuers
560560
},
561561
{
562562
desc: "Entra authority with a non-matching custom issuer",
@@ -606,6 +606,48 @@ func TestTenantDiscoveryValidateIssuer(t *testing.T) {
606606
aliases: map[string]bool{},
607607
expectError: true,
608608
},
609+
// Test cases for regional authority scenarios where instance discovery isn't performed
610+
{
611+
desc: "regional authority with trusted issuer host (no aliases)",
612+
issuer: "https://login.microsoftonline.com/tenant-id",
613+
authority: "https://westus2.login.microsoft.com/tenant-id",
614+
aliases: nil,
615+
expectError: false,
616+
},
617+
{
618+
desc: "regional authority with different trusted issuer host (no aliases)",
619+
issuer: "https://login.windows.net/tenant-id",
620+
authority: "https://eastus.login.microsoft.com/tenant-id",
621+
aliases: map[string]bool{},
622+
expectError: false,
623+
},
624+
{
625+
desc: "regional authority with Azure Government trusted issuer",
626+
issuer: "https://login.microsoftonline.us/tenant-id",
627+
authority: "https://usgovvirginia.login.microsoftonline.us/tenant-id",
628+
aliases: nil,
629+
expectError: false,
630+
},
631+
{
632+
desc: "regional authority with untrusted issuer host (no aliases)",
633+
issuer: "https://malicious.example.com/tenant-id",
634+
authority: "https://westus2.login.microsoft.com/tenant-id",
635+
aliases: nil,
636+
expectError: true,
637+
},
638+
{
639+
desc: "regional authority subdomain with matching trusted issuer",
640+
issuer: "https://login.microsoftonline.com/tenant-id",
641+
authority: "https://region.login.microsoftonline.com/tenant-id",
642+
aliases: nil,
643+
expectError: false,
644+
}, {
645+
desc: "regional authority subdomain with matching trusted issuer",
646+
issuer: "https://login.dummy-uri.com/tenant-id",
647+
authority: "https://region.login.dummy-uri.com/tenant-id",
648+
aliases: nil,
649+
expectError: false,
650+
},
609651
}
610652

611653
for _, test := range tests {
@@ -625,3 +667,39 @@ func TestTenantDiscoveryValidateIssuer(t *testing.T) {
625667
})
626668
}
627669
}
670+
671+
func TestTrustedHost(t *testing.T) {
672+
tests := []struct {
673+
host string
674+
expectedTrust bool
675+
}{
676+
// Microsoft Azure Worldwide hosts
677+
{"login.microsoftonline.com", true},
678+
{"login.windows.net", true},
679+
{"login.microsoft.com", true},
680+
{"sts.windows.net", true},
681+
// Microsoft Azure China hosts
682+
{"login.partner.microsoftonline.cn", true},
683+
{"login.chinacloudapi.cn", true},
684+
// Microsoft Azure Germany hosts
685+
{"login.microsoftonline.de", true},
686+
// Microsoft Azure US Government hosts
687+
{"login.microsoftonline.us", true},
688+
{"login.usgovcloudapi.net", true},
689+
{"login-us.microsoftonline.com", true},
690+
// Untrusted hosts
691+
{"malicious.example.com", false},
692+
{"fake-login.microsoftonline.com", false},
693+
{"login.example.com", false},
694+
{"", false},
695+
}
696+
697+
for _, test := range tests {
698+
t.Run(test.host, func(t *testing.T) {
699+
result := TrustedHost(test.host)
700+
if result != test.expectedTrust {
701+
t.Errorf("TrustedHost(%q) = %v, want %v", test.host, result, test.expectedTrust)
702+
}
703+
})
704+
}
705+
}

0 commit comments

Comments
 (0)