Skip to content

Commit 978ef03

Browse files
authored
auth: Support AzureDevOps Pipeline OIDC auth (similar to Github OIDC auth) (#1139)
* Support ADO pipeline OIDC * Update * remove audience query * Add back audience * Add up the missing test and doc * NewAuthorizerFromCredentials: Move ADO OIDC before Github OIDC This enables users who intend to use ADO OIDC while enbaled both won't end up using Github OIDC auth and fail. This can work since ADO OIDC has one more condition (i.e. the service connection ID) than Github OIDC. * Auth: set the service connection for ADO OIDC
1 parent 43abbe7 commit 978ef03

File tree

11 files changed

+545
-120
lines changed

11 files changed

+545
-120
lines changed

sdk/auth/README.md

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -130,8 +130,39 @@ func main() {
130130
credentials := auth.Credentials{
131131
Environment: environment,
132132
EnableAuthenticationUsingGitHubOIDC: true,
133-
GitHubOIDCTokenRequestURL: os.Getenv("ACTIONS_ID_TOKEN_REQUEST_URL"),
134-
GitHubOIDCTokenRequestToken: os.Getenv("ACTIONS_ID_TOKEN_REQUEST_TOKEN"),
133+
OIDCTokenRequestURL: os.Getenv("ACTIONS_ID_TOKEN_REQUEST_URL"),
134+
OIDCTokenRequestToken: os.Getenv("ACTIONS_ID_TOKEN_REQUEST_TOKEN"),
135+
}
136+
authorizer, err := auth.NewAuthorizerFromCredentials(context.TODO(), credentials, environment.MSGraph)
137+
if err != nil {
138+
log.Fatalf("building authorizer from credentials: %+v", err)
139+
}
140+
// ..
141+
}
142+
```
143+
144+
## Example: Authenticating using ADO Pipeline OIDC
145+
146+
```go
147+
package main
148+
149+
import (
150+
"context"
151+
"log"
152+
"os"
153+
154+
"github.com/hashicorp/go-azure-sdk/sdk/auth"
155+
"github.com/hashicorp/go-azure-sdk/sdk/environments"
156+
)
157+
158+
func main() {
159+
environment := environments.Public
160+
credentials := auth.Credentials{
161+
Environment: environment,
162+
EnableAuthenticationUsingADOPipelineOIDC: true,
163+
OIDCTokenRequestURL: os.Getenv("SYSTEM_OIDCREQUESTURI"),
164+
OIDCTokenRequestToken: os.Getenv("SYSTEM_ACCESSTOKEN"),
165+
ADOPipelineServiceConnectionID: "<Service Connection ID>",
135166
}
136167
authorizer, err := auth.NewAuthorizerFromCredentials(context.TODO(), credentials, environment.MSGraph)
137168
if err != nil {
@@ -167,4 +198,4 @@ func main() {
167198
}
168199
// ..
169200
}
170-
```
201+
```
Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
// Copyright (c) HashiCorp Inc. All rights reserved.
2+
// Licensed under the MPL-2.0 License. See NOTICE.txt in the project root for license information.
3+
4+
package auth
5+
6+
import (
7+
"context"
8+
"encoding/json"
9+
"fmt"
10+
"io"
11+
"net/http"
12+
"net/url"
13+
14+
"github.com/hashicorp/go-azure-sdk/sdk/environments"
15+
"golang.org/x/oauth2"
16+
)
17+
18+
const (
19+
adoPipelineOIDCAPIVersion = "7.1"
20+
)
21+
22+
type ADOPipelineOIDCAuthorizerOptions struct {
23+
// Api describes the Azure API being used
24+
Api environments.Api
25+
26+
// ClientId is the client ID used when authenticating
27+
ClientId string
28+
29+
// ServiceConnectionId is the ADO service connection ID used when authenticating
30+
ServiceConnectionId string
31+
32+
// Environment is the Azure environment/cloud being targeted
33+
Environment environments.Environment
34+
35+
// TenantId is the tenant to authenticate against
36+
TenantId string
37+
38+
// AuxiliaryTenantIds lists additional tenants to authenticate against, currently only
39+
// used for Resource Manager when auxiliary tenants are needed.
40+
// e.g. https://learn.microsoft.com/en-us/azure/azure-resource-manager/management/authenticate-multi-tenant
41+
AuxiliaryTenantIds []string
42+
43+
// IdTokenRequestUrl is the URL for the OIDC provider from which to request an ID token.
44+
// Usually exposed via the SYSTEM_OIDCREQUESTURI environment variable when running in ADO Pipelines
45+
IdTokenRequestUrl string
46+
47+
// IdTokenRequestToken is the bearer token for the request to the OIDC provider.
48+
// Usually exposed via the SYSTEM_ACCESSTOKEN environment variable when running in ADO Pipelines
49+
IdTokenRequestToken string
50+
}
51+
52+
// NewADOPipelineOIDCAuthorizer returns an authorizer which acquires a client assertion from a ADO endpoint, then uses client assertion authentication to obtain an access token.
53+
func NewADOPipelineOIDCAuthorizer(ctx context.Context, options ADOPipelineOIDCAuthorizerOptions) (Authorizer, error) {
54+
scope, err := environments.Scope(options.Api)
55+
if err != nil {
56+
return nil, fmt.Errorf("determining scope for %q: %+v", options.Api.Name(), err)
57+
}
58+
59+
conf := adoPipelineOIDCConfig{
60+
Environment: options.Environment,
61+
TenantID: options.TenantId,
62+
AuxiliaryTenantIDs: options.AuxiliaryTenantIds,
63+
ClientID: options.ClientId,
64+
ServiceConnectionID: options.ServiceConnectionId,
65+
IDTokenRequestURL: options.IdTokenRequestUrl,
66+
IDTokenRequestToken: options.IdTokenRequestToken,
67+
Scopes: []string{
68+
*scope,
69+
},
70+
}
71+
72+
return conf.TokenSource(ctx)
73+
}
74+
75+
var _ Authorizer = &ADOPipelineOIDCAuthorizer{}
76+
77+
type ADOPipelineOIDCAuthorizer struct {
78+
conf *adoPipelineOIDCConfig
79+
}
80+
81+
func (a *ADOPipelineOIDCAuthorizer) adoPipelineAssertion(ctx context.Context, _ *http.Request) (*string, error) {
82+
req, err := http.NewRequestWithContext(ctx, http.MethodPost, a.conf.IDTokenRequestURL, http.NoBody)
83+
if err != nil {
84+
return nil, fmt.Errorf("adoPipelineAssertion: failed to build request: %+v", err)
85+
}
86+
87+
query, err := url.ParseQuery(req.URL.RawQuery)
88+
if err != nil {
89+
return nil, fmt.Errorf("adoPipelineAssertion: cannot parse URL query")
90+
}
91+
if query.Get("api-version") == "" {
92+
query.Add("api-version", adoPipelineOIDCAPIVersion)
93+
}
94+
if query.Get("serviceConnectionId") == "" {
95+
query.Add("serviceConnectionId", a.conf.ServiceConnectionID)
96+
}
97+
if query.Get("audience") == "" {
98+
query.Add("audience", "api://AzureADTokenExchange")
99+
}
100+
req.URL.RawQuery = query.Encode()
101+
102+
req.Header.Set("Accept", "application/json")
103+
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", a.conf.IDTokenRequestToken))
104+
req.Header.Set("Content-Type", "application/json")
105+
106+
resp, err := Client.Do(req)
107+
if err != nil {
108+
return nil, fmt.Errorf("adoPipelineAssertion: cannot request token: %v", err)
109+
}
110+
111+
defer resp.Body.Close()
112+
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
113+
if err != nil {
114+
return nil, fmt.Errorf("adoPipelineAssertion: cannot parse response: %v", err)
115+
}
116+
117+
if c := resp.StatusCode; c < 200 || c > 299 {
118+
return nil, fmt.Errorf("adoPipelineAssertion: received HTTP status %d with response: %s", resp.StatusCode, body)
119+
}
120+
121+
var tokenRes struct {
122+
Value *string `json:"oidcToken"`
123+
}
124+
if err := json.Unmarshal(body, &tokenRes); err != nil {
125+
return nil, fmt.Errorf("adoPipelineAssertion: cannot unmarshal response: %v", err)
126+
}
127+
128+
return tokenRes.Value, nil
129+
}
130+
131+
func (a *ADOPipelineOIDCAuthorizer) tokenSource(ctx context.Context, req *http.Request) (Authorizer, error) {
132+
assertion, err := a.adoPipelineAssertion(ctx, req)
133+
if err != nil {
134+
return nil, err
135+
}
136+
if assertion == nil {
137+
return nil, fmt.Errorf("ADOPipelineOIDCAuthorizer: nil JWT assertion received from ADOPipeline")
138+
}
139+
140+
conf := clientCredentialsConfig{
141+
Environment: a.conf.Environment,
142+
TenantID: a.conf.TenantID,
143+
AuxiliaryTenantIDs: a.conf.AuxiliaryTenantIDs,
144+
ClientID: a.conf.ClientID,
145+
FederatedAssertion: *assertion,
146+
Scopes: a.conf.Scopes,
147+
TokenURL: a.conf.TokenURL,
148+
Audience: a.conf.Audience,
149+
}
150+
151+
source, err := conf.TokenSource(ctx, clientCredentialsAssertionType)
152+
if err != nil {
153+
return nil, fmt.Errorf("ADOPipelineOIDCAuthorizer: building Authorizer: %+v", err)
154+
}
155+
return source, nil
156+
}
157+
158+
func (a *ADOPipelineOIDCAuthorizer) Token(ctx context.Context, req *http.Request) (*oauth2.Token, error) {
159+
source, err := a.tokenSource(ctx, req)
160+
if err != nil {
161+
return nil, err
162+
}
163+
return source.Token(ctx, req)
164+
}
165+
166+
func (a *ADOPipelineOIDCAuthorizer) AuxiliaryTokens(ctx context.Context, req *http.Request) ([]*oauth2.Token, error) {
167+
source, err := a.tokenSource(ctx, req)
168+
if err != nil {
169+
return nil, err
170+
}
171+
return source.AuxiliaryTokens(ctx, req)
172+
}
173+
174+
type adoPipelineOIDCConfig struct {
175+
// Environment is the national cloud environment to use
176+
Environment environments.Environment
177+
178+
// TenantID is the required tenant ID for the primary token
179+
TenantID string
180+
181+
// AuxiliaryTenantIDs is an optional list of tenant IDs for which to obtain additional tokens
182+
AuxiliaryTenantIDs []string
183+
184+
// ClientID is the application's ID.
185+
ClientID string
186+
187+
// ServiceConnectionID is the ADO service connection ID used when authenticating
188+
ServiceConnectionID string
189+
190+
// IDTokenRequestURL is the URL for ADO Pipeline's OIDC provider.
191+
IDTokenRequestURL string
192+
193+
// IDTokenRequestToken is the bearer token for the request to the OIDC provider.
194+
IDTokenRequestToken string
195+
196+
// Scopes specifies a list of requested permission scopes (used for v2 tokens)
197+
Scopes []string
198+
199+
// TokenURL is the clientCredentialsToken endpoint, which overrides the default endpoint constructed from a tenant ID
200+
TokenURL string
201+
202+
// Audience optionally specifies the intended audience of the
203+
// request. If empty, the value of TokenURL is used as the
204+
// intended audience.
205+
Audience string
206+
}
207+
208+
func (c *adoPipelineOIDCConfig) TokenSource(ctx context.Context) (Authorizer, error) {
209+
return NewCachedAuthorizer(&ADOPipelineOIDCAuthorizer{
210+
conf: c,
211+
})
212+
}
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
// Copyright (c) HashiCorp, Inc.
2+
// SPDX-License-Identifier: MPL-2.0
3+
4+
package auth_test
5+
6+
import (
7+
"context"
8+
"fmt"
9+
"testing"
10+
11+
"github.com/hashicorp/go-azure-sdk/sdk/auth"
12+
"github.com/hashicorp/go-azure-sdk/sdk/environments"
13+
"github.com/hashicorp/go-azure-sdk/sdk/internal/test"
14+
)
15+
16+
func TestADOPipelineOIDCAuthorizer(t *testing.T) {
17+
ctx := context.Background()
18+
env := environments.AzurePublic()
19+
20+
mockHost := "ado-oidc-issuer"
21+
auth.Client = &oidcMockClient{
22+
authorization: *env.Authorization,
23+
platform: OidcMockClientPlatformADOPipeline,
24+
mockHost: mockHost,
25+
}
26+
27+
idTokenRequestUrl := fmt.Sprintf("https://%s/vend-id-token", mockHost)
28+
idTokenRequestToken := test.DummyAccessToken
29+
30+
opts := auth.ADOPipelineOIDCAuthorizerOptions{
31+
Api: env.MicrosoftGraph,
32+
AuxiliaryTenantIds: test.AuxiliaryTenantIds,
33+
ClientId: "11111111-0000-0000-0000-000000000000",
34+
Environment: *env,
35+
IdTokenRequestToken: idTokenRequestToken,
36+
IdTokenRequestUrl: idTokenRequestUrl,
37+
ServiceConnectionId: "test-service-connection",
38+
TenantId: "00000000-1111-0000-0000-000000000000",
39+
}
40+
41+
authorizer, err := auth.NewADOPipelineOIDCAuthorizer(ctx, opts)
42+
if err != nil {
43+
t.Fatalf("NewADOPipelineOIDCAuthorizer(): %v", err)
44+
}
45+
46+
if authorizer == nil {
47+
t.Fatal("authorizer is nil, expected Authorizer")
48+
}
49+
50+
if _, err = testObtainAccessToken(ctx, authorizer); err != nil {
51+
t.Fatal(err)
52+
}
53+
}
54+
55+
func TestAccADOPipelineOIDCAuthorizer(t *testing.T) {
56+
test.AccTest(t)
57+
58+
if test.OIDCRequestToken == "" {
59+
t.Skip("test.OIDCRequestToken was empty")
60+
}
61+
if test.OIDCRequestURL == "" {
62+
t.Skip("test.OIDCRequestURL was empty")
63+
}
64+
if test.ADOServiceConnectionId == "" {
65+
t.Skip("test.ADOServiceConnectionId was empty")
66+
}
67+
68+
ctx := context.Background()
69+
70+
env, err := environments.FromName(test.Environment)
71+
if err != nil {
72+
t.Fatal(err)
73+
}
74+
75+
opts := auth.ADOPipelineOIDCAuthorizerOptions{
76+
Api: env.MicrosoftGraph,
77+
AuxiliaryTenantIds: test.AuxiliaryTenantIds,
78+
ClientId: test.ClientId,
79+
Environment: *env,
80+
TenantId: test.TenantId,
81+
IdTokenRequestUrl: test.OIDCRequestURL,
82+
IdTokenRequestToken: test.OIDCRequestToken,
83+
ServiceConnectionId: test.ADOServiceConnectionId,
84+
}
85+
86+
authorizer, err := auth.NewADOPipelineOIDCAuthorizer(ctx, opts)
87+
if err != nil {
88+
t.Fatalf("NewADOPipelineOIDCAuthorizer(): %v", err)
89+
}
90+
91+
if authorizer == nil {
92+
t.Fatal("authorizer is nil, expected Authorizer")
93+
}
94+
95+
if _, err = testObtainAccessToken(ctx, authorizer); err != nil {
96+
t.Fatal(err)
97+
}
98+
}

0 commit comments

Comments
 (0)