Skip to content

Commit ac908c2

Browse files
authored
Merge pull request #292 from aojea/oidc
node: fix device flow polling against dex's non-RFC 401 responses
2 parents 16362c5 + d82dee3 commit ac908c2

8 files changed

Lines changed: 234 additions & 19 deletions

File tree

cmd/sam-control-plane/main.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ var (
3636
dbDSN string
3737
dbDSNPath string
3838
oidcIssuer string
39+
oidcClientID string
3940
allowedAudiencesFlag string
4041
keyRotationInterval time.Duration
4142
keyGracePeriod time.Duration
@@ -112,6 +113,7 @@ func main() {
112113
DriverName: dbDriver,
113114
DataSourceName: dbDSN,
114115
OIDCIssuer: oidcIssuer,
116+
OIDCClientID: oidcClientID,
115117
AllowedAudiences: auds,
116118
LeaseDuration: leaseDuration,
117119
KeyRotationInterval: keyRotationInterval,
@@ -146,6 +148,7 @@ func main() {
146148
rootCmd.PersistentFlags().StringVar(&dbDSN, "db-dsn", "control-plane.db", "Database DSN/Connection URL (avoid for postgres: embeds a password; prefer --db-dsn-path or SAM_DB_DSN)")
147149
rootCmd.PersistentFlags().StringVar(&dbDSNPath, "db-dsn-path", "", "Path to file containing the database DSN/Connection URL (overrides --db-dsn; or env SAM_DB_DSN)")
148150
rootCmd.Flags().StringVar(&oidcIssuer, "issuer", "", "OIDC Issuer URL (comma-separated)")
151+
rootCmd.Flags().StringVar(&oidcClientID, "oidc-client-id", "", "OAuth client ID advertised to joining nodes via /info (defaults to the first allowed audience)")
149152
rootCmd.Flags().StringVar(&allowedAudiencesFlag, "allowed-audiences", api.DefaultAudience, "Comma-separated list of allowed OIDC audiences")
150153
rootCmd.Flags().DurationVar(&keyRotationInterval, "key-rotation-interval", 24*time.Hour, "Key rotation interval (e.g. 24h). 0 disables rotation.")
151154
rootCmd.Flags().DurationVar(&keyGracePeriod, "key-grace-period", 1*time.Hour, "Key grace period for rotated keys.")

internal/controlplane/config.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ type Options struct {
2525
DriverName string
2626
DataSourceName string
2727
OIDCIssuer string
28+
OIDCClientID string // OAuth client id advertised via /info; defaults to the first allowed audience
2829
AllowedAudiences []string
2930
LeaseDuration time.Duration
3031
KeyRotationInterval time.Duration

internal/controlplane/server.go

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -368,6 +368,13 @@ func (s *Server) HandleInfo(w http.ResponseWriter, r *http.Request) {
368368
aud = s.config.AllowedAudiences[0]
369369
}
370370

371+
// aud == client_id only holds for id_token-model IdPs (dex, Google); an
372+
// explicit client id supports providers where the two differ.
373+
clientID := s.config.OIDCClientID
374+
if clientID == "" {
375+
clientID = aud
376+
}
377+
371378
// Fetch active routers
372379
activeRouters, err := s.store.GetActiveRouters(r.Context())
373380
if err != nil {
@@ -383,7 +390,7 @@ func (s *Server) HandleInfo(w http.ResponseWriter, r *http.Request) {
383390

384391
resp := &api.ControlPlaneInfoResponse{
385392
OidcIssuer: issuer,
386-
ClientId: aud,
393+
ClientId: clientID,
387394
Audience: aud,
388395
RouterAddresses: routerAddrs, // Reused this field for back-compatibility with bootstrap routers list
389396
}

internal/controlplane/server_test.go

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,41 @@ func TestControlPlaneBasic(t *testing.T) {
202202
t.Errorf("expected 0 active routers, got %d", len(info.RouterAddresses))
203203
}
204204

205+
// With an explicit OIDC client id, /info must advertise it while the
206+
// audience stays the first allowed audience.
207+
t.Run("explicit oidc client id", func(t *testing.T) {
208+
dbPath := filepath.Join(t.TempDir(), "cp-clientid.db")
209+
st, err := storage.NewSQLStore("sqlite", dbPath)
210+
if err != nil {
211+
t.Fatalf("failed to create store: %v", err)
212+
}
213+
defer func() { _ = st.Close() }()
214+
215+
srv2, err := NewServer(Options{
216+
DriverName: "sqlite",
217+
DataSourceName: dbPath,
218+
OIDCIssuer: issuer,
219+
OIDCClientID: "sam-cli-app",
220+
AllowedAudiences: []string{"sam-mesh-audience"},
221+
}, st)
222+
if err != nil {
223+
t.Fatalf("failed to create server: %v", err)
224+
}
225+
226+
rec := httptest.NewRecorder()
227+
srv2.HandleInfo(rec, httptest.NewRequest(http.MethodGet, "/info", nil))
228+
if rec.Code != http.StatusOK {
229+
t.Fatalf("unexpected /info status: %d", rec.Code)
230+
}
231+
var info2 api.ControlPlaneInfoResponse
232+
if err := proto.Unmarshal(rec.Body.Bytes(), &info2); err != nil {
233+
t.Fatalf("failed to unmarshal ControlPlaneInfoResponse: %v", err)
234+
}
235+
if info2.ClientId != "sam-cli-app" || info2.Audience != "sam-mesh-audience" {
236+
t.Errorf("unexpected client id/audience: %+v", &info2)
237+
}
238+
})
239+
205240
// 2. Test /keys
206241
resp, err = client.Get(baseURL + "/keys")
207242
if err != nil {

internal/identity/oidc.go

Lines changed: 19 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -44,31 +44,39 @@ func VerifyJWT(ctx context.Context, jwtStr string, allowedAudiences []string, pr
4444
}
4545
iss, _ := claims["iss"].(string)
4646

47-
// 2. Extract the audience
48-
var aud string
47+
// 2. Extract all audiences; the aud claim may be a string or an array.
48+
var auds []string
4949
switch a := claims["aud"].(type) {
5050
case string:
51-
aud = a
51+
auds = []string{a}
5252
case []any:
53-
if len(a) > 0 {
54-
aud, _ = a[0].(string)
53+
for _, v := range a {
54+
if s, ok := v.(string); ok {
55+
auds = append(auds, s)
56+
}
5557
}
5658
}
5759

58-
if aud == "" {
60+
if len(auds) == 0 {
5961
return nil, nil, fmt.Errorf("missing aud claim")
6062
}
6163

62-
// 3. Verify the audience matches one of your expected tenants/platforms
64+
// 3. Accept if any audience matches an allowed one: a multi-audience token
65+
// only needs to be intended for us, whatever else it names.
6366
validAudience := false
64-
for _, allowed := range allowedAudiences {
65-
if aud == allowed {
66-
validAudience = true
67+
for _, aud := range auds {
68+
for _, allowed := range allowedAudiences {
69+
if aud == allowed {
70+
validAudience = true
71+
break
72+
}
73+
}
74+
if validAudience {
6775
break
6876
}
6977
}
7078
if !validAudience {
71-
return nil, nil, fmt.Errorf("untrusted audience: %s", aud)
79+
return nil, nil, fmt.Errorf("untrusted audience(s): %s", strings.Join(auds, ", "))
7280
}
7381

7482
// 4. Route to the correct provider

internal/identity/oidc_test.go

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,39 @@ func TestVerifyJWT(t *testing.T) {
157157
}
158158
})
159159

160+
t.Run("allowed audience in any array position succeeds", func(t *testing.T) {
161+
claims := validClaims()
162+
claims["aud"] = []string{"some-other-audience", "sam-mesh-audience"}
163+
tokenStr := signToken(t, key, testKID, claims)
164+
165+
_, _, err := VerifyJWT(ctx, tokenStr, allowedAudiences, providers)
166+
if err != nil {
167+
t.Fatalf("expected success for multi-audience token, got: %v", err)
168+
}
169+
})
170+
171+
t.Run("audience array with no allowed entry is rejected", func(t *testing.T) {
172+
claims := validClaims()
173+
claims["aud"] = []string{"some-other-audience", "yet-another-audience"}
174+
tokenStr := signToken(t, key, testKID, claims)
175+
176+
_, _, err := VerifyJWT(ctx, tokenStr, allowedAudiences, providers)
177+
if err == nil {
178+
t.Fatal("expected error for audience array with no allowed entry")
179+
}
180+
})
181+
182+
t.Run("empty audience array is rejected", func(t *testing.T) {
183+
claims := validClaims()
184+
claims["aud"] = []string{}
185+
tokenStr := signToken(t, key, testKID, claims)
186+
187+
_, _, err := VerifyJWT(ctx, tokenStr, allowedAudiences, providers)
188+
if err == nil {
189+
t.Fatal("expected error for empty audience array")
190+
}
191+
})
192+
160193
t.Run("unknown issuer is rejected", func(t *testing.T) {
161194
claims := validClaims()
162195
claims["iss"] = "https://unknown-issuer.example"

internal/node/oidc.go

Lines changed: 35 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -383,6 +383,19 @@ func stdinIsInteractive() bool {
383383
return isatty.IsTerminal(fd) || isatty.IsCygwinTerminal(fd)
384384
}
385385

386+
// bodySnippet renders a response body for error messages, bounded so a
387+
// misbehaving server can't flood logs.
388+
func bodySnippet(body []byte) string {
389+
if len(body) > 256 {
390+
return strings.TrimSpace(string(body[:256])) + "..."
391+
}
392+
s := strings.TrimSpace(string(body))
393+
if s == "" {
394+
return "(empty response body)"
395+
}
396+
return s
397+
}
398+
386399
// DeviceLogin performs OAuth 2.0 Device Authorization Grant (RFC 8628).
387400
func (n *SamNode) DeviceLogin(ctx context.Context, deviceAuthURL, tokenURL, clientID, audience string, requestRefresh bool) (string, error) {
388401
if deviceAuthURL == "" {
@@ -514,7 +527,9 @@ func (n *SamNode) DeviceLogin(ctx context.Context, deviceAuthURL, tokenURL, clie
514527
continue
515528
}
516529

517-
body, readErr := io.ReadAll(tokenResp.Body)
530+
// Cap the read: the poll target comes from discovery and a misbehaving
531+
// server must not be able to exhaust memory.
532+
body, readErr := io.ReadAll(io.LimitReader(tokenResp.Body, 1<<20))
518533
if closeErr := tokenResp.Body.Close(); closeErr != nil {
519534
logger.Errorf("Failed to close response body: %v", closeErr)
520535
}
@@ -546,16 +561,29 @@ func (n *SamNode) DeviceLogin(ctx context.Context, deviceAuthURL, tokenURL, clie
546561
return jwt, nil
547562
}
548563

549-
if tokenResp.StatusCode != http.StatusBadRequest {
550-
return "", fmt.Errorf("token request failed with status: %s", tokenResp.Status)
551-
}
552-
553564
var errResp struct {
554565
Error string `json:"error"`
555566
ErrorDescription string `json:"error_description"`
556567
}
557-
if err := json.Unmarshal(body, &errResp); err != nil {
558-
return "", fmt.Errorf("failed to decode token polling error response: %w", err)
568+
// Best-effort: non-JSON bodies (proxy HTML, empty) leave Error empty
569+
// and are reported raw via bodySnippet below.
570+
_ = json.Unmarshal(body, &errResp)
571+
pending := errResp.Error == "authorization_pending" || errResp.Error == "slow_down"
572+
573+
// RFC 8628 §3.5 delivers polling errors as HTTP 400 with an OAuth error
574+
// body. Known exception: dex returns the pending/slow_down signals with
575+
// HTTP 401. Anything else is a real failure and is surfaced verbatim.
576+
rfcError := tokenResp.StatusCode == http.StatusBadRequest && errResp.Error != ""
577+
dexPending := tokenResp.StatusCode == http.StatusUnauthorized && pending
578+
if !rfcError && !dexPending {
579+
if errResp.Error != "" {
580+
msg := errResp.Error
581+
if errResp.ErrorDescription != "" {
582+
msg += " - " + errResp.ErrorDescription
583+
}
584+
return "", fmt.Errorf("token request failed with status %s: %s", tokenResp.Status, msg)
585+
}
586+
return "", fmt.Errorf("token request failed with status %s: %s", tokenResp.Status, bodySnippet(body))
559587
}
560588

561589
switch errResp.Error {

internal/node/oidc_test.go

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -510,6 +510,106 @@ func TestDeviceLoginRetriesOnTransientTokenError(t *testing.T) {
510510
}
511511
}
512512

513+
// TestDeviceLoginPending401 verifies polling survives providers (e.g. dex)
514+
// that return authorization_pending with HTTP 401 instead of the
515+
// RFC 8628-mandated 400: the OAuth error code in the body wins over the
516+
// HTTP status code.
517+
func TestDeviceLoginPending401(t *testing.T) {
518+
mux := http.NewServeMux()
519+
mux.HandleFunc("/device", func(w http.ResponseWriter, r *http.Request) {
520+
w.Header().Set("Content-Type", "application/json")
521+
_ = json.NewEncoder(w).Encode(map[string]interface{}{
522+
"device_code": "dev_code_1",
523+
"user_code": "CCCC-DDDD",
524+
"verification_uri": "https://example.com/device",
525+
"expires_in": 60,
526+
"interval": 1,
527+
})
528+
})
529+
530+
var pollCount int32
531+
mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) {
532+
w.Header().Set("Content-Type", "application/json")
533+
if atomic.AddInt32(&pollCount, 1) == 1 {
534+
w.WriteHeader(http.StatusUnauthorized)
535+
_ = json.NewEncoder(w).Encode(map[string]string{"error": "authorization_pending"})
536+
return
537+
}
538+
_ = json.NewEncoder(w).Encode(map[string]string{"id_token": "token_after_pending_401"})
539+
})
540+
541+
server := httptest.NewServer(mux)
542+
defer server.Close()
543+
544+
node := &SamNode{}
545+
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
546+
defer cancel()
547+
548+
token, err := node.DeviceLogin(ctx, server.URL+"/device", server.URL+"/token", "client_id_test", "sam-e2e", false)
549+
if err != nil {
550+
t.Fatalf("DeviceLogin failed: %v", err)
551+
}
552+
if token != "token_after_pending_401" {
553+
t.Fatalf("Expected token_after_pending_401, got %q", token)
554+
}
555+
if got := atomic.LoadInt32(&pollCount); got < 2 {
556+
t.Fatalf("expected at least 2 poll attempts, got %d", got)
557+
}
558+
}
559+
560+
// TestDeviceLoginFatalErrors verifies polling aborts on terminal responses:
561+
// a non-OAuth error body (regardless of status) and an OAuth error code
562+
// that is not a pending/slow_down signal.
563+
func TestDeviceLoginFatalErrors(t *testing.T) {
564+
cases := []struct {
565+
name string
566+
status int
567+
body string
568+
wantErr string
569+
}{
570+
{"non-oauth 401", http.StatusUnauthorized, `{"message":"nope"}`, "token request failed with status"},
571+
{"invalid_client 401", http.StatusUnauthorized, `{"error":"invalid_client","error_description":"unknown client"}`, "invalid_client"},
572+
{"access_denied 400", http.StatusBadRequest, `{"error":"access_denied"}`, "denied"},
573+
{"oauth-shaped 500 is not a protocol error", http.StatusInternalServerError, `{"error":"server_error"}`, "server_error"},
574+
{"html 502 surfaces the body", http.StatusBadGateway, `<html>bad gateway</html>`, "bad gateway"},
575+
}
576+
for _, tc := range cases {
577+
t.Run(tc.name, func(t *testing.T) {
578+
mux := http.NewServeMux()
579+
mux.HandleFunc("/device", func(w http.ResponseWriter, r *http.Request) {
580+
w.Header().Set("Content-Type", "application/json")
581+
_ = json.NewEncoder(w).Encode(map[string]interface{}{
582+
"device_code": "dev_code_1",
583+
"user_code": "EEEE-FFFF",
584+
"verification_uri": "https://example.com/device",
585+
"expires_in": 60,
586+
"interval": 1,
587+
})
588+
})
589+
mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) {
590+
w.Header().Set("Content-Type", "application/json")
591+
w.WriteHeader(tc.status)
592+
_, _ = w.Write([]byte(tc.body))
593+
})
594+
595+
server := httptest.NewServer(mux)
596+
defer server.Close()
597+
598+
node := &SamNode{}
599+
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
600+
defer cancel()
601+
602+
_, err := node.DeviceLogin(ctx, server.URL+"/device", server.URL+"/token", "client_id_test", "sam-e2e", false)
603+
if err == nil {
604+
t.Fatal("expected DeviceLogin to fail")
605+
}
606+
if !strings.Contains(err.Error(), tc.wantErr) {
607+
t.Fatalf("expected error containing %q, got %v", tc.wantErr, err)
608+
}
609+
})
610+
}
611+
}
612+
513613
func TestParseAuthMode(t *testing.T) {
514614
cases := []struct {
515615
in string

0 commit comments

Comments
 (0)