Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

auth: Support AzureDevOps Pipeline OIDC auth (similar to Github OIDC auth) #1139

Merged
merged 7 commits into from
Jan 31, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 34 additions & 3 deletions sdk/auth/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,8 +130,39 @@ func main() {
credentials := auth.Credentials{
Environment: environment,
EnableAuthenticationUsingGitHubOIDC: true,
GitHubOIDCTokenRequestURL: os.Getenv("ACTIONS_ID_TOKEN_REQUEST_URL"),
GitHubOIDCTokenRequestToken: os.Getenv("ACTIONS_ID_TOKEN_REQUEST_TOKEN"),
OIDCTokenRequestURL: os.Getenv("ACTIONS_ID_TOKEN_REQUEST_URL"),
OIDCTokenRequestToken: os.Getenv("ACTIONS_ID_TOKEN_REQUEST_TOKEN"),
}
authorizer, err := auth.NewAuthorizerFromCredentials(context.TODO(), credentials, environment.MSGraph)
if err != nil {
log.Fatalf("building authorizer from credentials: %+v", err)
}
// ..
}
```

## Example: Authenticating using ADO Pipeline OIDC

```go
package main

import (
"context"
"log"
"os"

"github.com/hashicorp/go-azure-sdk/sdk/auth"
"github.com/hashicorp/go-azure-sdk/sdk/environments"
)

func main() {
environment := environments.Public
credentials := auth.Credentials{
Environment: environment,
EnableAuthenticationUsingADOPipelineOIDC: true,
OIDCTokenRequestURL: os.Getenv("SYSTEM_OIDCREQUESTURI"),
OIDCTokenRequestToken: os.Getenv("SYSTEM_ACCESSTOKEN"),
ADOPipelineServiceConnectionID: "<Service Connection ID>",
}
authorizer, err := auth.NewAuthorizerFromCredentials(context.TODO(), credentials, environment.MSGraph)
if err != nil {
Expand Down Expand Up @@ -167,4 +198,4 @@ func main() {
}
// ..
}
```
```
212 changes: 212 additions & 0 deletions sdk/auth/ado_pipeline_oidc_authorizer.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
// Copyright (c) HashiCorp Inc. All rights reserved.
// Licensed under the MPL-2.0 License. See NOTICE.txt in the project root for license information.

package auth

import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"

"github.com/hashicorp/go-azure-sdk/sdk/environments"
"golang.org/x/oauth2"
)

const (
adoPipelineOIDCAPIVersion = "7.1"
)

type ADOPipelineOIDCAuthorizerOptions struct {
// Api describes the Azure API being used
Api environments.Api

// ClientId is the client ID used when authenticating
ClientId string

// ServiceConnectionId is the ADO service connection ID used when authenticating
ServiceConnectionId string

// Environment is the Azure environment/cloud being targeted
Environment environments.Environment

// TenantId is the tenant to authenticate against
TenantId string

// AuxiliaryTenantIds lists additional tenants to authenticate against, currently only
// used for Resource Manager when auxiliary tenants are needed.
// e.g. https://learn.microsoft.com/en-us/azure/azure-resource-manager/management/authenticate-multi-tenant
AuxiliaryTenantIds []string

// IdTokenRequestUrl is the URL for the OIDC provider from which to request an ID token.
// Usually exposed via the SYSTEM_OIDCREQUESTURI environment variable when running in ADO Pipelines
IdTokenRequestUrl string

// IdTokenRequestToken is the bearer token for the request to the OIDC provider.
// Usually exposed via the SYSTEM_ACCESSTOKEN environment variable when running in ADO Pipelines
IdTokenRequestToken string
}

// NewADOPipelineOIDCAuthorizer returns an authorizer which acquires a client assertion from a ADO endpoint, then uses client assertion authentication to obtain an access token.
func NewADOPipelineOIDCAuthorizer(ctx context.Context, options ADOPipelineOIDCAuthorizerOptions) (Authorizer, error) {
scope, err := environments.Scope(options.Api)
if err != nil {
return nil, fmt.Errorf("determining scope for %q: %+v", options.Api.Name(), err)
}

conf := adoPipelineOIDCConfig{
Environment: options.Environment,
TenantID: options.TenantId,
AuxiliaryTenantIDs: options.AuxiliaryTenantIds,
ClientID: options.ClientId,
ServiceConnectionID: options.ServiceConnectionId,
IDTokenRequestURL: options.IdTokenRequestUrl,
IDTokenRequestToken: options.IdTokenRequestToken,
Scopes: []string{
*scope,
},
}

return conf.TokenSource(ctx)
}

var _ Authorizer = &ADOPipelineOIDCAuthorizer{}

type ADOPipelineOIDCAuthorizer struct {
conf *adoPipelineOIDCConfig
}

func (a *ADOPipelineOIDCAuthorizer) adoPipelineAssertion(ctx context.Context, _ *http.Request) (*string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, a.conf.IDTokenRequestURL, http.NoBody)
if err != nil {
return nil, fmt.Errorf("adoPipelineAssertion: failed to build request: %+v", err)
}

query, err := url.ParseQuery(req.URL.RawQuery)
if err != nil {
return nil, fmt.Errorf("adoPipelineAssertion: cannot parse URL query")
}
if query.Get("api-version") == "" {
query.Add("api-version", adoPipelineOIDCAPIVersion)
}
if query.Get("serviceConnectionId") == "" {
query.Add("serviceConnectionId", a.conf.ServiceConnectionID)
}
if query.Get("audience") == "" {
query.Add("audience", "api://AzureADTokenExchange")
}
req.URL.RawQuery = query.Encode()

req.Header.Set("Accept", "application/json")
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", a.conf.IDTokenRequestToken))
req.Header.Set("Content-Type", "application/json")

resp, err := Client.Do(req)
if err != nil {
return nil, fmt.Errorf("adoPipelineAssertion: cannot request token: %v", err)
}

defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return nil, fmt.Errorf("adoPipelineAssertion: cannot parse response: %v", err)
}

if c := resp.StatusCode; c < 200 || c > 299 {
return nil, fmt.Errorf("adoPipelineAssertion: received HTTP status %d with response: %s", resp.StatusCode, body)
}

var tokenRes struct {
Value *string `json:"oidcToken"`
}
if err := json.Unmarshal(body, &tokenRes); err != nil {
return nil, fmt.Errorf("adoPipelineAssertion: cannot unmarshal response: %v", err)
}

return tokenRes.Value, nil
}

func (a *ADOPipelineOIDCAuthorizer) tokenSource(ctx context.Context, req *http.Request) (Authorizer, error) {
assertion, err := a.adoPipelineAssertion(ctx, req)
if err != nil {
return nil, err
}
if assertion == nil {
return nil, fmt.Errorf("ADOPipelineOIDCAuthorizer: nil JWT assertion received from ADOPipeline")
}

conf := clientCredentialsConfig{
Environment: a.conf.Environment,
TenantID: a.conf.TenantID,
AuxiliaryTenantIDs: a.conf.AuxiliaryTenantIDs,
ClientID: a.conf.ClientID,
FederatedAssertion: *assertion,
Scopes: a.conf.Scopes,
TokenURL: a.conf.TokenURL,
Audience: a.conf.Audience,
}

source, err := conf.TokenSource(ctx, clientCredentialsAssertionType)
if err != nil {
return nil, fmt.Errorf("ADOPipelineOIDCAuthorizer: building Authorizer: %+v", err)
}
return source, nil
}

func (a *ADOPipelineOIDCAuthorizer) Token(ctx context.Context, req *http.Request) (*oauth2.Token, error) {
source, err := a.tokenSource(ctx, req)
if err != nil {
return nil, err
}
return source.Token(ctx, req)
}

func (a *ADOPipelineOIDCAuthorizer) AuxiliaryTokens(ctx context.Context, req *http.Request) ([]*oauth2.Token, error) {
source, err := a.tokenSource(ctx, req)
if err != nil {
return nil, err
}
return source.AuxiliaryTokens(ctx, req)
}

type adoPipelineOIDCConfig struct {
// Environment is the national cloud environment to use
Environment environments.Environment

// TenantID is the required tenant ID for the primary token
TenantID string

// AuxiliaryTenantIDs is an optional list of tenant IDs for which to obtain additional tokens
AuxiliaryTenantIDs []string

// ClientID is the application's ID.
ClientID string

// ServiceConnectionID is the ADO service connection ID used when authenticating
ServiceConnectionID string

// IDTokenRequestURL is the URL for ADO Pipeline's OIDC provider.
IDTokenRequestURL string

// IDTokenRequestToken is the bearer token for the request to the OIDC provider.
IDTokenRequestToken string

// Scopes specifies a list of requested permission scopes (used for v2 tokens)
Scopes []string

// TokenURL is the clientCredentialsToken endpoint, which overrides the default endpoint constructed from a tenant ID
TokenURL string

// Audience optionally specifies the intended audience of the
// request. If empty, the value of TokenURL is used as the
// intended audience.
Audience string
}

func (c *adoPipelineOIDCConfig) TokenSource(ctx context.Context) (Authorizer, error) {
return NewCachedAuthorizer(&ADOPipelineOIDCAuthorizer{
conf: c,
})
}
98 changes: 98 additions & 0 deletions sdk/auth/ado_pipeline_oidc_authorizer_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0

package auth_test

import (
"context"
"fmt"
"testing"

"github.com/hashicorp/go-azure-sdk/sdk/auth"
"github.com/hashicorp/go-azure-sdk/sdk/environments"
"github.com/hashicorp/go-azure-sdk/sdk/internal/test"
)

func TestADOPipelineOIDCAuthorizer(t *testing.T) {
ctx := context.Background()
env := environments.AzurePublic()

mockHost := "ado-oidc-issuer"
auth.Client = &oidcMockClient{
authorization: *env.Authorization,
platform: OidcMockClientPlatformADOPipeline,
mockHost: mockHost,
}

idTokenRequestUrl := fmt.Sprintf("https://%s/vend-id-token", mockHost)
idTokenRequestToken := test.DummyAccessToken

opts := auth.ADOPipelineOIDCAuthorizerOptions{
Api: env.MicrosoftGraph,
AuxiliaryTenantIds: test.AuxiliaryTenantIds,
ClientId: "11111111-0000-0000-0000-000000000000",
Environment: *env,
IdTokenRequestToken: idTokenRequestToken,
IdTokenRequestUrl: idTokenRequestUrl,
ServiceConnectionId: "test-service-connection",
TenantId: "00000000-1111-0000-0000-000000000000",
}

authorizer, err := auth.NewADOPipelineOIDCAuthorizer(ctx, opts)
if err != nil {
t.Fatalf("NewADOPipelineOIDCAuthorizer(): %v", err)
}

if authorizer == nil {
t.Fatal("authorizer is nil, expected Authorizer")
}

if _, err = testObtainAccessToken(ctx, authorizer); err != nil {
t.Fatal(err)
}
}

func TestAccADOPipelineOIDCAuthorizer(t *testing.T) {
test.AccTest(t)

if test.OIDCRequestToken == "" {
t.Skip("test.OIDCRequestToken was empty")
}
if test.OIDCRequestURL == "" {
t.Skip("test.OIDCRequestURL was empty")
}
if test.ADOServiceConnectionId == "" {
t.Skip("test.ADOServiceConnectionId was empty")
}

ctx := context.Background()

env, err := environments.FromName(test.Environment)
if err != nil {
t.Fatal(err)
}

opts := auth.ADOPipelineOIDCAuthorizerOptions{
Api: env.MicrosoftGraph,
AuxiliaryTenantIds: test.AuxiliaryTenantIds,
ClientId: test.ClientId,
Environment: *env,
TenantId: test.TenantId,
IdTokenRequestUrl: test.OIDCRequestURL,
IdTokenRequestToken: test.OIDCRequestToken,
ServiceConnectionId: test.ADOServiceConnectionId,
}

authorizer, err := auth.NewADOPipelineOIDCAuthorizer(ctx, opts)
if err != nil {
t.Fatalf("NewADOPipelineOIDCAuthorizer(): %v", err)
}

if authorizer == nil {
t.Fatal("authorizer is nil, expected Authorizer")
}

if _, err = testObtainAccessToken(ctx, authorizer); err != nil {
t.Fatal(err)
}
}
Loading
Loading