github.com/yogeshkumararora/slsa-github-generator@v1.10.1-0.20240520161934-11278bd5afb4/github/oidc.go (about)

     1  // Copyright 2022 SLSA Authors
     2  //
     3  // Licensed under the Apache License, Version 2.0 (the "License");
     4  // you may not use this file except in compliance with the License.
     5  // You may obtain a copy of the License at
     6  //
     7  //     https://www.apache.org/licenses/LICENSE-2.0
     8  //
     9  // Unless required by applicable law or agreed to in writing, software
    10  // distributed under the License is distributed on an "AS IS" BASIS,
    11  // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    12  // See the License for the specific language governing permissions and
    13  // limitations under the License.
    14  
    15  package github
    16  
    17  import (
    18  	"bytes"
    19  	"context"
    20  	"encoding/json"
    21  	"errors"
    22  	"fmt"
    23  	"io"
    24  	"net/http"
    25  	"net/url"
    26  	"os"
    27  	"sort"
    28  	"time"
    29  
    30  	"github.com/coreos/go-oidc/v3/oidc"
    31  )
    32  
    33  var defaultActionsProviderURL = "https://token.actions.githubusercontent.com"
    34  
    35  const (
    36  	requestTokenEnvKey = "ACTIONS_ID_TOKEN_REQUEST_TOKEN"
    37  	requestURLEnvKey   = "ACTIONS_ID_TOKEN_REQUEST_URL"
    38  )
    39  
    40  // OIDCToken represents the contents of a GitHub OIDC JWT token.
    41  type OIDCToken struct {
    42  	// Issuer is the token issuer.
    43  	Issuer string
    44  
    45  	// JobWorkflowRef is a reference to the current job workflow.
    46  	JobWorkflowRef string `json:"job_workflow_ref"`
    47  
    48  	// RepositoryID is the unique repository ID.
    49  	RepositoryID string `json:"repository_id"`
    50  
    51  	// RepositoryOwnerID is the unique ID of the owner of the repository.
    52  	RepositoryOwnerID string `json:"repository_owner_id"`
    53  
    54  	// ActorID is the unique ID of the actor who triggered the build.
    55  	ActorID string `json:"actor_id"`
    56  
    57  	// Expiry is the expiration date of the token.
    58  	Expiry time.Time
    59  
    60  	// Audience is the audience for which the token was granted.
    61  	Audience []string
    62  }
    63  
    64  var (
    65  	// errURLError indicates the OIDC server URL is invalid.
    66  	errURLError = errors.New("url")
    67  
    68  	// errRequestError indicates an error requesting the token from the issuer.
    69  	errRequestError = errors.New("http request")
    70  
    71  	// errToken indicates an error in the format of the token.
    72  	errToken = errors.New("token")
    73  
    74  	// errClaims indicates an error in the claims of the token.
    75  	errClaims = errors.New("claims")
    76  
    77  	// errVerify indicates an error in the token verification process.
    78  	errVerify = errors.New("verify")
    79  )
    80  
    81  // OIDCClient is a client for the GitHub OIDC provider.
    82  type OIDCClient struct {
    83  	// requestURL is the GitHub URL to request a OIDC token.
    84  	requestURL *url.URL
    85  
    86  	// verifierFunc is a factory to generate an oidc.IDTokenVerifier for token verification.
    87  	// This is used for tests.
    88  	verifierFunc func(context.Context) (*oidc.IDTokenVerifier, error)
    89  
    90  	// bearerToken is used to request an ID token.
    91  	bearerToken string
    92  }
    93  
    94  // NewOIDCClient returns new GitHub OIDC provider client.
    95  func NewOIDCClient() (*OIDCClient, error) {
    96  	requestURL := os.Getenv(requestURLEnvKey)
    97  	parsedURL, err := url.ParseRequestURI(requestURL)
    98  	if err != nil {
    99  		return nil, fmt.Errorf(
   100  			"%w: invalid request URL %q: %w; does your workflow have `id-token: write` scope?",
   101  			errURLError,
   102  			requestURL, err,
   103  		)
   104  	}
   105  
   106  	c := OIDCClient{
   107  		requestURL:  parsedURL,
   108  		bearerToken: os.Getenv(requestTokenEnvKey),
   109  	}
   110  	c.verifierFunc = func(ctx context.Context) (*oidc.IDTokenVerifier, error) {
   111  		provider, err := oidc.NewProvider(ctx, defaultActionsProviderURL)
   112  		if err != nil {
   113  			return nil, err
   114  		}
   115  		return provider.Verifier(&oidc.Config{
   116  			// NOTE: Disable ClientID check.
   117  			// ClientID is normally checked to be part of the audience but we
   118  			// don't use a ClientID when requesting a token.
   119  			SkipClientIDCheck: true,
   120  		}), nil
   121  	}
   122  	return &c, nil
   123  }
   124  
   125  func (c *OIDCClient) newRequestURL(audience []string) string {
   126  	requestURL := *c.requestURL
   127  	q := requestURL.Query()
   128  	for _, a := range audience {
   129  		q.Add("audience", a)
   130  	}
   131  	requestURL.RawQuery = q.Encode()
   132  	return requestURL.String()
   133  }
   134  
   135  func (c *OIDCClient) requestToken(ctx context.Context, audience []string) ([]byte, error) {
   136  	// Request the token.
   137  	req, err := http.NewRequest("GET", c.newRequestURL(audience), nil)
   138  	if err != nil {
   139  		return nil, fmt.Errorf("%w: creating request: %w", errRequestError, err)
   140  	}
   141  	req.Header.Add("Authorization", "bearer "+c.bearerToken)
   142  	req = req.WithContext(ctx)
   143  	resp, err := http.DefaultClient.Do(req)
   144  	if err != nil {
   145  		return nil, fmt.Errorf("%w: %w", errRequestError, err)
   146  	}
   147  	defer resp.Body.Close()
   148  
   149  	// Read the response.
   150  	b, err := io.ReadAll(resp.Body)
   151  	if err != nil {
   152  		return nil, fmt.Errorf("%w: reading response: %w", errRequestError, err)
   153  	}
   154  	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
   155  		return nil, fmt.Errorf("%w: response: %s: %s", errRequestError, resp.Status, string(b))
   156  	}
   157  	return b, nil
   158  }
   159  
   160  func (c *OIDCClient) decodePayload(b []byte) (string, error) {
   161  	// Extract the raw token from JSON payload.
   162  	var payload struct {
   163  		Value string `json:"value"`
   164  	}
   165  	decoder := json.NewDecoder(bytes.NewReader(b))
   166  	if err := decoder.Decode(&payload); err != nil {
   167  		return "", fmt.Errorf("%w: parsing JSON: %w", errToken, err)
   168  	}
   169  	return payload.Value, nil
   170  }
   171  
   172  // verifyToken verifies the token contents and signature.
   173  func (c *OIDCClient) verifyToken(ctx context.Context, audience []string, payload string) (*oidc.IDToken, error) {
   174  	// Verify the token.
   175  	verifier, err := c.verifierFunc(ctx)
   176  	if err != nil {
   177  		return nil, fmt.Errorf("%w: creating verifier: %w", errVerify, err)
   178  	}
   179  
   180  	t, err := verifier.Verify(ctx, payload)
   181  	if err != nil {
   182  		return nil, fmt.Errorf("%w: could not verify token: %w", errVerify, err)
   183  	}
   184  
   185  	// Verify the audience received is the one we requested.
   186  	if !compareStringSlice(audience, t.Audience) {
   187  		return nil, fmt.Errorf("%w: audience not equal %q != %q", errVerify, audience, t.Audience)
   188  	}
   189  
   190  	return t, nil
   191  }
   192  
   193  func (c *OIDCClient) decodeToken(token *oidc.IDToken) (*OIDCToken, error) {
   194  	var t OIDCToken
   195  	t.Issuer = token.Issuer
   196  	t.Audience = token.Audience
   197  	t.Expiry = token.Expiry
   198  
   199  	if err := token.Claims(&t); err != nil {
   200  		return nil, fmt.Errorf("%w: getting claims: %w", errToken, err)
   201  	}
   202  
   203  	return &t, nil
   204  }
   205  
   206  func (c *OIDCClient) verifyClaims(token *OIDCToken) error {
   207  	// Verify some of the fields we expect to populate the provenance.
   208  	if token.RepositoryID == "" {
   209  		return fmt.Errorf("%w: repository ID is empty", errClaims)
   210  	}
   211  	if token.RepositoryOwnerID == "" {
   212  		return fmt.Errorf("%w: repository owner ID is empty", errClaims)
   213  	}
   214  	if token.ActorID == "" {
   215  		return fmt.Errorf("%w: actor ID is empty", errClaims)
   216  	}
   217  	if token.JobWorkflowRef == "" {
   218  		return fmt.Errorf("%w: job workflow ref is empty", errClaims)
   219  	}
   220  	return nil
   221  }
   222  
   223  // Token requests an OIDC token from GitHub's provider, verifies it, and
   224  // returns the token.
   225  func (c *OIDCClient) Token(ctx context.Context, audience []string) (*OIDCToken, error) {
   226  	tokenBytes, err := c.requestToken(ctx, audience)
   227  	if err != nil {
   228  		return nil, err
   229  	}
   230  
   231  	tokenPayload, err := c.decodePayload(tokenBytes)
   232  	if err != nil {
   233  		return nil, err
   234  	}
   235  
   236  	t, err := c.verifyToken(ctx, audience, tokenPayload)
   237  	if err != nil {
   238  		return nil, err
   239  	}
   240  
   241  	token, err := c.decodeToken(t)
   242  	if err != nil {
   243  		return nil, err
   244  	}
   245  
   246  	if err := c.verifyClaims(token); err != nil {
   247  		return nil, err
   248  	}
   249  
   250  	return token, nil
   251  }
   252  
   253  func compareStringSlice(s1, s2 []string) bool {
   254  	// Verify the audience received is the one we requested.
   255  	if len(s1) != len(s2) {
   256  		return false
   257  	}
   258  
   259  	c1 := append([]string{}, s1...)
   260  	sort.Strings(c1)
   261  
   262  	c2 := append([]string{}, s2...)
   263  	sort.Strings(c2)
   264  
   265  	for i := range c1 {
   266  		if c1[i] != c2[i] {
   267  			return false
   268  		}
   269  	}
   270  
   271  	return true
   272  }