├── .github ├── CODEOWNERS └── workflows │ └── ci.yml ├── go.mod ├── go.sum ├── LICENSE ├── README.md ├── oidc_gateway_test.go └── oidc_gateway.go /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @steiza 2 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github/actions-oidc-proxy 2 | 3 | go 1.16 4 | 5 | require github.com/golang-jwt/jwt/v4 v4.5.2 6 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= 2 | github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= 3 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Run CI 2 | permissions: 3 | contents: read 4 | 5 | on: 6 | push: 7 | branches: [ main ] 8 | paths: 9 | - '*.go' 10 | workflow_dispatch: 11 | 12 | jobs: 13 | run_ci: 14 | runs-on: ubuntu-20.04 15 | steps: 16 | - uses: actions/checkout@v2 17 | - uses: actions/setup-go@v3 18 | with: 19 | go-version: 1.18 20 | - run: go test 21 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 GitHub 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # actions-oidc-gateway-example 2 | 3 | [![Run CI](https://github.com/github/actions-oidc-gateway-example/actions/workflows/ci.yml/badge.svg)](https://github.com/github/actions-oidc-gateway-example/actions/workflows/ci.yml) 4 | 5 | Have you ever wanted to connect to a private network from a GitHub-hosted Actions runner? 6 | 7 | This gateway is a reference implementation of how to authorize traffic from Actions into your private network, either as an API gateway or as an HTTP CONNECT proxy tunnel. 8 | 9 | It is *not* intended to be used as-is. 10 | 11 | At a minimum, you'd want to customize the claim check for your use case (see [Configuring the OIDC trust with the cloud](https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/about-security-hardening-with-openid-connect#configuring-the-oidc-trust-with-the-cloud) for examples as to what's possible here). The default configuration only allows Actions from the repo `octo-org/octo-repo`. 12 | 13 | Then, if you're using this as an API gateway, you probably want to customize the existing `/apiExample` handler (unless you **really** need proxied access to the Bing homepage?). You could add additional handlers, and even customize the claim checking per handler if you'd like. 14 | 15 | Lastly, you are responsible for deploying this gateway in a secure way with access to your private network. There's lots of different options here, but you probably want this gateway to be behind a load balancer that speaks TLS, with scoped network access to the private services it provides access to. That will probably look something like this: 16 | 17 | ```mermaid 18 | flowchart LR 19 | Runner-->|Actions OIDC Token| LB 20 | subgraph GitHub Actions 21 | Runner[Runner] 22 | end 23 | subgraph Private Network 24 | LB[Load Balancer] 25 | LB-->G1[This Gateway] 26 | LB-->G2[This Gateway] 27 | G1-->PS[Private Service] 28 | end 29 | ``` 30 | 31 | ## How would I use this? 32 | 33 | Once you customize and deploy your gateway, you can configure your Actions workflow to make use of it: 34 | 35 | ```yaml 36 | ... 37 | 38 | jobs: 39 | your_job_name: 40 | ... 41 | permissions: 42 | id-token: write 43 | steps: 44 | ... 45 | 46 | - name: Get OIDC token and set OIDC_TOKEN environment variable 47 | run: | 48 | echo "OIDC_TOKEN=$(curl -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" -H "Accept: application/json; api-version=2.0" "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=api://ActionsOIDCGateway" | jq -r ".value")" >> $GITHUB_ENV 49 | echo "::add-mask::$OIDC_TOKEN" 50 | 51 | - name: Example of using gateway as a proxy 52 | run: | 53 | curl -v -p --proxy-header "Gateway-Authorization: ${{ env.OIDC_TOKEN }}" -x https://your-load-balancer.example.com https://www.google.com 54 | 55 | - name: Example of an API gateway 56 | run: | 57 | curl -v -H "Gateway-Authorization: ${{ env.OIDC_TOKEN }}" https://your-load-balancer.example.com/apiExample 58 | 59 | ... 60 | ``` 61 | -------------------------------------------------------------------------------- /oidc_gateway_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "crypto/rand" 5 | "crypto/rsa" 6 | "encoding/base64" 7 | "encoding/json" 8 | "testing" 9 | "time" 10 | 11 | "github.com/golang-jwt/jwt/v4" 12 | ) 13 | 14 | func TestGetKeyForTokenMaker(t *testing.T) { 15 | // Create a JWKS for verifying tokens 16 | privateKey, err := rsa.GenerateKey(rand.Reader, 2048) 17 | if err != nil { 18 | panic(err) 19 | } 20 | 21 | pubKey := privateKey.Public().(*rsa.PublicKey) 22 | 23 | jwk := JWK{Kty: "RSA", Kid: "testKey", Alg: "RS256", Use: "sig"} 24 | jwk.N = base64.RawURLEncoding.EncodeToString(pubKey.N.Bytes()) 25 | jwk.E = "AQAB" 26 | 27 | jwks := JWKS{Keys: []JWK{jwk}} 28 | 29 | jwksBytes, _ := json.Marshal(jwks) 30 | getKeyFunc := getKeyFromJwks(jwksBytes) 31 | 32 | // Test token referencing known key 33 | tokenClaims := jwt.MapClaims{"for": "testing"} 34 | token := jwt.NewWithClaims(jwt.SigningMethodRS256, tokenClaims) 35 | 36 | token.Header["kid"] = "testKey" 37 | 38 | key, err := getKeyFunc(token) 39 | if err != nil { 40 | t.Error(err) 41 | } 42 | if key.(*rsa.PublicKey).N.Cmp(pubKey.N) != 0 { 43 | t.Error("public key does not match") 44 | } 45 | 46 | // Test token referencing unknown key 47 | token.Header["kid"] = "unknownKey" 48 | key, err = getKeyFunc(token) 49 | if err == nil { 50 | t.Error("Should fail when passed unknown key") 51 | } 52 | 53 | // Test token fails with any other signing key than RSA 54 | tokenHmac := jwt.NewWithClaims(jwt.SigningMethodHS256, tokenClaims) 55 | 56 | key, err = getKeyFunc(tokenHmac) 57 | if err == nil { 58 | t.Error("Should fail any signing algorithm other than RSA") 59 | } 60 | } 61 | 62 | func TestValidateTokenCameFromGitHub(t *testing.T) { 63 | // Create a JWKS for verifying tokens 64 | privateKey, err := rsa.GenerateKey(rand.Reader, 2048) 65 | if err != nil { 66 | panic(err) 67 | } 68 | 69 | pubKey := privateKey.Public().(*rsa.PublicKey) 70 | 71 | jwk := JWK{Kty: "RSA", Kid: "testKey", Alg: "RS256", Use: "sig"} 72 | jwk.N = base64.RawURLEncoding.EncodeToString(pubKey.N.Bytes()) 73 | jwk.E = "AQAB" 74 | 75 | jwks := JWKS{Keys: []JWK{jwk}} 76 | jwksBytes, _ := json.Marshal(jwks) 77 | 78 | gatewayContext := &GatewayContext{jwksCache: jwksBytes, jwksLastUpdate: time.Now()} 79 | 80 | // Test token signed in the expected way 81 | tokenClaims := jwt.MapClaims{"for": "testing"} 82 | token := jwt.NewWithClaims(jwt.SigningMethodRS256, tokenClaims) 83 | token.Header["kid"] = "testKey" 84 | 85 | signedToken, err := token.SignedString(privateKey) 86 | if err != nil { 87 | panic(err) 88 | } 89 | 90 | claims, err := validateTokenCameFromGitHub(signedToken, gatewayContext) 91 | 92 | if err != nil { 93 | t.Error(err) 94 | } 95 | if claims["for"] != "testing" { 96 | t.Error("Unable to find claims") 97 | } 98 | 99 | // Test signing with a unknown key is not allowed 100 | otherPrivateKey, err := rsa.GenerateKey(rand.Reader, 2048) 101 | if err != nil { 102 | panic(err) 103 | } 104 | 105 | signedToken, err = token.SignedString(otherPrivateKey) 106 | if err != nil { 107 | panic(err) 108 | } 109 | 110 | claims, err = validateTokenCameFromGitHub(signedToken, gatewayContext) 111 | if err == nil { 112 | t.Error("Should not validate token signed with other key") 113 | } 114 | 115 | // Test unsigned token is not allowed 116 | unsigendToken := jwt.NewWithClaims(jwt.SigningMethodNone, tokenClaims) 117 | unsigendToken.Header["kid"] = "testKey" 118 | 119 | noneToken, err := token.SignedString("none signing method allowed") 120 | 121 | claims, err = validateTokenCameFromGitHub(noneToken, gatewayContext) 122 | if err == nil { 123 | t.Error("Should not validate unsigned token") 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /oidc_gateway.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "crypto/rsa" 5 | "encoding/base64" 6 | "encoding/json" 7 | "fmt" 8 | "io" 9 | "io/ioutil" 10 | "math/big" 11 | "net" 12 | "net/http" 13 | "time" 14 | 15 | "github.com/golang-jwt/jwt/v4" 16 | ) 17 | 18 | type JWK struct { 19 | N string 20 | Kty string 21 | Kid string 22 | Alg string 23 | E string 24 | Use string 25 | X5c []string 26 | X5t string 27 | } 28 | 29 | type JWKS struct { 30 | Keys []JWK 31 | } 32 | 33 | type GatewayContext struct { 34 | jwksCache []byte 35 | jwksLastUpdate time.Time 36 | } 37 | 38 | func getKeyFromJwks(jwksBytes []byte) func(*jwt.Token) (interface{}, error) { 39 | return func(token *jwt.Token) (interface{}, error) { 40 | if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok { 41 | return nil, fmt.Errorf("Unexpected signing method: %v", token.Header["alg"]) 42 | } 43 | 44 | var jwks JWKS 45 | if err := json.Unmarshal(jwksBytes, &jwks); err != nil { 46 | return nil, fmt.Errorf("Unable to parse JWKS") 47 | } 48 | 49 | for _, jwk := range jwks.Keys { 50 | if jwk.Kid == token.Header["kid"] { 51 | nBytes, err := base64.RawURLEncoding.DecodeString(jwk.N) 52 | if err != nil { 53 | return nil, fmt.Errorf("Unable to parse key") 54 | } 55 | var n big.Int 56 | 57 | eBytes, err := base64.RawURLEncoding.DecodeString(jwk.E) 58 | if err != nil { 59 | return nil, fmt.Errorf("Unable to parse key") 60 | } 61 | var e big.Int 62 | 63 | key := rsa.PublicKey{ 64 | N: n.SetBytes(nBytes), 65 | E: int(e.SetBytes(eBytes).Uint64()), 66 | } 67 | 68 | return &key, nil 69 | } 70 | } 71 | 72 | return nil, fmt.Errorf("Unknown kid: %v", token.Header["kid"]) 73 | } 74 | } 75 | 76 | func validateTokenCameFromGitHub(oidcTokenString string, gc *GatewayContext) (jwt.MapClaims, error) { 77 | // Check if we have a recently cached JWKS 78 | now := time.Now() 79 | 80 | if now.Sub(gc.jwksLastUpdate) > time.Minute || len(gc.jwksCache) == 0 { 81 | resp, err := http.Get("https://token.actions.githubusercontent.com/.well-known/jwks") 82 | if err != nil { 83 | fmt.Println(err) 84 | return nil, fmt.Errorf("Unable to get JWKS configuration") 85 | } 86 | 87 | jwksBytes, err := ioutil.ReadAll(resp.Body) 88 | if err != nil { 89 | fmt.Println(err) 90 | return nil, fmt.Errorf("Unable to get JWKS configuration") 91 | } 92 | 93 | gc.jwksCache = jwksBytes 94 | gc.jwksLastUpdate = now 95 | } 96 | 97 | // Attempt to validate JWT with JWKS 98 | oidcToken, err := jwt.Parse(string(oidcTokenString), getKeyFromJwks(gc.jwksCache)) 99 | if err != nil || !oidcToken.Valid { 100 | return nil, fmt.Errorf("Unable to validate JWT") 101 | } 102 | 103 | claims, ok := oidcToken.Claims.(jwt.MapClaims) 104 | if !ok { 105 | return nil, fmt.Errorf("Unable to map JWT claims") 106 | } 107 | 108 | return claims, nil 109 | } 110 | 111 | func transfer(destination io.WriteCloser, source io.ReadCloser) { 112 | defer destination.Close() 113 | defer source.Close() 114 | io.Copy(destination, source) 115 | } 116 | 117 | func handleProxyRequest(w http.ResponseWriter, req *http.Request) { 118 | proxyConn, err := net.DialTimeout("tcp", req.Host, 5*time.Second) 119 | if err != nil { 120 | fmt.Println(err) 121 | http.Error(w, http.StatusText(http.StatusRequestTimeout), http.StatusRequestTimeout) 122 | return 123 | } 124 | 125 | w.WriteHeader(http.StatusOK) 126 | 127 | hijacker, ok := w.(http.Hijacker) 128 | if !ok { 129 | fmt.Println("Connection hijacking not supported") 130 | http.Error(w, http.StatusText(http.StatusExpectationFailed), http.StatusExpectationFailed) 131 | return 132 | } 133 | 134 | reqConn, _, err := hijacker.Hijack() 135 | if err != nil { 136 | fmt.Println(err) 137 | http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) 138 | return 139 | } 140 | 141 | go transfer(proxyConn, reqConn) 142 | go transfer(reqConn, proxyConn) 143 | } 144 | 145 | func handleApiRequest(w http.ResponseWriter) { 146 | resp, err := http.Get("https://www.bing.com") 147 | if err != nil { 148 | fmt.Println(err) 149 | http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 150 | return 151 | } 152 | 153 | defer resp.Body.Close() 154 | io.Copy(w, resp.Body) 155 | } 156 | 157 | func (gatewayContext *GatewayContext) ServeHTTP(w http.ResponseWriter, req *http.Request) { 158 | if req.Method != http.MethodConnect && req.RequestURI != "/apiExample" { 159 | http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound) 160 | return 161 | } 162 | 163 | // Check that the OIDC token verifies as a valid token from GitHub 164 | // 165 | // This only means the OIDC token came from any GitHub Actions workflow, 166 | // we *must* check claims specific to our use case below 167 | oidcTokenString := string(req.Header.Get("Gateway-Authorization")) 168 | 169 | claims, err := validateTokenCameFromGitHub(oidcTokenString, gatewayContext) 170 | if err != nil { 171 | fmt.Println(err) 172 | http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) 173 | return 174 | } 175 | 176 | // Token is valid, but we *must* check some claim specific to our use case 177 | // 178 | // For examples of other claims you could check, see: 179 | // https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/about-security-hardening-with-openid-connect#configuring-the-oidc-trust-with-the-cloud 180 | // 181 | // Here we check the same claims for all requests, but you could customize 182 | // the claims you check per handler below 183 | if claims["repository"] != "octo-org/octo-repo" { 184 | http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) 185 | return 186 | } 187 | 188 | // You can customize the audience when you request an Actions OIDC token. 189 | // 190 | // This is a good idea to prevent a token being accidentally leaked by a 191 | // service from being used in another service. 192 | // 193 | // The example in the README.md requests this specific custom audience. 194 | if claims["aud"] != "api://ActionsOIDCGateway" { 195 | http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) 196 | return 197 | 198 | } 199 | 200 | // Now that claims have been verified, we can service the request 201 | if req.Method == http.MethodConnect { 202 | handleProxyRequest(w, req) 203 | } else if req.RequestURI == "/apiExample" { 204 | handleApiRequest(w) 205 | } 206 | } 207 | 208 | func main() { 209 | fmt.Println("starting up") 210 | 211 | gatewayContext := &GatewayContext{jwksLastUpdate: time.Now()} 212 | 213 | server := http.Server{ 214 | Addr: ":8000", 215 | Handler: gatewayContext, 216 | ReadTimeout: 60 * time.Second, 217 | WriteTimeout: 60 * time.Second, 218 | } 219 | 220 | server.ListenAndServe() 221 | } 222 | --------------------------------------------------------------------------------