fix: invoke auth via Fission router JWT login
This commit is contained in:
@@ -46,7 +46,7 @@ spec:
|
||||
serviceAccountName: fission-console
|
||||
containers:
|
||||
- name: console
|
||||
image: naeel/fission-console:v0.1.5
|
||||
image: naeel/fission-console:v0.2.2
|
||||
ports:
|
||||
- containerPort: 8090
|
||||
env:
|
||||
@@ -56,6 +56,16 @@ spec:
|
||||
value: "http://router.fission.svc.cluster.local"
|
||||
- name: PORT
|
||||
value: "8090"
|
||||
- name: FISSION_AUTH_USERNAME
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: router
|
||||
key: username
|
||||
- name: FISSION_AUTH_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: router
|
||||
key: password
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"fission-console/ui"
|
||||
@@ -32,11 +33,21 @@ var (
|
||||
timeTrigGVR = schema.GroupVersionResource{Group: "fission.io", Version: "v1", Resource: "timetriggers"}
|
||||
)
|
||||
|
||||
const defaultSATokenPath = "/var/run/secrets/kubernetes.io/serviceaccount/token"
|
||||
|
||||
type server struct {
|
||||
dyn dynamic.Interface
|
||||
ns string
|
||||
routerURL string
|
||||
http *http.Client
|
||||
saTokenPath string
|
||||
|
||||
authUser string
|
||||
authPass string
|
||||
|
||||
tokenMu sync.Mutex
|
||||
cachedJWT string
|
||||
tokenExpAt time.Time
|
||||
}
|
||||
|
||||
type createFunctionRequest struct {
|
||||
@@ -68,11 +79,18 @@ func main() {
|
||||
log.Fatalf("create dynamic client: %v", err)
|
||||
}
|
||||
|
||||
authUser := envDefault("FISSION_AUTH_USERNAME", "")
|
||||
authPass := envDefault("FISSION_AUTH_PASSWORD", "")
|
||||
saTokenPath := envDefault("SA_TOKEN_PATH", defaultSATokenPath)
|
||||
|
||||
s := &server{
|
||||
dyn: dyn,
|
||||
ns: namespace,
|
||||
routerURL: routerURL,
|
||||
http: &http.Client{Timeout: 30 * time.Second},
|
||||
saTokenPath: saTokenPath,
|
||||
authUser: authUser,
|
||||
authPass: authPass,
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
@@ -474,6 +492,9 @@ func (s *server) handleInvokeFunction(w http.ResponseWriter, r *http.Request, na
|
||||
if invokeMethod == http.MethodPost {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
if token := s.getRouterToken(); token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
|
||||
resp, err := s.http.Do(req)
|
||||
if err != nil {
|
||||
@@ -491,6 +512,58 @@ func (s *server) handleInvokeFunction(w http.ResponseWriter, r *http.Request, na
|
||||
})
|
||||
}
|
||||
|
||||
func (s *server) readSAToken() string {
|
||||
if s.saTokenPath == "" {
|
||||
return ""
|
||||
}
|
||||
data, err := os.ReadFile(s.saTokenPath)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(string(data))
|
||||
}
|
||||
|
||||
func (s *server) getRouterToken() string {
|
||||
if s.authUser == "" || s.authPass == "" {
|
||||
return s.readSAToken()
|
||||
}
|
||||
|
||||
s.tokenMu.Lock()
|
||||
defer s.tokenMu.Unlock()
|
||||
|
||||
if s.cachedJWT != "" && time.Now().Before(s.tokenExpAt) {
|
||||
return s.cachedJWT
|
||||
}
|
||||
|
||||
loginURL := s.routerURL + "/auth/login"
|
||||
body, _ := json.Marshal(map[string]string{"username": s.authUser, "password": s.authPass})
|
||||
resp, err := s.http.Post(loginURL, "application/json", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
log.Printf("router login failed: %v", err)
|
||||
return s.readSAToken()
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
log.Printf("router login %d: %s", resp.StatusCode, string(respBody))
|
||||
return s.readSAToken()
|
||||
}
|
||||
|
||||
var result struct {
|
||||
AccessToken string `json:"accesstoken"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil || result.AccessToken == "" {
|
||||
log.Printf("router login decode error: %v", err)
|
||||
return s.readSAToken()
|
||||
}
|
||||
|
||||
s.cachedJWT = result.AccessToken
|
||||
s.tokenExpAt = time.Now().Add(100 * time.Second)
|
||||
log.Printf("router JWT obtained, expires in 100s")
|
||||
return s.cachedJWT
|
||||
}
|
||||
|
||||
func (s *server) handleDeleteFunction(w http.ResponseWriter, r *http.Request, name string) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
|
||||
defer cancel()
|
||||
|
||||
+71
-1
@@ -26,7 +26,7 @@ func newTestServer(objs ...runtime.Object) *server {
|
||||
timeTrigGVR: "TimeTriggerList",
|
||||
}
|
||||
dyn := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(runtime.NewScheme(), listKinds, objs...)
|
||||
return &server{dyn: dyn, ns: "default", routerURL: "http://example.invalid", http: &http.Client{}}
|
||||
return &server{dyn: dyn, ns: "default", routerURL: "http://example.invalid", http: &http.Client{}, saTokenPath: ""}
|
||||
}
|
||||
|
||||
func TestNormalizeMethods(t *testing.T) {
|
||||
@@ -141,3 +141,73 @@ func TestUpdateFunctionCode(t *testing.T) {
|
||||
t.Fatalf("expected new-code, got %q", string(decoded))
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvokeFunctionWithJWTAuth(t *testing.T) {
|
||||
// Mock router: /auth/login returns JWT, /inv-fn returns hello
|
||||
var gotAuth string
|
||||
mockRouter := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/auth/login" && r.Method == http.MethodPost {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"accesstoken":"fake-jwt-token-xyz","tokentype":"Bearer"}`))
|
||||
return
|
||||
}
|
||||
gotAuth = r.Header.Get("Authorization")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`{"hello":"world"}`))
|
||||
}))
|
||||
defer mockRouter.Close()
|
||||
|
||||
env := &unstructured.Unstructured{Object: map[string]any{
|
||||
"apiVersion": "fission.io/v1",
|
||||
"kind": "Environment",
|
||||
"metadata": map[string]any{"name": "python", "namespace": "default"},
|
||||
}}
|
||||
|
||||
listKinds := map[schema.GroupVersionResource]string{
|
||||
environmentGVR: "EnvironmentList",
|
||||
packageGVR: "PackageList",
|
||||
functionGVR: "FunctionList",
|
||||
httpTrigGVR: "HTTPTriggerList",
|
||||
timeTrigGVR: "TimeTriggerList",
|
||||
}
|
||||
dyn := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(runtime.NewScheme(), listKinds, env)
|
||||
s := &server{
|
||||
dyn: dyn,
|
||||
ns: "default",
|
||||
routerURL: mockRouter.URL,
|
||||
http: mockRouter.Client(),
|
||||
authUser: "admin",
|
||||
authPass: "pass",
|
||||
}
|
||||
|
||||
// Create function first
|
||||
createBody := `{"name":"inv-fn","environment":"python","code":"print(1)","route":"/inv-fn","methods":["GET"]}`
|
||||
createRec := httptest.NewRecorder()
|
||||
s.handleCreateFunction(createRec, httptest.NewRequest(http.MethodPost, "/api/functions", strings.NewReader(createBody)))
|
||||
if createRec.Code != http.StatusCreated {
|
||||
t.Fatalf("create: %d %s", createRec.Code, createRec.Body.String())
|
||||
}
|
||||
|
||||
// Invoke
|
||||
invokeRec := httptest.NewRecorder()
|
||||
s.handleInvokeFunction(invokeRec, httptest.NewRequest(http.MethodPost, "/api/functions/inv-fn/invoke", strings.NewReader(`{}`)), "inv-fn")
|
||||
if invokeRec.Code != http.StatusOK {
|
||||
t.Fatalf("invoke: %d %s", invokeRec.Code, invokeRec.Body.String())
|
||||
}
|
||||
|
||||
// Verify JWT was obtained via login and sent
|
||||
if gotAuth != "Bearer fake-jwt-token-xyz" {
|
||||
t.Fatalf("expected 'Bearer fake-jwt-token-xyz', got %q", gotAuth)
|
||||
}
|
||||
|
||||
var out map[string]any
|
||||
if err := json.Unmarshal(invokeRec.Body.Bytes(), &out); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if out["status"] != float64(200) {
|
||||
t.Fatalf("expected status 200, got %v", out["status"])
|
||||
}
|
||||
if !strings.Contains(out["response_raw"].(string), "hello") {
|
||||
t.Fatalf("unexpected response: %v", out["response_raw"])
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user