Skip to content

Commit 253046b

Browse files
committed
feat: setup-gap-authz
1 parent c741150 commit 253046b

9 files changed

Lines changed: 469 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: 271 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,271 @@
1+
package main
2+
3+
import (
4+
"encoding/base64"
5+
"encoding/json"
6+
"fmt"
7+
"io"
8+
"log"
9+
"net/http"
10+
"os"
11+
"regexp"
12+
"strings"
13+
"sync"
14+
"time"
15+
)
16+
17+
// JWT token management
18+
var (
19+
jwtToken string
20+
jwtExpiration int64
21+
tokenRefreshMutex sync.Mutex
22+
)
23+
24+
// JWT payload structure
25+
type JWTPayload struct {
26+
Exp int64 `json:"exp"`
27+
}
28+
29+
// OIDCResponse structure
30+
type OIDCResponse struct {
31+
Value string `json:"value"`
32+
}
33+
34+
// Auth Response to Envoy
35+
type AuthResponse struct {
36+
Status struct {
37+
Code int `json:"code"`
38+
} `json:"status"`
39+
HttpResponse struct {
40+
Headers map[string]string `json:"headers"`
41+
} `json:"httpResponse"`
42+
}
43+
44+
// Decode the JWT payload
45+
func decodeJWTPayload(jwt string) (*JWTPayload, error) {
46+
parts := strings.Split(jwt, ".")
47+
if len(parts) != 3 {
48+
return nil, fmt.Errorf("invalid JWT format")
49+
}
50+
51+
// Add padding if needed
52+
payload := parts[1]
53+
if l := len(payload) % 4; l > 0 {
54+
payload += strings.Repeat("=", 4-l)
55+
}
56+
57+
// Decode the base64 encoded payload
58+
decoded, err := base64.URLEncoding.DecodeString(payload)
59+
if err != nil {
60+
return nil, fmt.Errorf("failed to decode payload: %v", err)
61+
}
62+
63+
// Parse the JSON payload
64+
var payloadObj JWTPayload
65+
if err := json.Unmarshal(decoded, &payloadObj); err != nil {
66+
return nil, fmt.Errorf("failed to parse payload: %v", err)
67+
}
68+
69+
return &payloadObj, nil
70+
}
71+
72+
// Fetch a new JWT token from GitHub
73+
func fetchGitHubOIDCToken() (string, int64, error) {
74+
tokenRefreshMutex.Lock()
75+
defer tokenRefreshMutex.Unlock()
76+
77+
// Check if we already have a valid token
78+
now := time.Now().Unix()
79+
if jwtToken != "" && jwtExpiration > now+60 {
80+
log.Printf("Using existing token, expires in %d seconds", jwtExpiration-now)
81+
return jwtToken, jwtExpiration, nil
82+
}
83+
84+
log.Println("Fetching new GitHub OIDC token")
85+
86+
// Prepare the URL and headers
87+
audience := "gap"
88+
requestURL := os.Getenv("ACTIONS_ID_TOKEN_REQUEST_URL") + "&audience=" + audience
89+
hostname := os.Getenv("GITHUB_OIDC_HOSTNAME")
90+
authToken := os.Getenv("ACTIONS_ID_TOKEN_REQUEST_TOKEN")
91+
92+
// Create a new HTTP request
93+
req, err := http.NewRequest("GET", requestURL, nil)
94+
if err != nil {
95+
return "", 0, fmt.Errorf("failed to create request: %v", err)
96+
}
97+
98+
// Set headers
99+
req.Host = hostname
100+
req.Header.Set("Authorization", "Bearer "+authToken)
101+
req.Header.Set("Accept", "application/json")
102+
103+
// Send the request
104+
client := &http.Client{Timeout: 5 * time.Second}
105+
resp, err := client.Do(req)
106+
if err != nil {
107+
return "", 0, fmt.Errorf("request failed: %v", err)
108+
}
109+
defer resp.Body.Close()
110+
111+
// Check the response status
112+
if resp.StatusCode != http.StatusOK {
113+
return "", 0, fmt.Errorf("request failed with status: %d", resp.StatusCode)
114+
}
115+
116+
// Read the response body
117+
body, err := io.ReadAll(resp.Body)
118+
if err != nil {
119+
return "", 0, fmt.Errorf("failed to read response: %v", err)
120+
}
121+
122+
// Parse the JSON response
123+
var oidcResp OIDCResponse
124+
if err := json.Unmarshal(body, &oidcResp); err != nil {
125+
return "", 0, fmt.Errorf("failed to parse response: %v", err)
126+
}
127+
128+
// Extract the token
129+
token := oidcResp.Value
130+
if token == "" {
131+
return "", 0, fmt.Errorf("empty token received")
132+
}
133+
134+
// Parse the JWT to get expiration
135+
payload, err := decodeJWTPayload(token)
136+
if err != nil {
137+
log.Printf("Failed to decode JWT payload: %v", err)
138+
// If we can't parse expiration, use a conservative default (5 minutes)
139+
jwtToken = token
140+
jwtExpiration = now + 300 // 5 minute default
141+
log.Printf("Could not parse token expiration, using 5 min default. Expires at: %s",
142+
time.Unix(jwtExpiration, 0).Format(time.RFC3339))
143+
return jwtToken, jwtExpiration, nil
144+
}
145+
146+
// Store the token and expiration in global variables
147+
jwtToken = token
148+
jwtExpiration = payload.Exp
149+
150+
log.Printf("Token fetched successfully, expires at %s", time.Unix(jwtExpiration, 0).Format(time.RFC3339))
151+
return jwtToken, jwtExpiration, nil
152+
}
153+
154+
// ensurePort443 modifies the authority header to use port 443
155+
func ensurePort443(authority string) string {
156+
mainDNSZone := os.Getenv("MAIN_DNS_ZONE")
157+
if mainDNSZone == "" {
158+
log.Println("MAIN_DNS_ZONE environment variable not set")
159+
return authority
160+
}
161+
162+
// Escape special characters in the DNS zone for regex
163+
escapedDNSZone := regexp.QuoteMeta(mainDNSZone)
164+
165+
// Check if the authority matches the pattern and has a port
166+
re := regexp.MustCompile(escapedDNSZone + ":\\d+$")
167+
if re.MatchString(authority) {
168+
// Replace the port with 443
169+
newAuthority := regexp.MustCompile(":\\d+$").ReplaceAllString(authority, ":443")
170+
log.Printf("Updated authority header from %s to %s", authority, newAuthority)
171+
return newAuthority
172+
}
173+
174+
return authority
175+
}
176+
177+
func addHeader(w http.ResponseWriter, headerName, headerValue string) {
178+
log.Printf("Adding header: %s", headerName)
179+
w.Header().Set(headerName, headerValue)
180+
}
181+
182+
// handleCheck processes auth requests from Envoy
183+
func handleCheck(w http.ResponseWriter, r *http.Request) {
184+
log.Printf("Check: %s %s %s", r.Method, r.URL.Path, r.UserAgent())
185+
186+
// Get the authority header from the request headers
187+
// For HTTP-based ext_authz, Envoy forwards the original headers
188+
authority := r.Header.Get(":authority")
189+
if authority == "" {
190+
// Try the Host header as fallback
191+
authority = r.Host
192+
log.Printf("No :authority header found, using Host header: %s", authority)
193+
}
194+
log.Printf("Received Authority header: %s", authority)
195+
196+
// Fetch or refresh the token
197+
token, _, err := fetchGitHubOIDCToken()
198+
if err != nil {
199+
log.Printf("Error fetching token: %v", err)
200+
http.Error(w, "Failed to fetch token", http.StatusInternalServerError)
201+
return
202+
}
203+
204+
// Prepare the response
205+
authResp := AuthResponse{}
206+
authResp.Status.Code = 200
207+
authResp.HttpResponse.Headers = make(map[string]string)
208+
209+
// Add the JWT token header
210+
headerName := os.Getenv("GITHUB_OIDC_TOKEN_HEADER_NAME")
211+
addHeader(w, headerName, "Bearer "+token)
212+
213+
githubRepository := os.Getenv("GITHUB_REPOSITORY")
214+
addHeader(w, "x-repository", githubRepository)
215+
216+
// Check and modify the authority header if needed
217+
if authority != "" {
218+
modifiedAuthority := ensurePort443(authority)
219+
addHeader(w, ":authority", modifiedAuthority)
220+
}
221+
222+
// Send the response
223+
w.Header().Set("Content-Type", "application/json")
224+
json.NewEncoder(w).Encode(authResp)
225+
}
226+
227+
// handleHealthz is a simple health check endpoint
228+
func handleHealthz(w http.ResponseWriter, r *http.Request) {
229+
log.Printf("Health check request: %s", r.URL.Path)
230+
fmt.Fprint(w, "OK")
231+
}
232+
233+
// handleNotFound handles all other paths, logs the request path, and returns a 404
234+
func handleNotFound(w http.ResponseWriter, r *http.Request) {
235+
log.Printf("Not found request: %s %s", r.Method, r.URL.Path)
236+
http.Error(w, "Not Found", http.StatusNotFound)
237+
}
238+
239+
func main() {
240+
port := os.Getenv("AUTH_SERVICE_PORT")
241+
if port == "" {
242+
log.Fatal("AUTH_SERVICE_PORT environment variable is required.")
243+
os.Exit(1)
244+
}
245+
246+
// Initialize token
247+
_, _, err := fetchGitHubOIDCToken()
248+
if err != nil {
249+
log.Printf("Initial token fetch failed: %v", err)
250+
// Continue anyway, we'll retry on first request
251+
}
252+
253+
// Set up HTTP server with custom mux
254+
mux := http.NewServeMux()
255+
256+
// Register /check and /check/* endpoints
257+
mux.HandleFunc("/check/", handleCheck)
258+
mux.HandleFunc("/check", handleCheck)
259+
260+
mux.HandleFunc("/healthz", handleHealthz)
261+
262+
// Set up a catch-all handler for any other paths
263+
mux.HandleFunc("/", handleNotFound)
264+
265+
// Start the server
266+
address := fmt.Sprintf("0.0.0.0:%s", port)
267+
log.Printf("Starting auth service on %s", address)
268+
if err := http.ListenAndServe(address, mux); err != nil {
269+
log.Fatalf("Failed to start server: %v", err)
270+
}
271+
}
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)