From 4d9ccf767357248f86d13bc9bade85bad250f407 Mon Sep 17 00:00:00 2001 From: eekdood Date: Thu, 16 Jul 2026 16:00:32 -0400 Subject: [PATCH 1/3] Support provider-agnostic cloud OIDC tokens --- cloud.go | 98 +++++++++++++++++++++++++++++++++++------ config.go | 2 + ui/src/routes/adopt.tsx | 12 +++-- web.go | 13 +++++- 4 files changed, 105 insertions(+), 20 deletions(-) diff --git a/cloud.go b/cloud.go index 19ac95575..94b1f689b 100644 --- a/cloud.go +++ b/cloud.go @@ -23,10 +23,34 @@ import ( ) type CloudRegisterRequest struct { - Token string `json:"token"` - CloudAPI string `json:"cloudApi"` - OidcGoogle string `json:"oidcGoogle"` - ClientId string `json:"clientId"` + Token string `json:"token"` + CloudAPI string `json:"cloudApi"` + OIDCToken string `json:"oidcToken"` + OIDCClientID string `json:"oidcClientId"` + OIDCIssuer string `json:"oidcIssuer"` + OidcGoogle string `json:"oidcGoogle"` + ClientId string `json:"clientId"` +} + +func (r CloudRegisterRequest) oidcToken() string { + if r.OIDCToken != "" { + return r.OIDCToken + } + return r.OidcGoogle +} + +func (r CloudRegisterRequest) oidcClientID() string { + if r.OIDCClientID != "" { + return r.OIDCClientID + } + return r.ClientId +} + +func (r CloudRegisterRequest) oidcIssuer() string { + if r.OIDCIssuer != "" { + return r.OIDCIssuer + } + return "https://accounts.google.com" } const ( @@ -257,7 +281,7 @@ func handleCloudRegister(c *gin.Context) { config.CloudToken = tokenResp.SecretToken - provider, err := oidc.NewProvider(c, "https://accounts.google.com") + provider, err := oidc.NewProvider(c, req.oidcIssuer()) if err != nil { cloudLogger.Error().Err(err).Msg("failed to initialize OIDC provider") c.JSON(500, gin.H{"error": "Failed to initialize OIDC provider"}) @@ -265,18 +289,33 @@ func handleCloudRegister(c *gin.Context) { } oidcConfig := &oidc.Config{ - ClientID: req.ClientId, + ClientID: req.oidcClientID(), } verifier := provider.Verifier(oidcConfig) - idToken, err := verifier.Verify(c, req.OidcGoogle) + idToken, err := verifier.Verify(c, req.oidcToken()) if err != nil { cloudLogger.Warn().Err(err).Msg("OIDC token verification failed") c.JSON(400, gin.H{"error": "Invalid OIDC token"}) return } - config.GoogleIdentity = idToken.Audience[0] + ":" + idToken.Subject + oidcIdentity, err := oidcIdentity(idToken) + if err != nil { + cloudLogger.Warn().Err(err).Msg("OIDC token is missing required identity claims") + c.JSON(400, gin.H{"error": "Invalid OIDC token"}) + return + } + legacyIdentity, err := legacyOIDCIdentity(idToken) + if err != nil { + cloudLogger.Warn().Err(err).Msg("OIDC token is missing required identity claims") + c.JSON(400, gin.H{"error": "Invalid OIDC token"}) + return + } + + config.OIDCIssuer = req.oidcIssuer() + config.OIDCIdentity = oidcIdentity + config.GoogleIdentity = legacyIdentity // Save the updated configuration if err := SaveConfig(); err != nil { @@ -399,7 +438,11 @@ func runWebsocketClient() error { func authenticateSession(ctx context.Context, c *websocket.Conn, req WebRTCSessionRequest) error { oidcCtx, cancelOIDC := context.WithTimeout(ctx, CloudOidcRequestTimeout) defer cancelOIDC() - provider, err := oidc.NewProvider(oidcCtx, "https://accounts.google.com") + issuer := config.OIDCIssuer + if issuer == "" { + issuer = "https://accounts.google.com" + } + provider, err := oidc.NewProvider(oidcCtx, issuer) if err != nil { _ = wsjson.Write(context.Background(), c, gin.H{ "error": fmt.Sprintf("failed to initialize OIDC provider: %v", err), @@ -413,20 +456,45 @@ func authenticateSession(ctx context.Context, c *websocket.Conn, req WebRTCSessi } verifier := provider.Verifier(oidcConfig) - idToken, err := verifier.Verify(oidcCtx, req.OidcGoogle) + idToken, err := verifier.Verify(oidcCtx, req.oidcToken()) if err != nil { return err } - googleIdentity := idToken.Audience[0] + ":" + idToken.Subject - if config.GoogleIdentity != googleIdentity { - _ = wsjson.Write(context.Background(), c, gin.H{"error": "google identity mismatch"}) - return fmt.Errorf("google identity mismatch") + expectedIdentity := config.OIDCIdentity + actualIdentity, err := oidcIdentity(idToken) + if err != nil { + return err + } + if expectedIdentity == "" { + expectedIdentity = config.GoogleIdentity + actualIdentity, err = legacyOIDCIdentity(idToken) + if err != nil { + return err + } + } + if expectedIdentity != actualIdentity { + _ = wsjson.Write(context.Background(), c, gin.H{"error": "OIDC identity mismatch"}) + return fmt.Errorf("OIDC identity mismatch") } return nil } +func oidcIdentity(idToken *oidc.IDToken) (string, error) { + if idToken.Issuer == "" || idToken.Subject == "" || len(idToken.Audience) == 0 { + return "", fmt.Errorf("OIDC token is missing issuer, subject, or audience") + } + return idToken.Issuer + ":" + idToken.Audience[0] + ":" + idToken.Subject, nil +} + +func legacyOIDCIdentity(idToken *oidc.IDToken) (string, error) { + if idToken.Subject == "" || len(idToken.Audience) == 0 { + return "", fmt.Errorf("OIDC token is missing subject or audience") + } + return idToken.Audience[0] + ":" + idToken.Subject, nil +} + func handleSessionRequest( ctx context.Context, c *websocket.Conn, @@ -565,6 +633,8 @@ func rpcDeregisterDevice() error { // (e.g., wrong cloud token, already deregistered). Regardless of the reason, we can safely remove it. if resp.StatusCode == http.StatusNotFound || (resp.StatusCode >= 200 && resp.StatusCode < 300) { config.CloudToken = "" + config.OIDCIssuer = "" + config.OIDCIdentity = "" config.GoogleIdentity = "" if err := SaveConfig(); err != nil { diff --git a/config.go b/config.go index 32b3b659b..2e4228ca0 100644 --- a/config.go +++ b/config.go @@ -93,6 +93,8 @@ type Config struct { CloudAppURL string `json:"cloud_app_url"` CloudToken string `json:"cloud_token"` TailscaleControlURL string `json:"tailscale_control_url,omitempty"` + OIDCIssuer string `json:"oidc_issuer,omitempty"` + OIDCIdentity string `json:"oidc_identity,omitempty"` GoogleIdentity string `json:"google_identity"` JigglerEnabled bool `json:"jiggler_enabled"` JigglerConfig *JigglerConfig `json:"jiggler_config"` diff --git a/ui/src/routes/adopt.tsx b/ui/src/routes/adopt.tsx index 055049b02..e24b7da26 100644 --- a/ui/src/routes/adopt.tsx +++ b/ui/src/routes/adopt.tsx @@ -16,15 +16,19 @@ const loader: LoaderFunction = async ({ request }: LoaderFunctionArgs) => { const tempToken = searchParams.get("tempToken"); const deviceId = searchParams.get("deviceId"); - const oidcGoogle = searchParams.get("oidcGoogle"); - const clientId = searchParams.get("clientId"); + const oidcToken = searchParams.get("oidcToken") ?? searchParams.get("oidcGoogle"); + const oidcClientId = searchParams.get("oidcClientId") ?? searchParams.get("clientId"); + const oidcIssuer = searchParams.get("oidcIssuer"); const [cloudStateResponse, registerResponse] = await Promise.all([ api.GET(`${DEVICE_API}/cloud/state`), api.POST(`${DEVICE_API}/cloud/register`, { token: tempToken, - oidcGoogle, - clientId, + oidcToken, + oidcClientId, + oidcIssuer, + oidcGoogle: oidcToken, + clientId: oidcClientId, }), ]); diff --git a/web.go b/web.go index baa94ff48..2074e77c6 100644 --- a/web.go +++ b/web.go @@ -41,11 +41,19 @@ var staticFiles embed.FS type WebRTCSessionRequest struct { Sd string `json:"sd"` + OIDCToken string `json:"OidcToken,omitempty"` OidcGoogle string `json:"OidcGoogle,omitempty"` IP string `json:"ip,omitempty"` ICEServers []string `json:"iceServers,omitempty"` } +func (r WebRTCSessionRequest) oidcToken() string { + if r.OIDCToken != "" { + return r.OIDCToken + } + return r.OidcGoogle +} + type SetPasswordRequest struct { Password string `json:"password"` } @@ -467,8 +475,8 @@ func handleWebRTCSignalWsMessages( continue } - if req.OidcGoogle != "" { - l.Info().Str("oidcGoogle", req.OidcGoogle).Msg("new session request with OIDC Google") + if req.oidcToken() != "" { + l.Info().Msg("new session request with OIDC token") } metricConnectionSessionRequestCount.WithLabelValues(sourceType, source).Inc() @@ -996,6 +1004,7 @@ func handleDiagnosticsDownload(c *gin.Context) { redactedConfig.CloudToken = "" redactedConfig.LocalAuthToken = "" redactedConfig.HashedPassword = "" + redactedConfig.OIDCIdentity = "" redactedConfig.GoogleIdentity = "" if configData, err := json.MarshalIndent(redactedConfig, "", " "); err == nil { if err := addBytesToZip(zw, "config.json", configData); err != nil { From c458bb2eb4321152635c4a09ac13bf271d5bf63e Mon Sep 17 00:00:00 2001 From: eekdood Date: Fri, 17 Jul 2026 22:03:19 -0400 Subject: [PATCH 2/3] Cover KVM OIDC compatibility helpers --- cloud_test.go | 128 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 128 insertions(+) create mode 100644 cloud_test.go diff --git a/cloud_test.go b/cloud_test.go new file mode 100644 index 000000000..1c3d8735a --- /dev/null +++ b/cloud_test.go @@ -0,0 +1,128 @@ +//go:build linux && arm + +package kvm + +import ( + "testing" + + "github.com/coreos/go-oidc/v3/oidc" +) + +func TestCloudRegisterRequestOIDCFieldsPreferProviderAgnosticValues(t *testing.T) { + req := CloudRegisterRequest{ + OIDCToken: "generic-token", + OIDCClientID: "generic-client", + OIDCIssuer: "https://auth.example.com/application/o/jetkvm/", + OidcGoogle: "legacy-token", + ClientId: "legacy-client", + } + + if got := req.oidcToken(); got != "generic-token" { + t.Fatalf("oidcToken() = %q, want generic token", got) + } + if got := req.oidcClientID(); got != "generic-client" { + t.Fatalf("oidcClientID() = %q, want generic client", got) + } + if got := req.oidcIssuer(); got != "https://auth.example.com/application/o/jetkvm/" { + t.Fatalf("oidcIssuer() = %q, want configured issuer", got) + } +} + +func TestCloudRegisterRequestOIDCFieldsFallbackToLegacyGoogleValues(t *testing.T) { + req := CloudRegisterRequest{ + OidcGoogle: "legacy-token", + ClientId: "legacy-client", + } + + if got := req.oidcToken(); got != "legacy-token" { + t.Fatalf("oidcToken() = %q, want legacy token", got) + } + if got := req.oidcClientID(); got != "legacy-client" { + t.Fatalf("oidcClientID() = %q, want legacy client", got) + } + if got := req.oidcIssuer(); got != "https://accounts.google.com" { + t.Fatalf("oidcIssuer() = %q, want legacy Google issuer", got) + } +} + +func TestWebRTCSessionRequestOIDCTokenPrefersProviderAgnosticValue(t *testing.T) { + req := WebRTCSessionRequest{ + OIDCToken: "generic-token", + OidcGoogle: "legacy-token", + } + + if got := req.oidcToken(); got != "generic-token" { + t.Fatalf("oidcToken() = %q, want generic token", got) + } +} + +func TestOIDCIdentityIncludesIssuerAudienceAndSubject(t *testing.T) { + idToken := &oidc.IDToken{ + Issuer: "https://auth.example.com/application/o/jetkvm/", + Audience: []string{"jetkvm-cloud-api"}, + Subject: "user-123", + } + + got, err := oidcIdentity(idToken) + if err != nil { + t.Fatalf("oidcIdentity() returned error: %v", err) + } + want := "https://auth.example.com/application/o/jetkvm/:jetkvm-cloud-api:user-123" + if got != want { + t.Fatalf("oidcIdentity() = %q, want %q", got, want) + } +} + +func TestOIDCIdentityRejectsMissingClaims(t *testing.T) { + tests := []struct { + name string + idToken *oidc.IDToken + }{ + { + name: "missing issuer", + idToken: &oidc.IDToken{ + Audience: []string{"jetkvm-cloud-api"}, + Subject: "user-123", + }, + }, + { + name: "missing audience", + idToken: &oidc.IDToken{ + Issuer: "https://auth.example.com/application/o/jetkvm/", + Subject: "user-123", + }, + }, + { + name: "missing subject", + idToken: &oidc.IDToken{ + Issuer: "https://auth.example.com/application/o/jetkvm/", + Audience: []string{"jetkvm-cloud-api"}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if _, err := oidcIdentity(tt.idToken); err == nil { + t.Fatal("oidcIdentity() returned nil error, want missing claim error") + } + }) + } +} + +func TestLegacyOIDCIdentityKeepsGoogleCompatibleFormat(t *testing.T) { + idToken := &oidc.IDToken{ + Issuer: "https://accounts.google.com", + Audience: []string{"legacy-client"}, + Subject: "google-user-123", + } + + got, err := legacyOIDCIdentity(idToken) + if err != nil { + t.Fatalf("legacyOIDCIdentity() returned error: %v", err) + } + want := "legacy-client:google-user-123" + if got != want { + t.Fatalf("legacyOIDCIdentity() = %q, want %q", got, want) + } +} From 1b4ea548f56ab5715937c7b57d2c4e77f909bc35 Mon Sep 17 00:00:00 2001 From: eekdood <23711964+eekdood@users.noreply.github.com> Date: Fri, 17 Jul 2026 22:14:34 -0400 Subject: [PATCH 3/3] Test fake OIDC cloud adoption --- cloud_test.go | 173 ++++++++++++++++++++++++++++++++++++++++++++++++++ config.go | 2 +- 2 files changed, 174 insertions(+), 1 deletion(-) diff --git a/cloud_test.go b/cloud_test.go index 1c3d8735a..4046df226 100644 --- a/cloud_test.go +++ b/cloud_test.go @@ -3,9 +3,20 @@ package kvm import ( + "bytes" + "crypto/rand" + "crypto/rsa" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" "testing" + "time" "github.com/coreos/go-oidc/v3/oidc" + "github.com/gin-gonic/gin" + "github.com/go-jose/go-jose/v4" ) func TestCloudRegisterRequestOIDCFieldsPreferProviderAgnosticValues(t *testing.T) { @@ -126,3 +137,165 @@ func TestLegacyOIDCIdentityKeepsGoogleCompatibleFormat(t *testing.T) { t.Fatalf("legacyOIDCIdentity() = %q, want %q", got, want) } } + +func TestHandleCloudRegisterCompletesProviderAgnosticFakeAdoption(t *testing.T) { + gin.SetMode(gin.TestMode) + + originalConfig := config + originalConfigPath := configPath + t.Cleanup(func() { + config = originalConfig + configPath = originalConfigPath + }) + + oidcIssuer, oidcToken := newTestOIDCIssuer(t, "jetkvm-cloud-api", "user-123") + cloudAPI := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || r.URL.Path != "/devices/token" { + t.Fatalf("unexpected cloud API request: %s %s", r.Method, r.URL.Path) + } + + var req struct { + TempToken string `json:"tempToken"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Fatalf("failed to decode cloud API token request: %v", err) + } + if req.TempToken != "temp-token" { + t.Fatalf("tempToken = %q, want temp-token", req.TempToken) + } + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"secretToken":"permanent-cloud-token"}`)) + })) + t.Cleanup(cloudAPI.Close) + + defaultConfig := getDefaultConfig() + config = &defaultConfig + config.CloudURL = cloudAPI.URL + configPath = filepath.Join(t.TempDir(), "kvm_config.json") + + body, err := json.Marshal(map[string]string{ + "token": "temp-token", + "oidcToken": oidcToken, + "oidcClientId": "jetkvm-cloud-api", + "oidcIssuer": oidcIssuer, + }) + if err != nil { + t.Fatalf("failed to marshal register request: %v", err) + } + req := httptest.NewRequest(http.MethodPost, "/cloud/register", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = req + + handleCloudRegister(c) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", w.Code, w.Body.String()) + } + if config.CloudToken != "permanent-cloud-token" { + t.Fatalf("CloudToken = %q, want permanent-cloud-token", config.CloudToken) + } + if config.OIDCIssuer != oidcIssuer { + t.Fatalf("OIDCIssuer = %q, want %q", config.OIDCIssuer, oidcIssuer) + } + wantOIDCIdentity := oidcIssuer + ":jetkvm-cloud-api:user-123" + if config.OIDCIdentity != wantOIDCIdentity { + t.Fatalf("OIDCIdentity = %q, want %q", config.OIDCIdentity, wantOIDCIdentity) + } + if config.GoogleIdentity != "jetkvm-cloud-api:user-123" { + t.Fatalf("GoogleIdentity = %q, want legacy-compatible identity", config.GoogleIdentity) + } + + var saved Config + rawSavedConfig, err := os.ReadFile(configPath) + if err != nil { + t.Fatalf("failed to read saved config: %v", err) + } + if err := json.Unmarshal(rawSavedConfig, &saved); err != nil { + t.Fatalf("failed to decode saved config: %v", err) + } + if saved.OIDCIssuer != oidcIssuer || saved.OIDCIdentity != wantOIDCIdentity { + t.Fatalf("saved OIDC config = issuer %q identity %q", saved.OIDCIssuer, saved.OIDCIdentity) + } +} + +func newTestOIDCIssuer(t *testing.T, clientID string, subject string) (string, string) { + t.Helper() + + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("failed to generate test RSA key: %v", err) + } + + var issuerURL string + mux := http.NewServeMux() + mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "issuer": issuerURL, + "authorization_endpoint": issuerURL + "/authorize", + "token_endpoint": issuerURL + "/token", + "jwks_uri": issuerURL + "/jwks", + "id_token_signing_alg_values_supported": []string{"RS256"}, + }) + }) + mux.HandleFunc("/jwks", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + key := jose.JSONWebKey{ + Key: &privateKey.PublicKey, + KeyID: "test-key", + Use: "sig", + Algorithm: string(jose.RS256), + } + _ = json.NewEncoder(w).Encode(map[string]any{"keys": []jose.JSONWebKey{key}}) + }) + + server := httptest.NewServer(mux) + t.Cleanup(server.Close) + issuerURL = server.URL + + return issuerURL, signTestIDToken(t, privateKey, issuerURL, clientID, subject) +} + +func signTestIDToken( + t *testing.T, + privateKey *rsa.PrivateKey, + issuer string, + clientID string, + subject string, +) string { + t.Helper() + + options := (&jose.SignerOptions{}).WithType("JWT").WithHeader("kid", "test-key") + signer, err := jose.NewSigner(jose.SigningKey{ + Algorithm: jose.RS256, + Key: privateKey, + }, options) + if err != nil { + t.Fatalf("failed to create JWT signer: %v", err) + } + + now := time.Now() + payload, err := json.Marshal(map[string]any{ + "iss": issuer, + "sub": subject, + "aud": clientID, + "iat": now.Unix(), + "exp": now.Add(time.Hour).Unix(), + }) + if err != nil { + t.Fatalf("failed to marshal ID token claims: %v", err) + } + + signed, err := signer.Sign(payload) + if err != nil { + t.Fatalf("failed to sign ID token: %v", err) + } + token, err := signed.CompactSerialize() + if err != nil { + t.Fatalf("failed to serialize ID token: %v", err) + } + return token +} diff --git a/config.go b/config.go index 2e4228ca0..4fe2f874c 100644 --- a/config.go +++ b/config.go @@ -156,7 +156,7 @@ func (c *Config) SetDisplayRotation(rotation string) error { return nil } -const configPath = "/userdata/kvm_config.json" +var configPath = "/userdata/kvm_config.json" // it's a temporary solution to avoid sharing the same pointer // we should migrate to a proper config solution in the future