Skip to content

Commit 281e3ae

Browse files
authored
Add validation for issuer returned by OIDC endpoint (#576)
* Add issuer validation and related tests * Refactor to use aliases from AAD instance discovery * Add missing metadata call * Add missing test case
1 parent 752e292 commit 281e3ae

File tree

5 files changed

+175
-4
lines changed

5 files changed

+175
-4
lines changed

apps/confidential/confidential_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1599,7 +1599,7 @@ func TestWithInstanceDiscovery(t *testing.T) {
15991599
idToken = mock.GetIDToken(tenant, authority)
16001600
refreshToken = "refresh-token"
16011601
}
1602-
mockClient.AppendResponse(mock.WithBody(mock.GetTenantDiscoveryBody(stackurl, tenant)))
1602+
mockClient.AppendResponse(mock.WithBody(mock.GetTenantDiscoveryBody(host, tenant)))
16031603
mockClient.AppendResponse(
16041604
mock.WithBody(mock.GetAccessTokenBody(accessToken, idToken, refreshToken, "", 3600, 0)),
16051605
)

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

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,41 @@ func (r *TenantDiscoveryResponse) Validate() error {
100100
return nil
101101
}
102102

103+
// ValidateIssuerMatchesAuthority validates that the issuer in the TenantDiscoveryResponse matches the authority.
104+
// This is used to identity security or configuration issues in authorities and the OIDC endpoint
105+
func (r *TenantDiscoveryResponse) ValidateIssuerMatchesAuthority(authorityURI string, aliases map[string]bool) error {
106+
107+
if authorityURI == "" {
108+
return errors.New("TenantDiscoveryResponse: empty authorityURI provided for validation")
109+
}
110+
111+
// Parse the issuer URL
112+
issuerURL, err := url.Parse(r.Issuer)
113+
if err != nil {
114+
return fmt.Errorf("TenantDiscoveryResponse: failed to parse issuer URL: %w", err)
115+
}
116+
117+
// Even if it doesn't match the authority, issuers from known and trusted hosts are valid
118+
if aliases != nil && aliases[issuerURL.Host] {
119+
return nil
120+
}
121+
122+
// Parse the authority URL for comparison
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+
// Check if the scheme and host match (paths can be ignored when validating the issuer)
129+
if issuerURL.Scheme == authorityURL.Scheme && issuerURL.Host == authorityURL.Host {
130+
return nil
131+
}
132+
133+
// If we get here, validation failed
134+
return fmt.Errorf("TenantDiscoveryResponse: issuer from OIDC discovery '%s' does not match authority '%s' or a known pattern",
135+
r.Issuer, authorityURI)
136+
}
137+
103138
type InstanceDiscoveryMetadata struct {
104139
PreferredNetwork string `json:"preferred_network"`
105140
PreferredCache string `json:"preferred_cache"`
@@ -356,6 +391,8 @@ type Info struct {
356391
Tenant string
357392
Region string
358393
InstanceDiscoveryDisabled bool
394+
// InstanceDiscoveryMetadata stores the metadata from AAD instance discovery
395+
InstanceDiscoveryMetadata []InstanceDiscoveryMetadata
359396
}
360397

361398
// NewInfoFromAuthorityURI creates an AuthorityInfo instance from the authority URL provided.

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

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -507,3 +507,121 @@ func TestMergeCapabilitiesAndClaims(t *testing.T) {
507507
})
508508
}
509509
}
510+
511+
func TestTenantDiscoveryValidateIssuer(t *testing.T) {
512+
tests := []struct {
513+
desc string
514+
issuer string
515+
authority string
516+
aliases map[string]bool
517+
expectError bool
518+
}{
519+
{
520+
desc: "issuer exactly matches authority",
521+
issuer: "https://login.microsoftonline.com/tenant-id",
522+
authority: "https://login.microsoftonline.com/tenant-id",
523+
expectError: false,
524+
},
525+
{
526+
desc: "issuer matches authority with trailing slash in authority",
527+
issuer: "https://login.microsoftonline.com/tenant-id",
528+
authority: "https://login.microsoftonline.com/tenant-id/",
529+
expectError: false,
530+
},
531+
{
532+
desc: "issuer matches authority with trailing slash in issuer",
533+
issuer: "https://login.microsoftonline.com/tenant-id/",
534+
authority: "https://login.microsoftonline.com/tenant-id",
535+
expectError: false,
536+
},
537+
{
538+
desc: "issuer is shorter than authority but is a prefix",
539+
issuer: "https://login.microsoftonline.com",
540+
authority: "https://login.microsoftonline.com/tenant-id",
541+
expectError: false,
542+
},
543+
{
544+
desc: "authority is shorter than issuer but is a prefix",
545+
issuer: "https://login.microsoftonline.com/tenant-id/additional-path",
546+
authority: "https://login.microsoftonline.com/tenant-id",
547+
expectError: false,
548+
},
549+
{
550+
desc: "issuer and authority have different paths",
551+
issuer: "https://login.microsoftonline.com/other-tenant",
552+
authority: "https://login.microsoftonline.com/tenant-id",
553+
expectError: false,
554+
},
555+
{
556+
desc: "custom authority with a non-matching Entra issuer",
557+
issuer: "https://login.microsoftonline.com/",
558+
authority: "https://contoso.com/tenant-id",
559+
expectError: true,
560+
},
561+
{
562+
desc: "Entra authority with a non-matching custom issuer",
563+
issuer: "https://contoso.com/",
564+
authority: "https://login.microsoftonline.com/tenant-id",
565+
expectError: true,
566+
},
567+
{
568+
desc: "empty issuer",
569+
issuer: "",
570+
authority: "https://login.microsoftonline.com/tenant-id",
571+
expectError: true,
572+
},
573+
{
574+
desc: "empty issuer and authority",
575+
issuer: "",
576+
authority: "",
577+
aliases: map[string]bool{"alias1.example.com": true, "alias2.example.com": true},
578+
expectError: true,
579+
},
580+
// New test cases for alias validation
581+
{
582+
desc: "issuer matches an alias",
583+
issuer: "https://alias1.example.com/tenant-id",
584+
authority: "https://contoso.com/tenant-id",
585+
aliases: map[string]bool{"alias1.example.com": true, "alias2.example.com": true},
586+
expectError: false,
587+
},
588+
{
589+
desc: "issuer matches a different alias",
590+
issuer: "https://alias2.example.com/tenant-id",
591+
authority: "https://contoso.com/tenant-id",
592+
aliases: map[string]bool{"alias1.example.com": true, "alias2.example.com": true},
593+
expectError: false,
594+
},
595+
{
596+
desc: "issuer doesn't match any alias",
597+
issuer: "https://unknown.example.com/tenant-id",
598+
authority: "https://contoso.com/tenant-id",
599+
aliases: map[string]bool{"alias1.example.com": true, "alias2.example.com": true},
600+
expectError: true,
601+
},
602+
{
603+
desc: "empty aliases map",
604+
issuer: "https://unknown.example.com/tenant-id",
605+
authority: "https://contoso.com/tenant-id",
606+
aliases: map[string]bool{},
607+
expectError: true,
608+
},
609+
}
610+
611+
for _, test := range tests {
612+
t.Run(test.desc, func(t *testing.T) {
613+
response := &TenantDiscoveryResponse{
614+
AuthorizationEndpoint: "https://login.microsoftonline.com/tenant-id/oauth2/v2.0/authorize",
615+
TokenEndpoint: "https://login.microsoftonline.com/tenant-id/oauth2/v2.0/token",
616+
Issuer: test.issuer,
617+
}
618+
619+
err := response.ValidateIssuerMatchesAuthority(test.authority, test.aliases)
620+
if test.expectError && err == nil {
621+
t.Errorf("expected error but got none")
622+
} else if !test.expectError && err != nil {
623+
t.Errorf("unexpected error: %v", err)
624+
}
625+
})
626+
}
627+
}

apps/internal/oauth/resolvers.go

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,12 @@ import (
2121
type cacheEntry struct {
2222
Endpoints authority.Endpoints
2323
ValidForDomainsInList map[string]bool
24+
// Aliases stores host aliases from instance discovery for quick lookup
25+
Aliases map[string]bool
2426
}
2527

2628
func createcacheEntry(endpoints authority.Endpoints) cacheEntry {
27-
return cacheEntry{endpoints, map[string]bool{}}
29+
return cacheEntry{endpoints, map[string]bool{}, map[string]bool{}}
2830
}
2931

3032
// AuthorityEndpoint retrieves endpoints from an authority for auth and token acquisition.
@@ -71,10 +73,15 @@ func (m *authorityEndpoint) ResolveEndpoints(ctx context.Context, authorityInfo
7173

7274
m.addCachedEndpoints(authorityInfo, userPrincipalName, endpoints)
7375

76+
if err := resp.ValidateIssuerMatchesAuthority(authorityInfo.CanonicalAuthorityURI,
77+
m.cache[authorityInfo.CanonicalAuthorityURI].Aliases); err != nil {
78+
return authority.Endpoints{}, fmt.Errorf("ResolveEndpoints(): %w", err)
79+
}
80+
7481
return endpoints, nil
7582
}
7683

77-
// cachedEndpoints returns a the cached endpoints if they exists. If not, we return false.
84+
// cachedEndpoints returns the cached endpoints if they exist. If not, we return false.
7885
func (m *authorityEndpoint) cachedEndpoints(authorityInfo authority.Info, userPrincipalName string) (authority.Endpoints, bool) {
7986
m.mu.Lock()
8087
defer m.mu.Unlock()
@@ -113,6 +120,13 @@ func (m *authorityEndpoint) addCachedEndpoints(authorityInfo authority.Info, use
113120
}
114121
}
115122

123+
// Extract aliases from instance discovery metadata and add to cache
124+
for _, metadata := range authorityInfo.InstanceDiscoveryMetadata {
125+
for _, alias := range metadata.Aliases {
126+
updatedCacheEntry.Aliases[alias] = true
127+
}
128+
}
129+
116130
m.cache[authorityInfo.CanonicalAuthorityURI] = updatedCacheEntry
117131
}
118132

@@ -127,12 +141,14 @@ func (m *authorityEndpoint) openIDConfigurationEndpoint(ctx context.Context, aut
127141
if err != nil {
128142
return "", err
129143
}
144+
authorityInfo.InstanceDiscoveryMetadata = resp.Metadata
130145
return resp.TenantDiscoveryEndpoint, nil
131146
} else if authorityInfo.Region != "" {
132147
resp, err := m.rest.Authority().AADInstanceDiscovery(ctx, authorityInfo)
133148
if err != nil {
134149
return "", err
135150
}
151+
authorityInfo.InstanceDiscoveryMetadata = resp.Metadata
136152
return resp.TenantDiscoveryEndpoint, nil
137153
}
138154

apps/public/public_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -210,7 +210,7 @@ func TestAcquireTokenByDeviceCode(t *testing.T) {
210210
VerificationURL: "https://device.code.local",
211211
}
212212
mockClient := mock.NewClient()
213-
mockClient.AppendResponse(mock.WithBody(mock.GetTenantDiscoveryBody("http://localhost", "tenant")))
213+
mockClient.AppendResponse(mock.WithBody(mock.GetTenantDiscoveryBody("login.microsoftonline.com", "tenant")))
214214
mockClient.AppendResponse(mock.WithBody([]byte(
215215
fmt.Sprintf(
216216
`{"device_code":%q,"expires_in":60,"interval":%d,"message":%q,"user_code":%q,"verification_uri":%q}`,

0 commit comments

Comments
 (0)