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 }