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

Support for authenticating via GitHub OIDC #142

Merged
merged 1 commit into from
Jan 26, 2022
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
15 changes: 15 additions & 0 deletions auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
36 changes: 36 additions & 0 deletions auth/auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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")
}
}
25 changes: 23 additions & 2 deletions auth/clientcredentials.go
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"},
Expand Down
146 changes: 146 additions & 0 deletions auth/github.go
Original file line number Diff line number Diff line change
@@ -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
manicminer marked this conversation as resolved.
Show resolved Hide resolved
}

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()
}