diff --git a/auth/auth.go b/auth/auth.go index 4497968f..03c36553 100644 --- a/auth/auth.go +++ b/auth/auth.go @@ -156,6 +156,21 @@ func NewClientSecretAuthorizer(ctx context.Context, environment environments.Env return conf.TokenSource(ctx, ClientCredentialsSecretType), nil } +// NewGitHubOIDCAuthorizer returns an authorizer which acquires a client assertion from a GitHub endpoint, then uses client assertion authentication to obtain an access token. +func NewGitHubOIDCAuthorizer(ctx context.Context, environment environments.Environment, api environments.Api, tenantId string, auxTenantIds []string, clientId, idTokenRequestUrl, idTokenRequestToken string) (Authorizer, error) { + conf := GitHubOIDCConfig{ + Environment: environment, + TenantID: tenantId, + AuxiliaryTenantIDs: auxTenantIds, + ClientID: clientId, + IDTokenRequestURL: idTokenRequestUrl, + IDTokenRequestToken: idTokenRequestToken, + Scopes: []string{api.DefaultScope()}, + } + + return conf.TokenSource(ctx), nil +} + func TokenEndpoint(endpoint environments.AzureADEndpoint, tenant string, version TokenVersion) (e string) { if tenant == "" { tenant = "common" diff --git a/auth/auth_test.go b/auth/auth_test.go index 3a730521..fbe4878b 100644 --- a/auth/auth_test.go +++ b/auth/auth_test.go @@ -25,6 +25,9 @@ var ( environment = os.Getenv("AZURE_ENVIRONMENT") msiEndpoint = os.Getenv("MSI_ENDPOINT") msiToken = os.Getenv("MSI_TOKEN") + + gitHubTokenURL = os.Getenv("ACTIONS_ID_TOKEN_REQUEST_URL") + gitHubToken = os.Getenv("ACTIONS_ID_TOKEN_REQUEST_TOKEN") ) func TestClientCertificateAuthorizerV1(t *testing.T) { @@ -209,3 +212,36 @@ func TestAutorestAuthorizerWrapper(t *testing.T) { t.Fatal("token.AccessToken was empty") } } + +func TestGitHubOIDCAuthorizer(t *testing.T) { + if gitHubTokenURL == "" { + t.Skip("gitHubTokenURL was empty") + } + if gitHubToken == "" { + t.Skip("gitHubToken was empty") + } + + env, err := environments.EnvironmentFromString(environment) + if err != nil { + t.Fatal(err) + } + + auth, err := auth.NewGitHubOIDCAuthorizer(context.Background(), env, env.MsGraph, tenantId, []string{}, clientId, gitHubTokenURL, gitHubToken) + if err != nil { + t.Fatalf("NewGitHubOIDCAuthorizer(): %v", err) + } + if auth == nil { + t.Fatal("auth is nil, expected Authorizer") + } + + token, err := auth.Token() + if err != nil { + t.Fatalf("auth.Token(): %v", err) + } + if token == nil { + t.Fatal("token was nil") + } + if token.AccessToken == "" { + t.Fatal("token.AccessToken was empty") + } +} diff --git a/auth/clientcredentials.go b/auth/clientcredentials.go index 69ecfb9d..5ab463a6 100644 --- a/auth/clientcredentials.go +++ b/auth/clientcredentials.go @@ -67,9 +67,14 @@ type ClientCredentialsConfig struct { PrivateKey []byte // Certificate contains the (optionally PEM encoded) X509 certificate registered - // for the application with which you are authenticating. + // for the application with which you are authenticating. Used when FederatedAssertion is empty. Certificate []byte + // FederatedAssertion contains a JWT provided by a trusted third-party vendor + // for obtaining an access token with a federated credential. When empty, an + // assertion will be created and signed using the specified PrivateKey and Certificate + FederatedAssertion string + // Resource specifies an API resource for which to request access (used for v1 tokens) Resource string @@ -183,7 +188,7 @@ type clientAssertionAuthorizer struct { conf *ClientCredentialsConfig } -func (a *clientAssertionAuthorizer) token(tokenUrl string) (*oauth2.Token, error) { +func (a *clientAssertionAuthorizer) assertion(tokenUrl string) (*string, error) { crt := a.conf.Certificate if der, _ := pem.Decode(a.conf.Certificate); der != nil { crt = der.Bytes @@ -224,6 +229,22 @@ func (a *clientAssertionAuthorizer) token(tokenUrl string) (*oauth2.Token, error return nil, fmt.Errorf("clientAssertionAuthorizer: failed to encode and sign JWT assertion") } + return &assertion, nil +} + +func (a *clientAssertionAuthorizer) token(tokenUrl string) (*oauth2.Token, error) { + assertion := a.conf.FederatedAssertion + if assertion == "" { + a, err := a.assertion(tokenUrl) + if err != nil { + return nil, err + } + if a == nil { + return nil, fmt.Errorf("clientAssertionAuthorizer: assertion was nil") + } + assertion = *a + } + v := url.Values{ "client_assertion": {assertion}, "client_assertion_type": {"urn:ietf:params:oauth:client-assertion-type:jwt-bearer"}, diff --git a/auth/github.go b/auth/github.go new file mode 100644 index 00000000..05441284 --- /dev/null +++ b/auth/github.go @@ -0,0 +1,146 @@ +package auth + +import ( + "context" + "encoding/json" + "fmt" + "io" + "io/ioutil" + "net/http" + "net/url" + + "golang.org/x/oauth2" + + "github.com/manicminer/hamilton/environments" +) + +type GitHubOIDCConfig 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 + + // IDTokenRequestURL is URL for GitHub'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 *GitHubOIDCConfig) TokenSource(ctx context.Context) Authorizer { + return NewCachedAuthorizer(&GitHubOIDCAuthorizer{ctx, c}) +} + +type GitHubOIDCAuthorizer struct { + ctx context.Context + conf *GitHubOIDCConfig +} + +func (a *GitHubOIDCAuthorizer) githubAssertion() (*string, error) { + req, err := http.NewRequestWithContext(a.ctx, http.MethodGet, a.conf.IDTokenRequestURL, http.NoBody) + if err != nil { + return nil, fmt.Errorf("githubAssertion: failed to build request") + } + + query, err := url.ParseQuery(req.URL.RawQuery) + if err != nil { + return nil, fmt.Errorf("githubAssertion: cannot parse URL query") + } + + if query.Get("audience") == "" { + query.Set("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/x-www-form-urlencoded") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("githubAssertion: cannot request token: %v", err) + } + + defer resp.Body.Close() + body, err := ioutil.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return nil, fmt.Errorf("githubAssertion: cannot parse response: %v", err) + } + + if c := resp.StatusCode; c < 200 || c > 299 { + return nil, fmt.Errorf("githubAssertion: received HTTP status %d with response: %s", resp.StatusCode, body) + } + + var tokenRes struct { + Count *int `json:"count"` + Value *string `json:"value"` + } + if err := json.Unmarshal(body, &tokenRes); err != nil { + return nil, fmt.Errorf("githubAssertion: cannot unmarshal response: %v", err) + } + + return tokenRes.Value, nil +} + +func (a *GitHubOIDCAuthorizer) tokenSource() (Authorizer, error) { + assertion, err := a.githubAssertion() + if err != nil { + return nil, err + } + if assertion == nil { + return nil, fmt.Errorf("GitHubOIDCAuthorizer: nil JWT assertion received from GitHub") + } + + 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, + TokenVersion: TokenVersion2, + Audience: a.conf.Audience, + } + + source := conf.TokenSource(a.ctx, ClientCredentialsAssertionType) + if source == nil { + return nil, fmt.Errorf("GitHubOIDCAuthorizer: nil Authorizer returned from ClientCredentialsConfig") + } + + return source, nil +} + +func (a *GitHubOIDCAuthorizer) Token() (*oauth2.Token, error) { + source, err := a.tokenSource() + if err != nil { + return nil, err + } + return source.Token() +} + +func (a *GitHubOIDCAuthorizer) AuxiliaryTokens() ([]*oauth2.Token, error) { + source, err := a.tokenSource() + if err != nil { + return nil, err + } + return source.AuxiliaryTokens() +}