Skip to content

Commit 9bbf049

Browse files
committed
feat: setup-gap-authz
1 parent c741150 commit 9bbf049

9 files changed

Lines changed: 438 additions & 16 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: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
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+
outputs:
19+
container-ip:
20+
description: "The IP address of the auth service container."
21+
value: ${{ steps.run-auth-service.outputs.container-ip }}
22+
23+
runs:
24+
using: "composite"
25+
steps:
26+
- name: Build and run auth service
27+
id: run-auth-service
28+
shell: bash
29+
env:
30+
AUTH_SERVICE_PORT: ${{ inputs.port }}
31+
GITHUB_OIDC_TOKEN_HEADER_NAME:
32+
${{ inputs.github-oidc-token-header-name }}
33+
GAP_NAME: ${{ inputs.gap-name }}
34+
run: |
35+
# Get the Github OIDC hostname
36+
export GITHUB_OIDC_HOSTNAME=$(echo "${ACTIONS_ID_TOKEN_REQUEST_URL}" | awk -F[/:] '{print $4}')
37+
38+
IMAGE_NAME="gap-auth-service-${GAP_NAME}"
39+
CONTAINER_NAME="gap-auth-service-${GAP_NAME}-$(uuidgen | cut -d'-' -f1)"
40+
41+
echo "Building auth service container..."
42+
# Pass the port at build time
43+
docker build -t "${IMAGE_NAME}" \
44+
--build-arg AUTH_SERVICE_PORT="${AUTH_SERVICE_PORT}" \
45+
"${GITHUB_ACTION_PATH}"
46+
47+
echo "Starting auth service on port ${AUTH_SERVICE_PORT}..."
48+
docker run -d \
49+
--name "${CONTAINER_NAME}" \
50+
-p "${AUTH_SERVICE_PORT}:${AUTH_SERVICE_PORT}" \
51+
-e AUTH_SERVICE_PORT="${AUTH_SERVICE_PORT}" \
52+
-e GITHUB_OIDC_TOKEN_HEADER_NAME="${GITHUB_OIDC_TOKEN_HEADER_NAME}" \
53+
-e GITHUB_OIDC_HOSTNAME="${GITHUB_OIDC_HOSTNAME}" \
54+
-e ACTIONS_ID_TOKEN_REQUEST_URL="${ACTIONS_ID_TOKEN_REQUEST_URL}" \
55+
-e ACTIONS_ID_TOKEN_REQUEST_TOKEN="${ACTIONS_ID_TOKEN_REQUEST_TOKEN}" \
56+
-e GITHUB_REPOSITORY="${GITHUB_REPOSITORY}" \
57+
"${IMAGE_NAME}"
58+
59+
CONTAINER_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "${CONTAINER_NAME}")
60+
echo "container-ip=${CONTAINER_IP}" | tee -a $GITHUB_OUTPUT
61+
62+
- name: Wait for auth service to be ready
63+
shell: bash
64+
env:
65+
AUTH_SERVICE_PORT: ${{ inputs.port }}
66+
run: |
67+
echo "Waiting for auth service to be ready..."
68+
echo "Sending requests to http://localhost:${AUTH_SERVICE_PORT}/healthz"
69+
for i in {1..25}; do
70+
if curl -s http://localhost:${AUTH_SERVICE_PORT}/healthz | grep -q "OK"; then
71+
echo "Auth service is ready."
72+
break
73+
fi
74+
75+
if [ $i -eq 25 ]; then
76+
echo "::error::Auth service failed to start."
77+
exit 1
78+
fi
79+
80+
echo "Waiting for auth service... (attempt $i/25)"
81+
sleep 1
82+
done

actions/setup-gap-authz/main

8.43 MB
Binary file not shown.

actions/setup-gap-authz/main.go

Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,243 @@
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", err)
152+
// If we can't parse expiration, use a conservative default (5 minutes)
153+
jwtToken = token
154+
jwtExpiration = now + 300 // 5 minute default
155+
log.Printf("Could not parse token expiration, using 5 min default. Expires at: %s",
156+
time.Unix(jwtExpiration, 0).Format(time.RFC3339))
157+
return jwtToken, jwtExpiration, nil
158+
}
159+
160+
// Store the token and expiration in global variables
161+
jwtToken = token
162+
jwtExpiration = payload.Exp
163+
164+
log.Printf("Token fetched successfully, expires at %s", time.Unix(jwtExpiration, 0).Format(time.RFC3339))
165+
return jwtToken, jwtExpiration, nil
166+
}
167+
168+
// handleCheck processes auth requests from Envoy
169+
func handleCheck(w http.ResponseWriter, r *http.Request) {
170+
log.Printf("Handle Check: %s %s %s", r.Method, r.URL.Path, r.UserAgent())
171+
172+
// Fetch or refresh the token
173+
token, _, err := fetchGitHubOIDCToken()
174+
if err != nil {
175+
log.Printf("Error fetching token: %v", err)
176+
http.Error(w, "Failed to fetch token", http.StatusInternalServerError)
177+
return
178+
}
179+
180+
// Prepare the response
181+
authResp := AuthResponse{}
182+
authResp.Status.Code = 200
183+
authResp.HttpResponse.Headers = make(map[string]string)
184+
185+
headerName := os.Getenv("GITHUB_OIDC_TOKEN_HEADER_NAME")
186+
// todo: do not log the token
187+
log.Printf("Adding %s (%s)", headerName, token)
188+
authResp.HttpResponse.Headers[headerName] = "Bearer " + token
189+
190+
githubRepository := os.Getenv("GITHUB_REPOSITORY")
191+
log.Printf("Adding x-repository (%s)", githubRepository)
192+
authResp.HttpResponse.Headers["x-repository"] = githubRepository
193+
194+
// Send the response
195+
w.Header().Set("Content-Type", "application/json")
196+
json.NewEncoder(w).Encode(authResp)
197+
}
198+
199+
// handleHealthz is a simple health check endpoint
200+
func handleHealthz(w http.ResponseWriter, r *http.Request) {
201+
log.Printf("Health check request: %s", r.URL.Path)
202+
fmt.Fprint(w, "OK")
203+
}
204+
205+
// handleNotFound handles all other paths, logs the request path, and returns a 404
206+
func handleNotFound(w http.ResponseWriter, r *http.Request) {
207+
log.Printf("Not found request: %s %s", r.Method, r.URL.Path)
208+
http.Error(w, "Not Found", http.StatusNotFound)
209+
}
210+
211+
func main() {
212+
port := os.Getenv("AUTH_SERVICE_PORT")
213+
if port == "" {
214+
log.Fatal("AUTH_SERVICE_PORT environment variable is required.")
215+
os.Exit(1)
216+
}
217+
218+
// Initialize token
219+
_, _, err := fetchGitHubOIDCToken()
220+
if err != nil {
221+
log.Printf("Initial token fetch failed: %v", err)
222+
// Continue anyway, we'll retry on first request
223+
}
224+
225+
// Set up HTTP server with custom mux
226+
mux := http.NewServeMux()
227+
228+
// Register /check and /check/* endpoints
229+
mux.HandleFunc("/check/", handleCheck)
230+
mux.HandleFunc("/check", handleCheck)
231+
232+
mux.HandleFunc("/healthz", handleHealthz)
233+
234+
// Set up a catch-all handler for any other paths
235+
mux.HandleFunc("/", handleNotFound)
236+
237+
// Start the server
238+
address := fmt.Sprintf("0.0.0.0:%s", port)
239+
log.Printf("Starting auth service on %s", address)
240+
if err := http.ListenAndServe(address, mux); err != nil {
241+
log.Fatalf("Failed to start server: %v", err)
242+
}
243+
}
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+
}

0 commit comments

Comments
 (0)