Skip to content

Commit 880c25b

Browse files
committed
feat: setup-gap-authz
1 parent c741150 commit 880c25b

8 files changed

Lines changed: 402 additions & 13 deletions

File tree

actions/setup-gap-authz/Dockerfile

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
FROM golang:1.21-alpine AS builder
2+
3+
WORKDIR /app
4+
COPY main.go .
5+
RUN go mod init github.com/auth-service && \
6+
go build -o auth-service .
7+
8+
FROM alpine:3.18
9+
10+
RUN apk --no-cache add ca-certificates
11+
WORKDIR /app
12+
COPY --from=builder /app/auth-service .
13+
14+
ENV AUTH_SERVICE_PORT=9001
15+
EXPOSE ${AUTH_SERVICE_PORT}
16+
17+
CMD ["/app/auth-service"]

actions/setup-gap-authz/README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# setup-gap-authz
2+
3+
Used by the [setup-gap](../setup-gap/) action.
4+
5+
This creates a local service which handles the fetching and refreshing of JWT.

actions/setup-gap-authz/action.yml

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
name: setup-gap-authz
2+
description: "An ext_authz service used by setup-gap to handle authorization"
3+
4+
inputs:
5+
gap-name:
6+
description: "See setup-gap for more details."
7+
required: true
8+
9+
github-oidc-token-header-name:
10+
description: "See setup-gap for more details."
11+
required: true
12+
13+
port:
14+
description: "The port the auth service will listen on."
15+
required: false
16+
default: "9001"
17+
18+
runs:
19+
using: "composite"
20+
steps:
21+
- name: Build and run auth service
22+
shell: bash
23+
env:
24+
AUTH_SERVICE_PORT: ${{ inputs.port }}
25+
GITHUB_OIDC_TOKEN_HEADER_NAME:
26+
${{ inputs.github-oidc-token-header-name }}
27+
GAP_NAME: ${{ inputs.gap-name }}
28+
run: |
29+
# Get the Github OIDC hostname
30+
export GITHUB_OIDC_HOSTNAME=$(echo "${ACTIONS_ID_TOKEN_REQUEST_URL}" | awk -F[/:] '{print $4}')
31+
32+
echo "Building auth service container..."
33+
# Pass the port at build time
34+
docker build -t "gap-auth-service-${{ inputs.gap-name }}" \
35+
--build-arg AUTH_SERVICE_PORT="${AUTH_SERVICE_PORT}" \
36+
"${{ github.action_path }}"
37+
38+
echo "Starting auth service on port ${AUTH_SERVICE_PORT}..."
39+
docker run -d \
40+
--name "gap-auth-service-${{ inputs.gap-name }}" \
41+
-p "${AUTH_SERVICE_PORT}:${AUTH_SERVICE_PORT}" \
42+
-e AUTH_SERVICE_PORT="${AUTH_SERVICE_PORT}" \
43+
-e GITHUB_OIDC_TOKEN_HEADER_NAME="${GITHUB_OIDC_TOKEN_HEADER_NAME}" \
44+
-e GITHUB_OIDC_HOSTNAME="${GITHUB_OIDC_HOSTNAME}" \
45+
-e ACTIONS_ID_TOKEN_REQUEST_URL="${ACTIONS_ID_TOKEN_REQUEST_URL}" \
46+
-e ACTIONS_ID_TOKEN_REQUEST_TOKEN="${ACTIONS_ID_TOKEN_REQUEST_TOKEN}" \
47+
-e GITHUB_REPOSITORY="${GITHUB_REPOSITORY}" \
48+
"gap-auth-service-${{ inputs.gap-name }}"
49+
50+
- name: Wait for auth service to be ready
51+
shell: bash
52+
env:
53+
AUTH_SERVICE_PORT: ${{ inputs.port }}
54+
run: |
55+
echo "Waiting for auth service to be ready..."
56+
echo "Sending requests to http://localhost:${AUTH_SERVICE_PORT}/healthz"
57+
for i in {1..25}; do
58+
if curl -s http://localhost:${AUTH_SERVICE_PORT}/healthz | grep -q "OK"; then
59+
echo "Auth service is ready."
60+
break
61+
fi
62+
63+
if [ $i -eq 25 ]; then
64+
echo "::error::Auth service failed to start."
65+
exit 1
66+
fi
67+
68+
echo "Waiting for auth service... (attempt $i/25)"
69+
sleep 1
70+
done

actions/setup-gap-authz/main.go

Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
package main
2+
3+
import (
4+
"encoding/base64"
5+
"encoding/json"
6+
"fmt"
7+
"io"
8+
"log"
9+
"net/http"
10+
"os"
11+
"strings"
12+
"sync"
13+
"time"
14+
)
15+
16+
// JWT token management
17+
var (
18+
jwtToken string
19+
jwtExpiration int64
20+
tokenRefreshMutex sync.Mutex
21+
)
22+
23+
// JWT payload structure
24+
type JWTPayload struct {
25+
Exp int64 `json:"exp"`
26+
}
27+
28+
// OIDCResponse structure
29+
type OIDCResponse struct {
30+
Value string `json:"value"`
31+
}
32+
33+
// Auth Request from Envoy
34+
type AuthRequest struct {
35+
Attributes struct {
36+
Request struct {
37+
Http struct {
38+
Method string `json:"method"`
39+
Path string `json:"path"`
40+
Host string `json:"host"`
41+
Headers map[string]string `json:"headers"`
42+
Protocol string `json:"protocol"`
43+
} `json:"http"`
44+
} `json:"request"`
45+
} `json:"attributes"`
46+
}
47+
48+
// Auth Response to Envoy
49+
type AuthResponse struct {
50+
Status struct {
51+
Code int `json:"code"`
52+
} `json:"status"`
53+
HttpResponse struct {
54+
Headers map[string]string `json:"headers"`
55+
} `json:"httpResponse"`
56+
}
57+
58+
// Decode the JWT payload
59+
func decodeJWTPayload(jwt string) (*JWTPayload, error) {
60+
parts := strings.Split(jwt, ".")
61+
if len(parts) != 3 {
62+
return nil, fmt.Errorf("invalid JWT format")
63+
}
64+
65+
// Add padding if needed
66+
payload := parts[1]
67+
if l := len(payload) % 4; l > 0 {
68+
payload += strings.Repeat("=", 4-l)
69+
}
70+
71+
// Decode the base64 encoded payload
72+
decoded, err := base64.URLEncoding.DecodeString(payload)
73+
if err != nil {
74+
return nil, fmt.Errorf("failed to decode payload: %v", err)
75+
}
76+
77+
// Parse the JSON payload
78+
var payloadObj JWTPayload
79+
if err := json.Unmarshal(decoded, &payloadObj); err != nil {
80+
return nil, fmt.Errorf("failed to parse payload: %v", err)
81+
}
82+
83+
return &payloadObj, nil
84+
}
85+
86+
// Fetch a new JWT token from GitHub
87+
func fetchGitHubOIDCToken() (string, int64, error) {
88+
tokenRefreshMutex.Lock()
89+
defer tokenRefreshMutex.Unlock()
90+
91+
// Check if we already have a valid token
92+
now := time.Now().Unix()
93+
if jwtToken != "" && jwtExpiration > now+60 {
94+
log.Printf("Using existing token, expires in %d seconds", jwtExpiration-now)
95+
return jwtToken, jwtExpiration, nil
96+
}
97+
98+
log.Println("Fetching new GitHub OIDC token")
99+
100+
// Prepare the URL and headers
101+
audience := "gap"
102+
requestURL := os.Getenv("ACTIONS_ID_TOKEN_REQUEST_URL") + "&audience=" + audience
103+
hostname := os.Getenv("GITHUB_OIDC_HOSTNAME")
104+
authToken := os.Getenv("ACTIONS_ID_TOKEN_REQUEST_TOKEN")
105+
106+
// Create a new HTTP request
107+
req, err := http.NewRequest("GET", requestURL, nil)
108+
if err != nil {
109+
return "", 0, fmt.Errorf("failed to create request: %v", err)
110+
}
111+
112+
// Set headers
113+
req.Host = hostname
114+
req.Header.Set("Authorization", "Bearer "+authToken)
115+
req.Header.Set("Accept", "application/json")
116+
117+
// Send the request
118+
client := &http.Client{Timeout: 5 * time.Second}
119+
resp, err := client.Do(req)
120+
if err != nil {
121+
return "", 0, fmt.Errorf("request failed: %v", err)
122+
}
123+
defer resp.Body.Close()
124+
125+
// Check the response status
126+
if resp.StatusCode != http.StatusOK {
127+
return "", 0, fmt.Errorf("request failed with status: %d", resp.StatusCode)
128+
}
129+
130+
// Read the response body
131+
body, err := io.ReadAll(resp.Body)
132+
if err != nil {
133+
return "", 0, fmt.Errorf("failed to read response: %v", err)
134+
}
135+
136+
// Parse the JSON response
137+
var oidcResp OIDCResponse
138+
if err := json.Unmarshal(body, &oidcResp); err != nil {
139+
return "", 0, fmt.Errorf("failed to parse response: %v", err)
140+
}
141+
142+
// Extract the token
143+
token := oidcResp.Value
144+
if token == "" {
145+
return "", 0, fmt.Errorf("empty token received")
146+
}
147+
148+
// Parse the JWT to get expiration
149+
payload, err := decodeJWTPayload(token)
150+
if err != nil {
151+
log.Printf("Failed to decode JWT payload: %v, using default expiration", err)
152+
return token, now + 300, nil // 5 minute default
153+
}
154+
155+
log.Printf("Token fetched successfully, expires at %s", time.Unix(payload.Exp, 0).Format(time.RFC3339))
156+
return token, payload.Exp, nil
157+
}
158+
159+
// handleCheck processes auth requests from Envoy
160+
func handleCheck(w http.ResponseWriter, r *http.Request) {
161+
// Parse the request
162+
var authReq AuthRequest
163+
if err := json.NewDecoder(r.Body).Decode(&authReq); err != nil {
164+
http.Error(w, "Invalid request body", http.StatusBadRequest)
165+
return
166+
}
167+
168+
// Fetch or refresh the token
169+
token, _, err := fetchGitHubOIDCToken()
170+
if err != nil {
171+
log.Printf("Error fetching token: %v", err)
172+
http.Error(w, "Failed to fetch token", http.StatusInternalServerError)
173+
return
174+
}
175+
176+
// Prepare the response
177+
authResp := AuthResponse{}
178+
authResp.Status.Code = 200
179+
authResp.HttpResponse.Headers = make(map[string]string)
180+
181+
// Add GitHub OIDC token header
182+
headerName := os.Getenv("GITHUB_OIDC_TOKEN_HEADER_NAME")
183+
authResp.HttpResponse.Headers[headerName] = "Bearer " + token
184+
185+
// Add repository header if not already present
186+
repoHeader := "x-repository"
187+
if _, exists := authReq.Attributes.Request.Http.Headers[repoHeader]; !exists {
188+
authResp.HttpResponse.Headers[repoHeader] = os.Getenv("GITHUB_REPOSITORY")
189+
}
190+
191+
// Send the response
192+
w.Header().Set("Content-Type", "application/json")
193+
json.NewEncoder(w).Encode(authResp)
194+
}
195+
196+
// handleHealthz is a simple health check endpoint
197+
func handleHealthz(w http.ResponseWriter, r *http.Request) {
198+
fmt.Fprint(w, "OK")
199+
}
200+
201+
func main() {
202+
port := os.Getenv("AUTH_SERVICE_PORT")
203+
if port == "" {
204+
log.Fatal("AUTH_SERVICE_PORT environment variable is required.")
205+
os.Exit(1)
206+
}
207+
208+
// Initialize token
209+
_, _, err := fetchGitHubOIDCToken()
210+
if err != nil {
211+
log.Printf("Initial token fetch failed: %v", err)
212+
}
213+
214+
// Set up HTTP server
215+
http.HandleFunc("/check", handleCheck)
216+
http.HandleFunc("/healthz", handleHealthz)
217+
218+
log.Printf("Starting auth service on port %s", port)
219+
if err := http.ListenAndServe(":"+port, nil); err != nil {
220+
log.Fatalf("Failed to start server: %v", err)
221+
}
222+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"name": "setup-gap-authz",
3+
"version": "0.0.0",
4+
"description": "",
5+
"private": true,
6+
"scripts": {},
7+
"author": "@smartcontractkit",
8+
"license": "MIT",
9+
"dependencies": {},
10+
"repository": "https://github.com/smartcontractkit/.github"
11+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"name": "setup-gap-authz",
3+
"$schema": "../../node_modules/nx/schemas/project-schema.json",
4+
"projectType": "application",
5+
"sourceRoot": "actions/setup-gap-authz",
6+
"targets": {}
7+
}

actions/setup-gap/action.yml

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,11 @@ inputs:
117117
default: ""
118118
required: false
119119

120+
auth-service-port:
121+
description: "The port the auth service will listen on."
122+
required: false
123+
default: "9001"
124+
120125
outputs:
121126
local-proxy-port:
122127
description: "The port the local proxy will listen on."
@@ -264,6 +269,14 @@ runs:
264269
# Verify the installation by checking version
265270
gomplate --version
266271
272+
- name: Build and run auth service
273+
uses: smartcontractkit/.github/actions/setup-gap-authz@feat/setup-gap-authz
274+
with:
275+
gap-name: ${{ inputs.gap-name }}
276+
github-oidc-token-header-name:
277+
${{ inputs.github-oidc-token-header-name }}
278+
port: ${{ inputs.auth-service-port }}
279+
267280
- name: Run local Envoy proxy
268281
shell: sh
269282
env:
@@ -279,6 +292,7 @@ runs:
279292
PROXY_PORT: ${{ inputs.proxy-port }}
280293
WEBSOCKETS_PROXY_PORT: ${{ inputs.websockets-proxy-port }}
281294
WEBSOCKETS_SERVICES: ${{ inputs.websockets-services }}
295+
AUTH_SERVICE_PORT: ${{ inputs.auth-service-port }}
282296
run: |
283297
# Get the Github OIDC hostname
284298
export GITHUB_OIDC_HOSTNAME=$(echo $ACTIONS_ID_TOKEN_REQUEST_URL | awk -F[/:] '{print $4}')
@@ -295,7 +309,7 @@ runs:
295309
echo "Using log level: ${PROXY_LOG_LEVEL}"
296310
297311
# List of required ENVs to check
298-
required_env_vars="DYNAMIC_PROXY_PORT ENABLE_PROXY_DEBUG GITHUB_OIDC_TOKEN_HEADER_NAME ENVOY_PROXY_IMAGE GITHUB_OIDC_HOSTNAME K8S_API_ENDPOINT_PORT MAIN_DNS_ZONE PROXY_LOG_LEVEL PROXY_PORT GITHUB_REPOSITORY WEBSOCKETS_PROXY_PORT"
312+
required_env_vars="DYNAMIC_PROXY_PORT ENABLE_PROXY_DEBUG GITHUB_OIDC_TOKEN_HEADER_NAME ENVOY_PROXY_IMAGE GITHUB_OIDC_HOSTNAME K8S_API_ENDPOINT_PORT MAIN_DNS_ZONE PROXY_LOG_LEVEL PROXY_PORT GITHUB_REPOSITORY WEBSOCKETS_PROXY_PORT AUTH_SERVICE_PORT"
299313
300314
# Loop through each variable and check if it's empty
301315
for var in $required_env_vars; do

0 commit comments

Comments
 (0)